`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>
75 lines
3.5 KiB
Go
75 lines
3.5 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
|
|
package cloud
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/authz"
|
|
)
|
|
|
|
// TestUsernamePrefersPreferredUsername pins the claim precedence the money path
|
|
// depends on. OIDC `name` is a DISPLAY name — IAM fills it from User.DisplayName,
|
|
// and a real token carried "Zach Kelling". Reading it as the username addressed
|
|
// wallet `hanzo/Zach Kelling`, which no funding path can name, while the balance
|
|
// sat in `hanzo/z`; every signed-in completion 402'd against a funded account.
|
|
func TestUsernamePrefersPreferredUsername(t *testing.T) {
|
|
// Both present: the username wins, never the human label.
|
|
c := &idClaims{Claims: authz.Claims{Name: "Zach Kelling", PreferredUsername: "z"}}
|
|
if got := c.username(); got != "z" {
|
|
t.Fatalf("username() = %q; want %q (preferred_username must win over the display name)", got, "z")
|
|
}
|
|
// A display name must never be returned when the username is available, and a
|
|
// space is the tell that a display name leaked into an account key.
|
|
if strings.ContainsRune(c.username(), ' ') {
|
|
t.Fatalf("username() = %q; an account key can never contain a space", c.username())
|
|
}
|
|
// Legacy token minted before IAM emitted preferred_username: `name` is all
|
|
// there is, so it stays the answer rather than becoming empty.
|
|
legacy := &idClaims{Claims: authz.Claims{Name: "z"}}
|
|
if got := legacy.username(); got != "z" {
|
|
t.Fatalf("legacy username() = %q; want %q (fallback must be retained)", got, "z")
|
|
}
|
|
// Neither present: empty, never a guess.
|
|
if got := (&idClaims{}).username(); got != "" {
|
|
t.Fatalf("empty username() = %q; want \"\"", got)
|
|
}
|
|
}
|
|
|
|
// TestOrgAdminAdmitsOwner pins the role vocabulary this gate reads. IAM's coarse
|
|
// membership set is exactly {owner, admin, member}, and `owner` is what it writes
|
|
// for whoever CREATES an org — self-service provisioning calls
|
|
// EnsureMembership(..., RoleOwner) precisely so a new org is not "born with nobody
|
|
// on it". Matching only "admin" therefore locked every self-serve founder out of
|
|
// their own org's admin surface, and an owner cannot escalate their way back in.
|
|
//
|
|
// The role is FOLDED (a closed vocabulary IAM controls); the org is compared
|
|
// VERBATIM, because a fold there would let a member of "acme" claim "ACME".
|
|
func TestOrgAdminAdmitsOwner(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
orgs []authz.Membership
|
|
org string
|
|
want bool
|
|
}{
|
|
{"owner of the org", []authz.Membership{{Org: "acme", Role: "owner"}}, "acme", true},
|
|
{"admin of the org", []authz.Membership{{Org: "acme", Role: "admin"}}, "acme", true},
|
|
{"role case is folded", []authz.Membership{{Org: "acme", Role: "Owner"}}, "acme", true},
|
|
{"role is trimmed", []authz.Membership{{Org: "acme", Role: " owner "}}, "acme", true},
|
|
{"plain member is not an admin", []authz.Membership{{Org: "acme", Role: "member"}}, "acme", false},
|
|
{"unknown role admits nothing", []authz.Membership{{Org: "acme", Role: "billing"}}, "acme", false},
|
|
{"owner ELSEWHERE does not admit here", []authz.Membership{{Org: "other", Role: "owner"}}, "acme", false},
|
|
{"org compare stays VERBATIM", []authz.Membership{{Org: "ACME", Role: "owner"}}, "acme", false},
|
|
{"empty set admits nothing", nil, "acme", false},
|
|
{"empty org admits nothing", []authz.Membership{{Org: "acme", Role: "owner"}}, "", false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|