authz: platform authority is a membership, and this gate was reading a position
`hanzo status` could not see the cloud because the token it carries could not pass the gate. The reason was not the token. SanitizeIdentity granted platform sudo on `claims.homeOrg() == adminOrg`, which is `Claims.Orgs[0].Org` — a POSITIONAL read. IAM's MemberOrgRefs always writes the user's own org at index 0 and appends every granted membership after it, so that test could only ever be true for someone whose USER ROW lives in the admin org. An operator anchored in a brand org and GRANTED admin-org membership — the deliberate, signed, revocable way operators are actually made — was structurally unreachable by it. z@hanzo.ai carries orgs:[{hanzo,admin},{admin,admin}, {lux,admin},{pars,admin},{zoo,admin}] and was refused every superAdminOf surface because `admin` sits at index 1. This widens nothing. The authority was already signed by IAM and already guarded on the write side: memberships.mayGrant refuses to create a membership into a reserved org unless the caller is already a SuperAdmin, on the stated grounds that it "seeds admin-org (SuperAdmin) tenancy". IAM protects the grant as platform authority; this gate now honors it as platform authority. The two agreeing is the fix. Both admin scopes now ask the predicates hanzoai/authz publishes — the issuer's own statement of what its claims mean — narrowed by the one denial only cloud can make (the per-org KMS-sync machine, named by its owner-bound audience; authz decides machine-ness from an empty membership set, which a machine carrying memberships would defeat). cloud's private re-derivations are deleted rather than kept beside them, because two readings of one claim is the condition that package exists to end. The org-admin bit was the same defect one scope down: `claims.IsAdmin || isOrgAdmin(...)` is an UNSCOPED disjunct, so a token carrying the bit would have been org-admin in whatever org it switched INTO. authz.Claims.OrgAdmin scopes it to the home org. The term is inert against IAM today, which is exactly why it could sit there reading wrong — a dead term cannot fail a test. Closes the standing "scope the legacy isAdmin bit to home org" item by adopting the published predicate rather than patching the local one. There is no isAdmin CLAIM in any of this, in either direction. IAM mints one into NEITHER token: internal/oidc/jwt.go's Claims struct has no such field, and (*Signer).claims is the single place an Identity becomes a claim set, so the access token and the id_token differ only in aud/tokenType/nonce. The bit exists only as a user-row column that userinfo and whoami report in a response body. Copying it into the access token would have changed nothing, because no gate reads it. The adminOrg parameter is gone. The reserved org is the ISSUER's constant — IAM hardcodes `owner == "admin"` — so a consumer-side knob could only ever let cloud disagree with the contract it is reading. This file's own test doc asserted "Hanzo pins it to hanzo", which, had anyone set IAM_ADMIN_ORG that way, would have handed platform sudo to every member of the hanzo org while IAM considered none of them a SuperAdmin. Production never set it, so the default carried the truth by luck. TestPlatformSudoIsMembershipNotPosition pins the fact end to end through real JWKS-validated tokens, including z's live membership set verbatim. It fails against the positional predicate and passes against the set one; the negative cases (admin of every brand org but no reserved membership, a look-alike "Admin", an empty set) pass under both, which is how the change is shown to widen nothing. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -492,7 +492,7 @@ func TestAudit_AnonRequestNotAttributedToForgedOrg(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(nil, "admin")) // trust boundary
|
||||
app.Use(SanitizeIdentity(nil)) // trust boundary
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
|
||||
|
||||
|
||||
+35
-70
@@ -298,38 +298,39 @@ func isKMSMachinePrincipal(claims *idClaims) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isHuman reports whether a validated token positively identifies a PERSON. It is
|
||||
// the gate on both admin scopes: SuperAdmin and the org-admin signal are granted
|
||||
// only to a principal that answers yes.
|
||||
// platformSudo and orgAdmin are cloud's reading of the two admin scopes. Each is
|
||||
// the PUBLISHED predicate (authz.Claims — the issuer's own statement of what its
|
||||
// claims mean) narrowed by the one denial only cloud can make.
|
||||
//
|
||||
// The signal is the MEMBERSHIP SET, because that is the one IAM guarantees. Every
|
||||
// USER token carries at least the home org — store.MemberOrgRefs opens with
|
||||
// {user.Owner, HomeRole(user)} before it appends anything else. A
|
||||
// client_credentials token carries none, and IAM says why at the call that mints
|
||||
// it: "a machine token has no user and therefore no membership set", so "an app
|
||||
// token can never carry a tenancy it did not earn". Authority here IS membership,
|
||||
// so an identity holding no memberships holds none of it. Same predicate as
|
||||
// authz.Claims.Machine, so this side of the boundary and the edge agree.
|
||||
// The grant is never restated here, and that is the point. cloud used to derive
|
||||
// both scopes itself; the derivation drifted from the contract in the direction
|
||||
// that matters. Platform sudo asked `homeOrg == adminOrg` — Orgs[0], a POSITIONAL
|
||||
// read — while IAM always writes the user's own org at index 0, so an operator
|
||||
// granted admin-org membership (the deliberate, signed, revocable way operators
|
||||
// are made) could never satisfy it. authz.Claims.PlatformSudo asks the question
|
||||
// that was meant: is the reserved org anywhere in the signed set.
|
||||
//
|
||||
// WHAT THIS REPLACES: `type == "application"`. The IAM line this cloud runs against
|
||||
// stamps that value NOWHERE — `tokenType` takes exactly "access-token" and
|
||||
// "id-token" (internal/oidc/jwt.go), and the object/token_oauth.go the old comment
|
||||
// cited is not in it. So the check could not fire, every machine fell through to the
|
||||
// KMS-audience clause, and that clause matches ONE identity in the estate. A generic
|
||||
// admin-org client_credentials token therefore read as a HUMAN and took the
|
||||
// SuperAdmin arm: cross-tenant reads, and the org-switch that decides which ledger
|
||||
// pays. The repo's own probe (TestRedIso_C_AdminCrossOrg) reproduces it.
|
||||
//
|
||||
// The KMS machine is excluded on its own owner-bound audience as well, so that one
|
||||
// identity is denied the admin scopes by two independent signals.
|
||||
// THE NARROWING is the per-org KMS-sync machine, named by its owner-bound
|
||||
// audience. authz decides machine-ness from the membership set — a
|
||||
// client_credentials token carries none, which is correct for every machine IAM
|
||||
// mints today — so a machine that DID carry memberships would read as a person
|
||||
// there. cloud can name that identity and therefore denies it explicitly, on both
|
||||
// scopes. It is a DENIAL layered over the grant, never a second route to one:
|
||||
// removing it can only ever refuse more, never admit more.
|
||||
//
|
||||
// FAIL-CLOSED, and it costs something. A human token carrying no `orgs` — minted
|
||||
// before that claim shipped — is not positively a person and loses the two admin
|
||||
// before that claim shipped — is not positively a person and loses both admin
|
||||
// scopes. That is an availability cost bounded by the token TTL, taken deliberately
|
||||
// over the alternative: admitting an unidentifiable principal to the only
|
||||
// cross-tenant scope in the system.
|
||||
func isHuman(claims *idClaims) bool {
|
||||
return len(claims.Orgs) > 0 && !isKMSMachinePrincipal(claims)
|
||||
func platformSudo(claims *idClaims) bool {
|
||||
return claims.PlatformSudo() && !isKMSMachinePrincipal(claims)
|
||||
}
|
||||
|
||||
// orgAdmin reports whether claims administer the org the request ACTS in. See
|
||||
// platformSudo for why the grant is authz's and the denial is cloud's.
|
||||
func orgAdmin(claims *idClaims, org string) bool {
|
||||
return claims.OrgAdmin(org) && !isKMSMachinePrincipal(claims)
|
||||
}
|
||||
|
||||
// isMember reports whether org is in the token's signed membership set — the
|
||||
@@ -343,51 +344,15 @@ func isHuman(claims *idClaims) bool {
|
||||
// selection leaves the caller in their home org. An empty set (a legacy token, an
|
||||
// opaque key, a machine principal — IAM never mints `orgs` for a client_credentials
|
||||
// token) admits nothing, which is exactly the pre-claim behavior.
|
||||
// isOrgAdmin reports whether the token's signed membership set names the caller an
|
||||
// ADMIN of org — the role side of the same `orgs` claim isMember reads for the org
|
||||
// side. One claim, one parser, two questions.
|
||||
//
|
||||
// It exists because the top-level `isAdmin` claim is NOT the org-admin fact. IAM
|
||||
// mints `isAdmin` for the platform's own super-users; a normal org's admin carries
|
||||
// their adminness in `orgs[].role`, and nowhere else. Minting X-User-IsOrgAdmin from
|
||||
// `isAdmin` alone therefore demoted EVERY org admin to a plain member — the whole
|
||||
// org-scoped admin surface (the platform fleet board, the org admin panels) refused
|
||||
// its own owner with "admin required". Verified against production: z@hanzo.ai's
|
||||
// token carries orgs:[{org:hanzo,role:admin}] and no isAdmin, and
|
||||
// GET /v1/paas/apps answered 403.
|
||||
//
|
||||
// VERBATIM org comparison for isMember's reason (a fold would let a member of
|
||||
// "acme" claim "ACME"); the ROLE is folded, because a role is a closed vocabulary
|
||||
// IAM controls, not a tenant-chosen identifier. An empty set — a legacy token, an
|
||||
// opaque hk-/sk- key, a machine principal — admits nothing, so this can only ever
|
||||
// restate a membership IAM already signed.
|
||||
// "owner" counts, and leaving it out reproduced the very failure described above
|
||||
// one role-name deeper. IAM's coarse membership vocabulary is exactly three values
|
||||
// — owner, admin, member (iam internal/store/membership.go) — and `owner` is the
|
||||
// one it assigns to whoever CREATES an org: self-service provisioning writes
|
||||
// EnsureMembership(..., RoleOwner) so "a self-service org is born with nobody on
|
||||
// it" cannot happen (iam internal/oidc/provision.go). Matching only "admin"
|
||||
// therefore refused every self-serve org founder from their own org's admin
|
||||
// surface — the strictly worse version of the bug this function was written to
|
||||
// fix, because an owner cannot escalate themselves out of it.
|
||||
//
|
||||
// IAM's own money path already treats the two as one (billingAccountFor admits
|
||||
// {RoleOwner, RoleAdmin}); this is the authz half of that same fact.
|
||||
func isOrgAdmin(orgs []authz.Membership, org string) bool {
|
||||
if org == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range orgs {
|
||||
if o.Org != org {
|
||||
continue
|
||||
}
|
||||
// Role.Admits folds owner into admin, in the one place that vocabulary is
|
||||
// defined. This used to lower-case and trim the role string here, which is a
|
||||
// second reading of the same enum.
|
||||
return o.Role.Admits(authz.Write)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// The org-admin fact is authz.Claims.OrgAdmin's to state (see orgAdmin above).
|
||||
// cloud used to re-derive it here, folding the role vocabulary itself; that
|
||||
// derivation is deleted rather than kept beside the published one, because two
|
||||
// readings of one claim is exactly the condition this package exists to end.
|
||||
// Its history is worth keeping: matching only "admin" once locked every
|
||||
// self-serve founder out of their own org, since IAM writes RoleOwner for
|
||||
// whoever CREATES an org (EnsureMembership(..., RoleOwner), so a new org is not
|
||||
// "born with nobody on it"). Role.Admits folds owner into admin, in the one
|
||||
// place that vocabulary is defined. TestOrgAdminAdmitsOwner pins it end to end.
|
||||
|
||||
func isMember(orgs []authz.Membership, org string) bool {
|
||||
if org == "" {
|
||||
|
||||
@@ -65,8 +65,9 @@ func TestOrgAdminAdmitsOwner(t *testing.T) {
|
||||
{"empty org admits nothing", []authz.Membership{{Org: "acme", Role: "owner"}}, "", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isOrgAdmin(tc.orgs, tc.org); got != tc.want {
|
||||
t.Fatalf("isOrgAdmin(%v, %q) = %v; want %v", tc.orgs, tc.org, got, tc.want)
|
||||
claims := &idClaims{Claims: authz.Claims{Orgs: tc.orgs}}
|
||||
if got := orgAdmin(claims, tc.org); got != tc.want {
|
||||
t.Fatalf("orgAdmin(%v, %q) = %v; want %v", tc.orgs, tc.org, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestIAMEmbedBehindMiddlewareChain(t *testing.T) {
|
||||
app := zip.New(zip.Config{})
|
||||
// nil validator = the unauthenticated case: no JWKS, no principal established,
|
||||
// but forgeable X-User-*/X-Org-* headers are still stripped (defense in depth).
|
||||
app.Use(SanitizeIdentity(nil, "admin"))
|
||||
app.Use(SanitizeIdentity(nil))
|
||||
// DefaultPrice governs the IAM paths under test — that is the exemption being
|
||||
// asserted — and it reads the price each surface DECLARED (price.go). Every real
|
||||
// surface is Free or Metered, so the real table cannot supply a control: a 2xx on an
|
||||
|
||||
+97
-56
@@ -19,12 +19,23 @@ package cloud
|
||||
// catalog + /v1/pricing/sync, provisioning, ml, eval, plan) becomes
|
||||
// trustworthy without touching a single handler.
|
||||
//
|
||||
// ADMIN IS SUPERADMIN. The gateway writes X-User-IsAdmin from the JWT `isAdmin`
|
||||
// bool, which IAM also sets true for ORG admins (an org owner). The cloud admin
|
||||
// surfaces (global catalog writes, the literal "admin" org bucket) mean
|
||||
// SuperAdmin. So the admin authority here is granted ONLY to a validated
|
||||
// principal whose org IS the admin org (owner == adminOrg — IAM's IsSuperAdmin),
|
||||
// matching the gateway's admin-guard. An org admin gets NO admin authority.
|
||||
// ADMIN IS SUPERADMIN, AND SUPERADMIN IS MEMBERSHIP. The cloud admin surfaces
|
||||
// (global catalog writes, the literal "admin" org bucket, the CD plane) mean
|
||||
// platform sudo, not "admin of my own org". Both facts are decided here, from the
|
||||
// signed membership set, through the predicates authz publishes:
|
||||
//
|
||||
// X-User-IsAdmin ⟸ authz.Claims.PlatformSudo — a HUMAN who is a MEMBER of
|
||||
// the reserved admin org, at ANY position in `orgs`.
|
||||
// X-User-IsOrgAdmin ⟸ authz.Claims.OrgAdmin(effOrg) — admin/owner role in the
|
||||
// org the request ACTS in. Never platform authority.
|
||||
//
|
||||
// There is no `isAdmin` claim in this picture, in either direction. IAM mints one
|
||||
// into NEITHER token — internal/oidc/jwt.go's Claims struct has no such field, and
|
||||
// (*Signer).claims is the single place an Identity becomes a claim set, so the
|
||||
// access token and the id_token carry the same claims but for aud/tokenType/nonce.
|
||||
// The bit exists only as a user-row column that /v1/iam/userinfo and whoami report
|
||||
// in a RESPONSE BODY. Anything here that appeared to read it was reading a claim
|
||||
// that is never sent.
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -134,12 +145,13 @@ var subScopeHeaders = []string{"X-Project-Id", "X-App-Id", "X-Billing-Account-Id
|
||||
// - ALWAYS delete every header in authorityHeaders (a client copy never
|
||||
// survives — this alone kills X-User-IsAdmin forgery).
|
||||
// - Validate a Bearer / Basic / session-cookie JWT, if present:
|
||||
// SuperAdmin (homeOrg == adminOrg, human) — membership of the reserved admin
|
||||
// org IS the predicate; the isAdmin bit is deliberately not a second term.
|
||||
// This line previously read "claims.isAdmin && owner == adminOrg", which was
|
||||
// wrong twice over: the code has never consulted isAdmin here (see
|
||||
// TestSuperAdminGate_IsAdminOrgMembership), and `owner` named the APP's org,
|
||||
// not the user's.
|
||||
// SuperAdmin (authz.Claims.PlatformSudo — a human MEMBER of the reserved admin
|
||||
// org, at any position in the signed `orgs` set). Membership IS the predicate;
|
||||
// the isAdmin bit is deliberately not a second term, and IAM does not mint one.
|
||||
// This test used to read `homeOrg == adminOrg`, i.e. `Orgs[0].Org` — a
|
||||
// POSITIONAL read that IAM's own ordering (home org always first) made true
|
||||
// only for a user whose ROW lives in the admin org, so every operator granted
|
||||
// admin-org membership was silently refused.
|
||||
// → X-User-IsAdmin=true; X-Org-Id = the requested org when present
|
||||
// (admin org-switch), else the home org.
|
||||
// any other principal (incl. org admins, normal users)
|
||||
@@ -168,8 +180,17 @@ var subScopeHeaders = []string{"X-Project-Id", "X-App-Id", "X-Billing-Account-Id
|
||||
// fails OPEN. The availability cost is bounded to COLD caches: the edge key cache is
|
||||
// stale-on-error (a warm cache keeps validating through a transient JWKS outage),
|
||||
// so only a from-cold JWKS failure degrades to anonymous-403.
|
||||
func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
|
||||
adminOrg = strings.TrimSpace(adminOrg)
|
||||
// The reserved admin org is NOT a parameter. It is the ISSUER's constant — IAM
|
||||
// hardcodes it (store.IsSuperAdmin: `owner == "admin"`, and the reserved-org set
|
||||
// beneath it) and authz publishes it as authz.AdminOrg — so a consumer-side knob
|
||||
// could only ever let cloud DISAGREE with the token contract it is reading. That
|
||||
// was not hypothetical: this file's own test doc asserted "Hanzo pins it to
|
||||
// 'hanzo'", which, had anyone set IAM_ADMIN_ORG that way, would have handed
|
||||
// platform sudo to every member of the hanzo org while IAM considered none of
|
||||
// them a SuperAdmin. Production never set it, so the default carried the truth by
|
||||
// luck. A knob whose only reachable non-default setting is an estate-wide
|
||||
// escalation is not configuration; it is a loaded footgun, and it is now gone.
|
||||
func SanitizeIdentity(v *identityValidator) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
req := c.Fiber().Request()
|
||||
|
||||
@@ -263,42 +284,52 @@ func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
|
||||
// project; a project owned by neither is refused).
|
||||
var effOrg string
|
||||
switch {
|
||||
case owner != "" && owner == adminOrg && isHuman(claims):
|
||||
// SuperAdmin ⟺ the principal's HOME org IS the reserved admin org AND the
|
||||
// principal is HUMAN.
|
||||
case owner != "" && platformSudo(claims):
|
||||
// SuperAdmin ⟺ a HUMAN who is a MEMBER of the reserved admin org — asked
|
||||
// through authz.Claims.PlatformSudo, the predicate the ISSUER's own claim
|
||||
// package publishes. cloud does not re-derive it, because cloud re-deriving
|
||||
// it is what this arm got wrong.
|
||||
//
|
||||
// `owner` here is the USER's org (claims.homeOrg, from the signed `orgs`
|
||||
// set) — NOT the `owner` claim. This predicate USED to read that claim,
|
||||
// which IAM stamps with the APPLICATION's org, so any human token minted
|
||||
// by an app belonging to the reserved admin org conferred platform admin
|
||||
// regardless of who the user was. Only now, reading the user's own org, is
|
||||
// this genuinely the equality IAM's User.IsSuperAdmin() uses (user.Owner ==
|
||||
// conf.AdminOrg); the comment previously claimed that parity while
|
||||
// comparing a different value, and a confidently wrong comment on a
|
||||
// security predicate is how it survived unnoticed.
|
||||
// IT USED TO READ `owner == adminOrg`, i.e. claims.homeOrg(), i.e.
|
||||
// Claims.Orgs[0].Org — a POSITIONAL read. IAM's MemberOrgRefs always writes
|
||||
// the user's OWN org at index 0 and appends every granted membership after
|
||||
// it (iam internal/store/membership.go), so that test could only ever be
|
||||
// true for someone whose USER ROW lives in the admin org. An operator
|
||||
// provisioned into a brand org and then granted admin-org membership — the
|
||||
// deliberate, signed, revocable way operators are actually made — was
|
||||
// UNREACHABLE by it. z@hanzo.ai carries
|
||||
// orgs:[{hanzo,admin},{admin,admin},{lux,admin},{pars,admin},{zoo,admin}]
|
||||
// and was refused every platform surface, because `admin` sits at index 1.
|
||||
//
|
||||
// This WIDENS NOTHING. The authority was already signed by IAM and already
|
||||
// guarded on the write side: memberships.mayGrant refuses to create a
|
||||
// membership into a reserved org unless the caller is ALREADY a SuperAdmin,
|
||||
// on the stated grounds that it "seeds admin-org (SuperAdmin) tenancy". IAM
|
||||
// protects the grant as platform authority; this arm now honors it as
|
||||
// platform authority. The two agreeing is the fix — a grant the issuer
|
||||
// treats as sudo must not be inert at the resource server.
|
||||
//
|
||||
// Membership ALONE decides, deliberately — the isAdmin bit is NOT a second
|
||||
// term. The admin org holds only SuperAdmins (provisioned in, never
|
||||
// promoted), so admin-org membership IS the fact; adding isAdmin would deny
|
||||
// every operator whose user row lacks the bit, which is a lockout, not a
|
||||
// hardening. That contract is pinned by
|
||||
// TestSuperAdminGate_IsAdminOrgMembership ("ONE predicate, no second
|
||||
// signal") and relied on by TestMasqueradeSpendsOwnBooks, whose SuperAdmin
|
||||
// carries isAdmin=false. Reading the USER's org is what closes the
|
||||
// escalation; a second signal is not needed and is not free. The human gate
|
||||
// (isHuman) is the necessary companion once the audience is no longer a gate —
|
||||
// otherwise ANY admin-org client_credentials app, not just the KMS-sync one,
|
||||
// would inherit platform-admin and read every org. A machine principal falls
|
||||
// through to the owner-scoped case below (org-scoped, not super). Honored
|
||||
// org-switch for the human admin.
|
||||
// term (IAM mints no such claim into ANY token; internal/oidc/jwt.go's
|
||||
// Claims struct has no such field, and one live token confirms it). The
|
||||
// admin org holds only SuperAdmins, provisioned in and never promoted, so
|
||||
// membership IS the fact; adding a role term would revoke sudo from an
|
||||
// admin-org user whose row carries isAdmin=false, which is a lockout, not a
|
||||
// hardening — TestMasqueradeSpendsOwnBooks pins exactly that principal.
|
||||
//
|
||||
// It is a POSITIVE human test rather than a negated machine one on purpose:
|
||||
// this grants the only cross-tenant scope in the system, so an unidentifiable
|
||||
// principal must be refused, not admitted by default. The org question just
|
||||
// below answers with the opposite polarity — it GRANTS an org from an
|
||||
// app-selected claim, so it needs a positively identified MACHINE — and one
|
||||
// predicate serving both is how a legacy human token came to be handed the
|
||||
// app's org instead of failing closed.
|
||||
// The HUMAN narrowing lives inside PlatformSudo (Claims.Machine: an App
|
||||
// principal, or an empty membership set — a client_credentials token, which
|
||||
// IAM never mints `orgs` for). It is a POSITIVE human test, not a negated
|
||||
// machine one: this grants the only cross-tenant scope in the system, so an
|
||||
// unidentifiable principal must be refused rather than admitted by default.
|
||||
// A machine falls through to the owner-scoped arm below (org-scoped, not
|
||||
// super). Honored org-switch for the human admin.
|
||||
//
|
||||
// `owner != ""` stays as the ANCHOR guard, and is a separate question from
|
||||
// authority: an identity whose home org is unrepresentable (a whitespace/
|
||||
// control/format rune — OrgHasUnsafeRune) has no billing anchor and no
|
||||
// effective org to fall back to, so it fails closed here exactly as it does
|
||||
// in the arm below rather than acting with an empty X-Org-Id.
|
||||
req.Header.Set(authz.HeaderUserAdmin, "true")
|
||||
if cliOrg != "" {
|
||||
effOrg = cliOrg
|
||||
@@ -341,15 +372,25 @@ func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
|
||||
// path grants NEITHER global NOR org admin, and the audience widening can never
|
||||
// be leveraged into an admin surface. Like every authorityHeader it is stripped
|
||||
// on ingress and re-injected ONLY here from validated claims, unforgeable.
|
||||
// The fact has TWO signed sources and needs both: `isAdmin` (the
|
||||
// platform's own super-users) and the EFFECTIVE org's role in the signed
|
||||
// membership set (`orgs[].role == "admin"` — how a normal org's admin
|
||||
// carries their adminness, and the ONLY place it appears). Reading only
|
||||
// isAdmin demoted every org admin to a plain member, so the org-scoped
|
||||
// admin surfaces refused their own owner. Keyed on effOrg, not on the
|
||||
// home org: the bit must describe the org the request ACTS in, so
|
||||
// switching to an org you merely belong to never carries admin across.
|
||||
if (claims.IsAdmin || isOrgAdmin(claims.Orgs, effOrg)) && isHuman(claims) {
|
||||
// Asked through authz.Claims.OrgAdmin — the same published predicate, for
|
||||
// the same reason: one reading of one claim, owned by the party that signs
|
||||
// it. It answers from the EFFECTIVE org's role in the signed membership set
|
||||
// (`orgs[].role`, folding owner into admin), and it SCOPES the legacy
|
||||
// `isAdmin` bit to the HOME org (`c.IsAdmin && org == c.Home()`).
|
||||
//
|
||||
// That scoping is the fix this line needed. It read `claims.IsAdmin ||
|
||||
// isOrgAdmin(...)` — an UNSCOPED disjunct, so a token carrying the bit would
|
||||
// have been org-admin in whatever org it switched INTO, not just its own.
|
||||
// The term is inert against IAM today (IAM mints no isAdmin claim at all),
|
||||
// which is precisely why it could sit there reading wrong: a dead term
|
||||
// cannot fail a test. Scoped to home, it is correct whether or not some
|
||||
// issuer ever starts minting it — forward-safe rather than accidentally-safe.
|
||||
//
|
||||
// Keyed on effOrg, not the home org: the bit must describe the org the
|
||||
// request ACTS in, so switching to an org you merely belong to never carries
|
||||
// admin across. The machine exclusion is inside OrgAdmin (Claims.Machine),
|
||||
// so a client_credentials identity is granted neither admin scope.
|
||||
if orgAdmin(claims, effOrg) {
|
||||
req.Header.Set(authz.HeaderUserOrgAdmin, "true")
|
||||
}
|
||||
sanitizeSubScopes(c, effOrg, claims.renderProject(), cliApp, claims.renderBillingAccount())
|
||||
@@ -384,7 +425,7 @@ func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
|
||||
// for the boundary, so Serve and integration tests wire it identically — no second
|
||||
// copy of the validator-construction glue to drift.
|
||||
func IdentityMiddleware(cfg *Config) zip.Handler {
|
||||
return SanitizeIdentity(newIdentityValidator(cfg.IAMIssuer, cfg.JWKSURL, 0), cfg.AdminOrg)
|
||||
return SanitizeIdentity(newIdentityValidator(cfg.IAMIssuer, cfg.JWKSURL, 0))
|
||||
}
|
||||
|
||||
// sanitizeSubScopes re-injects the org SUB-SCOPES (X-Project-Id, X-App-Id) for a
|
||||
|
||||
@@ -34,7 +34,7 @@ func identityProbe(t *testing.T, key *rsa.PrivateKey, jwksURL string, tok string
|
||||
v := newIdentityValidator(testIssuer, jwksURL, 0)
|
||||
var got string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(cx *zip.Ctx) error {
|
||||
got = cx.Header("X-Billing-Account-Id")
|
||||
return cx.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
@@ -154,7 +154,7 @@ func TestSanitizeIdentity_AnonymousCarriesNoBillingAccount(t *testing.T) {
|
||||
|
||||
var got string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(cx *zip.Ctx) error {
|
||||
got = cx.Header("X-Billing-Account-Id")
|
||||
return cx.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
|
||||
@@ -54,7 +54,7 @@ func orgFor(t *testing.T, claims idClaims, selected string) (org string, admin b
|
||||
tok := signWith(t, key, claims)
|
||||
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
org, admin = c.Org(), c.IsAdmin()
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
|
||||
@@ -43,7 +43,7 @@ func iamKeyServer(t *testing.T, owner, name string) *httptest.Server {
|
||||
func orgScopedProbe(t *testing.T, v *identityValidator, mutate func(*http.Request)) (status int, org string) {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/v1/agents", func(c *zip.Ctx) error {
|
||||
o, ok := principal.Org(c)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package cloud
|
||||
|
||||
// Platform authority is MEMBERSHIP of the reserved admin org, at any position in
|
||||
// the signed `orgs` set — not the home org, and not the first element.
|
||||
//
|
||||
// This file exists because the distinction is invisible to every other test here.
|
||||
// The gate read claims.homeOrg() == adminOrg, i.e. Claims.Orgs[0].Org, and IAM's
|
||||
// MemberOrgRefs ALWAYS writes the user's own org at index 0 and appends granted
|
||||
// memberships after it (iam internal/store/membership.go). So the positional read
|
||||
// was equivalent to the set read for exactly the population the old tests built —
|
||||
// a user whose row already lives in the admin org — and wrong for the population
|
||||
// that actually operates the estate: an operator anchored in a brand org who was
|
||||
// GRANTED admin-org membership.
|
||||
//
|
||||
// That grant is not incidental. IAM guards it as platform authority on the write
|
||||
// side: memberships.mayGrant refuses to create a membership into a reserved org
|
||||
// unless the caller is already a SuperAdmin, because it "seeds admin-org
|
||||
// (SuperAdmin) tenancy". A grant the issuer treats as sudo must not be inert at
|
||||
// the resource server. Production is the proof: z@hanzo.ai carries
|
||||
// orgs:[{hanzo,admin},{admin,admin},{lux,admin},{pars,admin},{zoo,admin}] and was
|
||||
// refused every superAdminOf surface — the CD plane, settings, version — because
|
||||
// `admin` sits at index 1.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/authz"
|
||||
)
|
||||
|
||||
// TestPlatformSudoIsMembershipNotPosition drives real JWKS-validated tokens
|
||||
// through SanitizeIdentity and reads the X-User-IsAdmin a downstream gate sees.
|
||||
//
|
||||
// The load-bearing case is "operator anchored in a brand org": it FAILS against a
|
||||
// homeOrg/Orgs[0] predicate and passes against a set-membership one. Every other
|
||||
// case pins that widening nothing else: a member of no reserved org gets nothing
|
||||
// no matter how many orgs they hold or what role they hold in them.
|
||||
func TestPlatformSudoIsMembershipNotPosition(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwks := jwksServer(t, &key.PublicKey)
|
||||
v := newIdentityValidator(testIssuer, jwks.URL, 0)
|
||||
future := time.Now().Add(time.Hour)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
orgs []authz.Membership
|
||||
// wantAdmin is the platform-sudo grant; wantOrg is the org the request acts
|
||||
// in when no org is selected — it must stay the ANCHOR, never the reserved org.
|
||||
wantAdmin bool
|
||||
wantOrg string
|
||||
}{
|
||||
{
|
||||
// THE REGRESSION. Anchored in a brand org, granted the reserved org.
|
||||
// Orgs[0] is "hanzo", so a positional predicate refuses this operator.
|
||||
name: "operator anchored in a brand org, granted admin membership",
|
||||
orgs: []authz.Membership{{Org: "hanzo", Role: authz.Admin}, {Org: "admin", Role: authz.Admin}},
|
||||
wantAdmin: true,
|
||||
wantOrg: "hanzo",
|
||||
},
|
||||
{
|
||||
// z@hanzo.ai's real production membership set, verbatim. `admin` at index 1.
|
||||
name: "the live operator set",
|
||||
orgs: []authz.Membership{
|
||||
{Org: "hanzo", Role: authz.Admin}, {Org: "admin", Role: authz.Admin},
|
||||
{Org: "lux", Role: authz.Admin}, {Org: "pars", Role: authz.Admin},
|
||||
{Org: "zoo", Role: authz.Admin},
|
||||
},
|
||||
wantAdmin: true,
|
||||
wantOrg: "hanzo",
|
||||
},
|
||||
{
|
||||
// Position is irrelevant, not merely "index 1 also works".
|
||||
name: "admin membership last in a long set",
|
||||
orgs: []authz.Membership{{Org: "a", Role: authz.Member}, {Org: "b", Role: authz.Member}, {Org: "admin", Role: authz.Member}},
|
||||
wantAdmin: true,
|
||||
wantOrg: "a",
|
||||
},
|
||||
{
|
||||
// Unchanged: a user whose ROW lives in the admin org. The population the
|
||||
// old predicate did serve must keep working.
|
||||
name: "operator whose home org IS the admin org",
|
||||
orgs: []authz.Membership{{Org: "admin", Role: authz.Admin}},
|
||||
wantAdmin: true,
|
||||
wantOrg: "admin",
|
||||
},
|
||||
{
|
||||
// Membership of the reserved org is the fact; the ROLE within it is not a
|
||||
// second term. An admin-org user whose row carries isAdmin=false gets
|
||||
// {admin, member} from IAM's HomeRole and is a SuperAdmin today —
|
||||
// TestMasqueradeSpendsOwnBooks pins exactly that principal. Adding a role
|
||||
// term here would revoke sudo from them: a lockout, not a hardening.
|
||||
name: "plain member of the reserved org is still an operator",
|
||||
orgs: []authz.Membership{{Org: "hanzo", Role: authz.Admin}, {Org: "admin", Role: authz.Member}},
|
||||
wantAdmin: true,
|
||||
wantOrg: "hanzo",
|
||||
},
|
||||
{
|
||||
// THE WIDENING TEST. Admin of every brand org in the estate, member of no
|
||||
// reserved org: still not platform authority.
|
||||
name: "admin of many orgs, none of them reserved",
|
||||
orgs: []authz.Membership{{Org: "hanzo", Role: authz.Admin}, {Org: "lux", Role: authz.Owner}, {Org: "zoo", Role: authz.Admin}},
|
||||
wantAdmin: false,
|
||||
wantOrg: "hanzo",
|
||||
},
|
||||
{
|
||||
// Verbatim comparison, like every other org compare at this boundary. A
|
||||
// fold would make an org someone can self-serve the reserved one.
|
||||
name: "a look-alike reserved org is not the reserved org",
|
||||
orgs: []authz.Membership{{Org: "hanzo", Role: authz.Admin}, {Org: "Admin", Role: authz.Admin}},
|
||||
wantAdmin: false,
|
||||
wantOrg: "hanzo",
|
||||
},
|
||||
{
|
||||
name: "no memberships at all is a machine, never an operator",
|
||||
orgs: nil,
|
||||
wantAdmin: false,
|
||||
wantOrg: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claims := tokenClaims("hanzo-cli", "some-app-org", "op@hanzo.ai", false, future)
|
||||
claims.Orgs = tc.orgs
|
||||
|
||||
app, seen := newIdentityApp(t, v)
|
||||
probe(t, app, func(r *http.Request) {
|
||||
r.Header.Set("Authorization", "Bearer "+signWith(t, key, claims))
|
||||
// Forged on top of the real token, so the ingress strip is exercised in
|
||||
// the same pass: a client copy must never be what grants this.
|
||||
r.Header.Set("X-User-IsAdmin", "true")
|
||||
})
|
||||
|
||||
if seen.admin != tc.wantAdmin {
|
||||
t.Errorf("platform sudo = %v; want %v (orgs=%v)", seen.admin, tc.wantAdmin, tc.orgs)
|
||||
}
|
||||
if seen.org != tc.wantOrg {
|
||||
t.Errorf("effective org = %q; want %q — sudo must not move the anchor", seen.org, tc.wantOrg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformSudoDoesNotRideTheAppOrg pins that the grant reads the SUBJECT's
|
||||
// membership set and never the `owner` claim, which IAM stamps with the
|
||||
// APPLICATION's org. Signing in through an admin-org-owned app must not confer
|
||||
// platform authority on a user who holds no reserved membership — otherwise the
|
||||
// authority is a property of whichever client you logged in through.
|
||||
func TestPlatformSudoDoesNotRideTheAppOrg(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwks := jwksServer(t, &key.PublicKey)
|
||||
v := newIdentityValidator(testIssuer, jwks.URL, 0)
|
||||
|
||||
// owner = "admin" (the APP's org), but the subject belongs only to "acme".
|
||||
claims := tokenClaims("admin-console", "admin", "user@acme.test", true, time.Now().Add(time.Hour))
|
||||
claims.Orgs = []authz.Membership{{Org: "acme", Role: authz.Admin}}
|
||||
|
||||
app, seen := newIdentityApp(t, v)
|
||||
probe(t, app, bearer(signWith(t, key, claims)))
|
||||
|
||||
if seen.admin {
|
||||
t.Error("the app's org conferred platform sudo on a user who holds no reserved membership")
|
||||
}
|
||||
if seen.org != "acme" {
|
||||
t.Errorf("effective org = %q; want %q (the SUBJECT's org, not the app's)", seen.org, "acme")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperatorTokenIsNotSpecialToAnyClient pins that platform authority is a
|
||||
// property of the PRINCIPAL, not of the client that minted the token. hanzo-cli is
|
||||
// a PUBLIC client — it ships to users' machines and holds no secret by design — so
|
||||
// the same operator arriving through it must get the same authority and no more.
|
||||
// The audience is deliberately not a gate here (auth_identity.go: a valid
|
||||
// signature from a trusted issuer already proves IAM minted it for one of its own
|
||||
// registered apps), so this pins that a public client neither gains nor loses
|
||||
// authority relative to a confidential one.
|
||||
func TestOperatorTokenIsNotSpecialToAnyClient(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwks := jwksServer(t, &key.PublicKey)
|
||||
v := newIdentityValidator(testIssuer, jwks.URL, 0)
|
||||
operator := []authz.Membership{{Org: "hanzo", Role: authz.Admin}, {Org: "admin", Role: authz.Admin}}
|
||||
tenant := []authz.Membership{{Org: "acme", Role: authz.Admin}}
|
||||
|
||||
for _, aud := range []string{"hanzo-cli", "hanzo-console", "hanzo-studio"} {
|
||||
t.Run("operator via "+aud, func(t *testing.T) {
|
||||
claims := tokenClaims(aud, "hanzo", "z@hanzo.ai", false, time.Now().Add(time.Hour))
|
||||
claims.Orgs = operator
|
||||
app, seen := newIdentityApp(t, v)
|
||||
probe(t, app, bearer(signWith(t, key, claims)))
|
||||
if !seen.admin {
|
||||
t.Errorf("operator was refused platform sudo through client %q", aud)
|
||||
}
|
||||
})
|
||||
t.Run("tenant via "+aud, func(t *testing.T) {
|
||||
claims := tokenClaims(aud, "acme", "user@acme.test", true, time.Now().Add(time.Hour))
|
||||
claims.Orgs = tenant
|
||||
app, seen := newIdentityApp(t, v)
|
||||
probe(t, app, bearer(signWith(t, key, claims)))
|
||||
if seen.admin {
|
||||
t.Errorf("a tenant was granted platform sudo through client %q", aud)
|
||||
}
|
||||
if !seen.orgAdmin {
|
||||
t.Errorf("an org admin lost their OWN org's admin scope through client %q", aud)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var _ = jwt.ClaimStrings{}
|
||||
@@ -51,7 +51,7 @@ func walletProbe(t *testing.T, claims idClaims, selected string) (billOrg, billU
|
||||
|
||||
done := make(chan struct{})
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
in := identityFromCtx(c)
|
||||
billOrg, billUser = in.Org, in.User
|
||||
@@ -265,7 +265,7 @@ func TestAnonymousRoutesAreUntouched(t *testing.T) {
|
||||
v := newIdentityValidator(testIssuer, jwks.URL, 0)
|
||||
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/health", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
@@ -316,7 +316,7 @@ func TestAnonymousGrantsNoPrincipal(t *testing.T) {
|
||||
var gotUser string
|
||||
var gotAdmin bool
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
gotUser, gotAdmin = c.User(), c.IsAdmin()
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
@@ -383,7 +383,7 @@ func TestHomeOrgHeaderSurvivesSwitch(t *testing.T) {
|
||||
|
||||
var gotOwner, gotOrg string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/anchor", func(c *zip.Ctx) error {
|
||||
gotOwner, gotOrg = c.Header("X-User-Owner"), c.Org()
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
|
||||
@@ -118,7 +118,7 @@ func newScopeApp(t *testing.T, v *identityValidator) (*zip.App, *scopeCap) {
|
||||
t.Helper()
|
||||
got := &scopeCap{}
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
got.org = c.Org()
|
||||
got.user = c.User()
|
||||
|
||||
@@ -99,7 +99,7 @@ func newIdentityApp(t *testing.T, v *identityValidator) (*zip.App, *captured) {
|
||||
t.Helper()
|
||||
got := &captured{}
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
got.org = c.Org()
|
||||
got.user = c.User()
|
||||
@@ -357,7 +357,7 @@ func TestSanitizeIdentity_OrgAdminHeader(t *testing.T) {
|
||||
|
||||
var gotAdmin, gotOrgAdmin, gotOrg string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(cx *zip.Ctx) error {
|
||||
gotAdmin = cx.Header("X-User-IsAdmin")
|
||||
gotOrgAdmin = cx.Header("X-User-IsOrgAdmin")
|
||||
@@ -472,7 +472,7 @@ func TestSanitizeIdentity_StampsUserName(t *testing.T) {
|
||||
|
||||
var gotName, gotID, gotOrg string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(cx *zip.Ctx) error {
|
||||
gotName = cx.Header("X-User-Name")
|
||||
gotID = cx.User()
|
||||
@@ -509,7 +509,7 @@ func TestSanitizeIdentity_UserNameForgeryStripped(t *testing.T) {
|
||||
|
||||
var gotName string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(cx *zip.Ctx) error {
|
||||
gotName = cx.Header("X-User-Name")
|
||||
return cx.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
@@ -710,7 +710,7 @@ func TestSanitizeIdentity_OrgAdminFromMembershipRole(t *testing.T) {
|
||||
|
||||
var gotOrgAdmin, gotAdmin, gotOrg string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Use(SanitizeIdentity(v))
|
||||
app.Get("/probe", func(cx *zip.Ctx) error {
|
||||
gotAdmin = cx.Header("X-User-IsAdmin")
|
||||
gotOrgAdmin = cx.Header("X-User-IsOrgAdmin")
|
||||
|
||||
Reference in New Issue
Block a user