Files
cloud/middleware_identity_operator_test.go
zeekayandHanzo Dev ebe77a1fe3 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>
2026-08-01 12:46:31 -07:00

222 lines
9.1 KiB
Go

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{}