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.
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.