19 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 10f67d734c Org Bases open under the KMS master key or not at all
The base binary passes IAM_ORGANIZATION as IAMOrg, and plugins/org
reads base/MASTER_KEY_B64 beneath it at Register: 32 bytes, base64.
With KMS configured, Register fails when no org is set, when KMS does
not answer, or when the key is not 32 bytes. No key stored leaves org
Bases unencrypted and logs it; a 404 from the KMS HTTP door reads as a
missing secret.

An encrypted Base needs SQLite to encrypt pages as it writes them, which
only a linked SQLCipher codec does, so Register refuses a master key on a
build without one. Under a key, the connector refuses an org Base file
that is plaintext.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 13:58:52 -07:00
zeekayandHanzo Dev 8fab4b95db Base reads its master key from KMS, over the door KMS serves
There were two ways to hold one thing. PRINCIPAL_ENCRYPTION_KEY carried a
32-byte master key through the environment into a Secret beside the pod, and
KMSEndpoint named a KMS that was supposed to hold it. Only the first worked,
because the client spoke ZAP and refused an http endpoint at construction —
and no KMS in this estate serves ZAP. So the deployment set
KMS_URL=http://kms.hanzo.svc, Register returned that refusal, and the env key
was the only path left. A field named Deprecated sat beside it, read through a
two-branch accessor, which is a third spelling of the same value.

Now: one key, one coordinate, one credential.

- httpSecrets implements the existing secrets interface over /v1/kms. The
  transport is a detail behind that interface, which is why adding one is not
  adding a second way to read a secret.
- It authenticates as an IAM application using the client credentials this
  plugin already holds to talk to IAM. One identity per Base, not two.
- The org leads the path on the ZAP side because that segment is the base
  boundary. Over HTTP the org comes from the credential and another tenant's
  secret is unnameable, so the prefix is dropped rather than composed twice.
- PrincipalEncryptionKey, its Deprecated twin and principalKey() are gone. The
  master key is read at Register from base/MASTER_KEY_B64 under the org.
- Absent stays non-fatal: no KMS, or no key stored, runs with encryption off
  exactly as an unset variable did.

Also dropped the default endpoint zap.kms.svc.cluster.local:9999. Nothing
answers there, so it turned "this deployment has no KMS" — which callers handle
— into a transport error on every read. Unset now means unset.

The HTTP-rejection test asserted the belief that caused this; it now asserts
what is true, that the door needs a credential. Added a coordinate test for the
org-segment drop.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 11:46:12 -07:00
zeekayandHanzo Dev 89adc2a014 one reader answers what credential a request carries
A rule that decides what a credential may reach and a door that resolves
it agree only if they read the same bytes. Three readers each accepted a
different subset of the spellings: the rule read `Bearer ` case-sensitively
from one header, the auth door matched the scheme without regard to case
across three headers and accepted a bare token, bootnode read X-API-Key
first. Past the /v1/iam and /v1/idv mounts — which forward headers and
query verbatim with the key doors unbound — the rule is the only thing
standing, so a spelling it did not read was a key the issuer authenticated.

apis.Credential is that one reader: the Bearer scheme matched without
regard to case and separated by spaces or tabs, a bare token, the four
headers in order, then the `key` query parameter. A value naming another
scheme presents no token, so a Basic blob is not compared against key
prefixes. The query is split on `;` as well as `&`, because this answer
decides what gets forwarded and the far side may split either way.

bootnode classifies by the credential rather than by which header carried
it — the same token was a JWT under one header and unsupported under
another — and a shape it does not recognise is IAM's to judge.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 06:47:32 -07:00
zeekay a48501eb34 what a publishable key reaches is a list, not what no rule covered
The refusal was scoped to /v1/bases: it opened by asking whether the
address rule recognised the matched pattern and returned Next when it did
not, so a pk- key — which travels in ?key= and needs no Authorization
header — reached every address outside that subtree. Pointed at
/v1/cloud-sql/databases/{id}/connection it was handed a PostgreSQL
connection string with the admin password in it.

plugins/org/publishable.go now states the whole rule in one place and
inverts it. public() is every address such a key reaches — the record list
and view, the same rows on the /v1/rest wire, a create in a collection open
to anyone, a record's files, liveness — and every other address is refused
on the strength of what the credential IS. Naming what is public is a list
that can be read; naming what is private is a list that is never finished.

It reads the credential off the request rather than what a door published
about it, because /v1/iam unbinds the doors that resolve a key and forwards
it upstream; a rule about a credential has to answer where nothing resolved
it. Bound anonymously, so no group can Unbind it. Addresses compare as
WHOLE patterns, method and apis.Prefix() included, the way
apis.CreatesRecord names the one route it grants.

Replaces both publishableReachesNoBase and the method floor: an address off
the list is refused whatever the method, and the one write stays exactly
where it was. keyKind was per-request state only those two read.
2026-08-24 06:02:17 -07:00
zeekayandHanzo Dev f5031b962d the create exception names a route, not a family of routes
A publishable key may write in one place: the records API's own create, in
a collection whose createRule admits anyone. That exception was granted to
any route whose pattern ENDED "/collections/{collection}/records", and an
extension registers whatever path it likes — so a route that merely looked
like the create route inherited the one write a key printed in a web page
is allowed to make.

The question is now asked by the package that REGISTERS the route, and
answered by the route's whole pattern: the method, this deployment's mount
prefix, and the address the records group hangs its create at. The mount
prefix is one function rather than a value computed inline, read fresh
each time so no process can hold a stale one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 03:43:14 -07:00
zeekayandHanzo Dev b783f7288a a provider credential belongs to the org, not to everyone in it
The three rules on /v1/bases compared orgs and users and said nothing
about role, so /v1/bases/{org}/creds/* answered any member of the org: the
key its Stripe account charges on, readable by anyone who can read, and
writable by anyone who can write.

Read and write are one fact here — whoever reads the key can spend it, and
whoever writes it points the org's integration at an account of their own
choosing — so both are refused together, to a credential that carries only
membership. An org admin's token, the org's own secret key, and a platform
operator act for the org and reach them; the predicate is the one
actsForUser already asked for the user half.

address() now says whether the address names the org's credentials, and it
reads the segment the way it reads the org: the value the router filled
where the pattern names a wildcard, the segment itself where the pattern
states it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 03:27:57 -07:00
zeekayandHanzo Dev 91dd7c6b2b a person is filed under the name IAM files them under
The two doors that resolve a credential learn different things about one
account. IAM's key endpoint answers with the account and never its opaque id, so
a key can only name a person <owner>/<name>; a token carries that id in `sub`.
Taken as they arrived, one human is two people to _org_customers, whose unique
key is (org_id, user_id): two rows in one org, two customer ids, two broker
accounts, two vaults, and a KYC record bound through one door that answers
nobody at the other.

owner/name is the name both doors produce. It is IAM's own natural key for a
user, and a token carries both halves of it — the membership set leads with the
account's owner, and `name` is where IAM puts the username. caller spells it,
and a customer row is filed under it.

A person is recognised by either name the credential carries for them, so a row
filed under the older of the two stays reachable by whoever holds it. Refiling
those rows under the one name is a separate pass; nothing is stranded until it
runs, because both names still answer.

The username key gets a name in apis beside the subject and the orgs it is read
with, rather than a string literal in each door.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 02:40:49 -07:00
zeekayandHanzo Dev b780f1ba95 the rule and the handler read one address, the router's
The rules on /v1/bases derived the org and the user from the escaped request
path while the handlers read the segments the router filled. Two readings of one
request can disagree: http.ServeMux matches by splitting the ESCAPED path on "/"
and unescaping each segment, so spellings that differ before decoding are one
address to it and reach one handler, and a literal prefix comparison over the
undecoded string reports a different address than the one being served. Where
the two disagreed the rules answered about an address nobody was asking for and
passed the request through to a handler that had no check of its own.

address now reads the router's own answer — the pattern it chose and the values
it filled — so the rule names what the handler names. Segments come from the
pattern, so a route that writes the org into its address is read like one that
takes it as a parameter, and a route registered off the router at the same
address carries the rule the same way it did before.

The publishable key's one write exception moves to the same reading, for the
same reason: an exception granted on a string found somewhere in a URL is
granted at whatever address the router matched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 02:31:47 -07:00
zeekayandHanzo Dev 60055ec45d address IAM by its nouns, and present a credential when doing it
The five user verbs collapsed onto the user collection and the key half went to
the key endpoint, so this client asks the same questions at the addresses that
answer them:

  get-user?accessKey=  -> GET  /v1/iam/keys/principal   (envelope)
  get-users?field=     -> GET  /v1/iam/users            (the page itself)
  get-user?owner&email -> GET  /v1/iam/users            (the page itself)
  add-user             -> POST /v1/iam/users            (the record itself)

Three things follow from the move rather than accompany it.

The credential travels in the Authorization header. A query clientId is not a
credential IAM reads, so every one of these reads would have arrived
unauthenticated; the plugin installs the application credentials it already
holds in Config where the client is built, and each read presents them.

The collection narrows on the address and on nothing else. LookupByAttribute
answers "email" and refuses any other attribute instead of sending a filter that
would be ignored — an ignored filter answers with the org's first page, and
every row on it reads as a match.

EnsureUser reads before it writes. The create refuses a duplicate username and
admits a duplicate address, so creating first would file a second row under one
address and leave both unresolvable by it. A username lost to a racing provision
still resolves by the address the two agree on.

Where an address names two accounts the answer is a refusal, not the first row:
that choice is how somebody acts under a colleague's identity. The team invite
asks for two for the same reason and stays pending when it sees them.

A resolved key carries the account the key endpoint names — owner/name, unique
across tenants where a bare username is unique only inside one — and an
unresolved one yields no user at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 01:47:45 -07:00
zeekayandHanzo Dev 046526b72a a brand is not an address, so reading takes the shorter one
Minting a token has to address the brand, because IAM stamps the issuer from
the host it was asked on and a relying party that discovered through one brand
refuses a token issued by another. Reading answers about the credential
presented and does not depend on which brand was addressed.

They were one value, so a deployment could only have the brand — every read
left the cluster, crossed the public edge and came back to a pod one hop away,
and naming the pod instead broke minting.

They are two now. The brand stays where it was; the service is named when it
answers somewhere else, and reads take it. A bare address is ZAP.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 19:10:13 -07:00
zeekayandHanzo Dev f5fd2fb3c8 one statement omits every nested store, so the next level is not forgotten
A backup checkpoints the write-ahead log of the Base it runs on. A store
nested under that Base — an org's, and a user's under an org's — is neither
quiesced nor named, so it was copied mid-write beside a log copied at another
instant. Excluding it removes a copy that could not be restored.

Both levels are stated once, on create and on restore together, where a Base
is opened. The two must name the same thing: an archive that omits a subtree
while restore does not spare it deletes that subtree. The zip says what it
leaves out.

The network document described a per-file key scheme, with an environment
table read by no code, as though it shipped. It says what the code does.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:09:48 -07:00
zeekayandHanzo Dev 1ed57a3544 an archive carries what it can quiesce, and a limiter counts what it admits
A backup checkpoints one Base's write-ahead log inside one transaction. A
tenant's Base is separate handles with its own log, in neither, so its file
was being copied mid-write beside a log copied at another instant — a torn
copy rather than a smaller one. The archive names what it leaves out, in the
zip's own comment, where every listing tool already prints it.

Restore spares exactly what the archive omits. That symmetry is the whole
thing: an archive that skips the tenants and a restore that does not spare
them deletes every tenant. Both are stated in the package that owns the
directory, so core stays ignorant of tenancy.

The rate limiter refuses a request it cannot key. A forwarded request arrives
with no client address, and a limiter that admits what it cannot count is not
one. The way to be counted already exists: a gateway that sets a forwarded
header restores the client, through the trusted-proxy settings.

Opening a Base holds the registry's lock over the map alone. It had spanned
provision, construct, bootstrap and declare, and a read waits behind a
waiting writer, so one org's first request stalled every other request in the
process — measured at 209ms against a 101ms open.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 14:59:41 -07:00
zeekayandHanzo Dev 781d500922 this repo is public, so its documentation states contracts
LLM.md is tracked here and this repository is public, which makes every note
in it a published note. It had been written as a lab notebook: what each part
of the code does, and alongside that, where it does the wrong thing, with the
line number.

The engineering stays. What the dialect covers, how the tiers select, which
driver opens SQLite and why exactly one may, where the mount prefix applies,
what the admin UI is built on — all unchanged, and that is most of the file.

What changes is how a defect is written. Where the code now holds a contract,
the file states the contract and not the road to it. Where it does not yet,
the file says nothing, and the detail lives in the project's private record
instead. Nine such notes moved; the same edit went through eight source
comments that had grown the same voice.

Three notes were simply stale and now match the code: the org shards are
encrypted per org through sqlite.OpenDB, the auth identifiers resolve, and
update checks its post-image.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 11:42:43 -07:00
zeekay 7cd428c222 what a credential reaches is a property of the address
Three sentences govern everything under /v1/bases, and all three are bound on the
router and read the path rather than stated on the group that declares those
seven routes. tools/router inherits middleware down the GROUP tree and not down
the URL, so a route registered off the router at the identical address carried
nothing — GET /v1/bases/beta/usage as alpha answered 200 while its guarded
sibling answered 403. Two live mechanisms register exactly that way: /v1/bases
itself, and jsvm's routerAdd, which takes a path string from an extension and
validates none of it. The comment claiming that stating the rule on the subtree
made a handler that forgets it impossible to write is replaced, because it was
false. It is the address that carries the data.

A publishable key now reaches none of it, refused by kind. pk- is the key you put
in a web page; it travels in ?key=, so it needs no Authorization header, and the
only thing in its way was a check that publishable keys may not WRITE while every
read here is a GET. The key printed in a customer's HTML returned that org's
provider credentials and every member's billing identity. Read-only was the wrong
reading of publishable. The method check stays as a floor.

And the user a path names must be the caller. /customers/{userId} compared orgs
and said nothing whatever about users, so an ordinary member's own token read any
colleague's customer id, broker account, commerce customer and vault. An org
admin, the org's own secret key and a platform operator act for the org and still
reach every row.

GetCreds no longer falls through to the process environment. It read
os.Getenv(PROVIDER + "_API_KEY") whenever KMS held no row for an org+provider,
which is every org that has not configured that provider — and provider is a path
segment the caller writes, so naming openai, anthropic or github returned the
DEPLOYMENT's secret for it, the one the KMSSecret CRDs inject. A test pinned that
as correct; it asserts the refusal now.

/v1/compliance names no org in its address, so nothing above reaches it and it
has to ask. It took applicationId from the path and called requireAuth, which
answers whether there is a caller and never whose it is, so one tenant read
another's KYC status and started verifications against it. The application is
recorded on the caller's customer row when it is created and checked on every
read. No deployment configures ComplianceEndpoint and nothing reads an env var
for it, so the surface is unreachable as shipped; this is what it needs before it
is not.

Also: OrgService leaves the store on tenant Bases, where an extension could have
read every other tenant's credentials by naming them; a revoked key expires on
its own clock, since a token carries an expiry and a key has none; and both
proxies refuse a path that climbs out of their mount, which a decoded %2e%2e did.
2026-08-11 16:08:25 -07:00
zeekay b3a9b68a09 the org a request names is the org it acts in
Seven routes under /v1/bases/{org} name an org in their path, and one sentence
governs all of them. Three stated it, four did not, and all three statements were
wrong or missing in a different way.

/config and /customers asked nothing at all, so any valid token read any org's
configuration and provisioned customers in any org. Against production,
GET /v1/bases/{someone-elses-org}/config answers 404 rather than 403 — the check
passed and the request went looking for data. It found none only because no org
has a config row yet, which is the whole of what stands between this and a leak.

/creds asked checkOrgAccess, which looked the org up in the local _orgs
collection and returned TRUE when it found nothing, on the reasoning that the org
"may be managed outside the org system". Every org is: _orgs is created by
ensureOrgsCollection and written by nothing, because orgs are IAM's and Base only
reads them off the validated token. So the collection is permanently empty, the
lookup permanently misses, and the guard permanently grants. Behind it,
GET /v1/bases/{org}/creds/{provider} returns that org's KMS credentials and POST
writes them — which redirects a victim org's provider traffic to an address the
attacker chooses.

The rule is now stated once, on the subtree, where a handler cannot forget it:
the org named in the path must be the org the request acts in. Which org that is
was already settled at the door, from the verified credential — home, an org the
membership set admits and the request selected with X-Org-Id, or any org for a
platform operator carrying the reserved admin membership. Comparing here rather
than deciding again is what keeps the two answers from drifting, and it holds for
the key door as well as the token door because both resolve the same value.

The refusal is 403 and never 404. A 404 answers a question about the data, so a
caller who reads one learns its token reaches that org.

_orgs and _org_members go with it, along with findMembership, addMember,
checkAccess and roleHasPermission. They were a second answer to "who is in this
org" that nothing ever wrote, and the emptiness that made them look harmless was
exactly what made the guard admit everyone. isValidSlug went too — validateSlug
is the one that runs, and a second spelling of a rule is a rule with two answers.
2026-08-11 12:45:45 -07:00
zeekayandHanzo Dev 363797aba9 the master key the binary supplies now reaches the key derivation
examples/base sets PrincipalEncryptionKey from PRINCIPAL_ENCRYPTION_KEY. The
plugin read OrgEncryptionKey, its deprecated spelling. So the key a deployment
provided never arrived, OrgDB.masterKey stayed empty, and OrgDEK refused for
want of a master — per-org encryption could not be enabled even by an operator
who had done everything right, and nothing said why.

Config.principalKey prefers the current field and falls back to the deprecated
one, so both spellings work and the binary's is the one that wins.

This does not encrypt anything by itself. The files are still opened plaintext —
the derived key has no caller yet — which is what the package doc already says.
It removes the reason that could not change.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-11 12:23:53 -07:00
zeekay d46f495b7b a request reaches the Base of the org its token carries
Every read landed on the shared platform Base whatever org was named. The
per-org files existed and were written by nothing.

Three things were wrong, stacked. The org was taken from the `owner` claim,
which IAM stamps with the org of the APPLICATION a token was minted through, so
a token carrying alpha resolved to whichever org owned the sign-in app. It then
travelled over X-Org-Id, a header the client also sets and this overwrote. And
whichever org it landed on, the resolver opened a dbx pool, set it on the
request as "orgDB", and nothing anywhere read that key — every handler kept
using e.App. Provisioning ran, directories appeared, and the request went to
data.db regardless.

The org is now resolved once, in loadAuthToken, from the membership set
authz.Claims carries, and RequestEvent.App is pointed at that org's Base before
any handler runs. An org's Base is a Base: {DataDir}/orgs/{org}/data.db,
bootstrapped on first use, with its own collections and settings. apis.Bases is
the one lookup from an org to its Base; plugins/org registers it, and a
deployment that registers none has exactly one Base for everybody.

X-Org-Id and the rest of the identity headers are deleted on ingress, because
inbound they are a client asserting an identity. The header is read once first,
as the org a request says it means, and honored only where the token already
carries it — naming any other org answers 403 rather than quietly serving the
caller's own, which reads as an empty Base and is a different, false statement.

The reserved admin org's Base is the process's own, since _superusers, schema,
settings, backups and logs are process-wide. A token with no membership at all
is a machine and is refused rather than handed that Base: its org could only
come from `owner`, and reading that claim is the bug above.

PrincipalIsolation is gone. Registering the plugin is what gives an org a Base;
a deployment with orgs and one shared database is not a shape anyone wants. The
pool manager it gated went with it, having lost its only caller. The org's file
is data.db rather than org.db — the directory already says whose it is.
2026-08-11 11:57:25 -07:00
zeekayandHanzo Dev e65730d0a3 the memberships have to travel with the identity
/v1/bases answered [] for a caller in five orgs, and /v1/bases/{org} answered 403
for every one of them — including their own. It read like an empty account.

resolveJWKSToken mirrors a verified token onto e.Auth, and from then on
requireAuth takes the e.Auth path and builds its IAMUser from the record: id,
email, name. Not the memberships, because a record holds fields and membership
is not one of Base's. So OrgIDs was empty, and both handlers do exactly what they
should with an empty membership set — list nothing, refuse everything.

The claims are already decoded to answer the superuser question, so the org
slugs ride the request beside the other resolved claims and requireAuth reads
them there. One decode, one source, and the answer cannot disagree with the
token the request actually arrived on.

Verified with a real hanzo.id token: /v1/bases lists all five orgs with hanzo
exists:true, /v1/bases/hanzo returns it, and /v1/bases/acme — an org the caller
does not belong to — is still 403. apis and plugins/org pass.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-10 21:56:21 -07:00
zeekay e4b1f3c75c a Base is addressed at /v1/bases, and Base stops running a second auth
Two things wore the name "platform" here, and neither is the PaaS at
platform.hanzo.ai. That collision is why "where are my Bases?" had no answer a
person could act on: one prefix, two products, and the one you reached was
whichever you happened to mean.

A Base is what an org gets — its own collections, its own files, its own key —
so the resource a client addresses is /v1/bases. Not /v1/orgs: the org is IAM's
noun and Base does not get a second one. Everything scoped to an org now hangs
off the Base it belongs to (/v1/bases/{id}/config, /creds, /customers), and
compliance moves to /v1/compliance, a sibling of /v1/iam rather than a child of
Base's own resource. The package is `org`, which seven of its files already
were. Nothing addressed the old prefix — no client, no UI, no manifest — so this
renames a surface rather than breaking one.

The auth proxy is DELETED, not renamed. Its five routes reshaped bodies and
forwarded to /v1/iam/login, /signup, /userinfo and /send-verification-code —
which the opaque /v1/iam/{path...} passthrough beside it already reaches. So
Base carried a hand-written login, signup and one-time-code flow whose only job
was to be a second way to reach IAM. Auth is IAM's, and one way is the whole
point of that rule.

The prefix test that already banned /api/ bans this too, so the name cannot come
back: it reads route LITERALS through go/ast, so a comment explaining the ban is
prose and a route is a route. Verified to fail on the exact regression.
2026-08-10 19:18:08 -07:00