identity: the home org is the USER's, never the minting app's

The `owner` claim has never carried the user's organization. IAM stamps the
APPLICATION's org into it (oidc/jwt.go Sign: `Owner: app.Organization`), so the same
person authenticating through two apps presented two different orgs. It read as
correct for years only because, pre-onboarding, the app org and the user org were
both "hanzo"; onboarding broke the coincidence, not the claim.

Cloud consumed exactly that field, and fed it to two different gates:

  - the billing anchor (owner -> effOrg -> X-Org-Id -> BillingOrg), which made the
    paying tenant CALLER-SELECTABLE: a hanzo user authenticating through lux-cloud
    spent lux's ledger. lux-cloud/zoo-cloud/pars-cloud are all live accepted
    audiences, so this crossed brands, not merely accounts.
  - the SuperAdmin predicate (owner == adminOrg), which made platform sudo a
    property of the app you logged in through. admin-console and hanzo-admin-guard
    are both org=admin with the tenant gate (orgChoiceMode) OFF.

One poisoned value, two defects, one accessor: idClaims.homeOrg reads orgs[0].org
from the signed membership set, which IAM builds home-first from the authoritative
user row (store.MemberOrgRefs). SanitizeIdentity reads that everywhere it used to
read `owner`, so both close together.

IAM knew: internal/authz/authz.go refuses these claims internally and says the org
"comes from the token SUBJECT ... never from the token's `owner`/`organization`
claims", and the Sign/SignUserToken pair documents the divergence while naming
cloud's SanitizeIdentity as the consumer. No IAM change is needed or wanted —
rewriting `owner` would move every personal-account actor out of the shared hanzo
ledger and silently change the claim for every relying party.

FAIL CLOSED on a token that names no home org (pre-v1.33.0, or a machine token, for
which IAM omits the claim). It must never fall back to `owner`: that is the
app-selected value being removed. An unresolvable home org grants no scoping at all
and is logged at WARN with the audience, so a real legacy principal is visible
rather than silently denied. Bounded by token TTL.

Membership of the admin org remains the WHOLE SuperAdmin predicate; the isAdmin bit
is deliberately not added as a second term. Reading the user's own org is what
closes the escalation, and requiring isAdmin would deny every operator whose row
lacks the bit — 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.
The summary comment claiming `claims.isAdmin && owner == adminOrg` was wrong twice
over and is corrected rather than implemented.

Fixtures: tokenClaims now seeds orgs[0] == owner, the ordinary case where the app
org and the user org agree. Every fixture previously set `owner` alone — a token
shape production has not emitted since IAM v1.33.0 — and because the two values were
always equal in tests, no assertion could tell them apart. That is precisely how a
caller-selectable tenant and a caller-selectable admin gate survived a green suite.
Tests needing the two to disagree, or modelling a pre-claim token, now say so.

TestLegacyTokenCannotSwitch changes contract deliberately: it asserted a claim-less
token "stays pinned to home" and expected the app's org. Pinning to it IS the
vulnerability. It now resolves nothing — the original intent ("no token gains
reach") strengthened from one org to none.

Baseline verified: 15 pre-existing failures on clean main (SQLCipher needs a tmpfs
this host lacks), 15 after, empty diff — no failure introduced.
This commit is contained in:
antje
2026-07-27 07:57:48 -07:00
parent 69ca63a2ca
commit d142194156
6 changed files with 343 additions and 12 deletions
+35
View File
@@ -196,6 +196,41 @@ func kmsMachineAudience(owner string) string {
return owner + kmsMachineAudSuffix
}
// homeOrg returns the USER's own organization — the tenant whose ledger pays and
// whose membership decides platform authority. It reads the FIRST entry of the
// signed `orgs` claim, which IAM builds home-first by construction from the
// authoritative user row (store.MemberOrgRefs: `refs := []OrgRef{{Org: user.Owner,
// …}}`, then explicit membership rows, deduped home-wins).
//
// IT IS DELIBERATELY NOT claims.Owner. The `owner` claim has never carried the
// user's org: IAM stamps the APPLICATION's org into it (oidc/jwt.go Sign:
// `Owner: app.Organization`). Same user, same password, two apps ⇒ two different
// `owner` values. That read as correct for years only because, pre-onboarding, the
// app org and the user org were both "hanzo"; onboarding broke the coincidence, not
// the claim. IAM knew — internal/authz/authz.go refuses to trust these claims
// internally and says the organization "comes from the token SUBJECT … never from
// the token's `owner`/`organization` claims" — and the Sign/SignUserToken pair
// documents the divergence while naming cloud's SanitizeIdentity as the consumer.
//
// Consuming `owner` made the tenant CALLER-SELECTABLE: a user picked which org's
// ledger to spend by choosing which app to authenticate through (a hanzo user via
// lux-cloud billed lux), and — because the same value gated SuperAdmin — a token
// from any app owned by the reserved admin org conferred platform admin. One
// poisoned value, two defects; one accessor, both closed.
//
// EMPTY MEANS EMPTY. A token with no `orgs` (minted before IAM v1.33.0, or a
// machine token, for which IAM omits the claim by design) resolves NO home org, and
// the caller must fail closed — an org-less request, every org() gate 403. It must
// never fall back to `owner`, because that is precisely the app-selected value this
// exists to stop trusting. Callers log the fail-closed case so a real legacy
// principal is visible rather than silently denied.
func (c *idClaims) homeOrg() string {
if len(c.Orgs) == 0 {
return ""
}
return c.Orgs[0].Org
}
// isKMSMachinePrincipal reports whether a validated token is a per-org KMS-sync
// machine identity: its audience set contains the owner-bound machine audience
// (<owner>-platform-kms). Such a principal is a client_credentials machine identity
+53 -6
View File
@@ -27,6 +27,7 @@ package cloud
// matching the gateway's admin-guard. An org admin gets NO admin authority.
import (
"log/slog"
"net/url"
"strings"
"unicode"
@@ -131,9 +132,14 @@ 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 (claims.isAdmin && owner == adminOrg)
// 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.
// → X-User-IsAdmin=true; X-Org-Id = the requested org when present
// (admin org-switch), else owner.
// (admin org-switch), else the home org.
// any other principal (incl. org admins, normal users)
// → X-Org-Id pinned to owner; NO admin. A client org cannot widen
// scope.
@@ -193,10 +199,32 @@ func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
// collapse "acme " onto "acme"), so it grants NO org-scoping — the request
// resolves org-less and every org() gate fails closed with 403,
// rather than folding two IAM orgs onto one namespace.
owner := claims.Owner
// The USER's org, from the signed membership set — NOT the `owner` claim,
// which carries the APPLICATION's org and is therefore chosen by whichever
// app the caller authenticated through (see idClaims.homeOrg). Everything
// below — the billing anchor, the SuperAdmin predicate, the effective org —
// reads this ONE value, so both defects close together.
owner := claims.homeOrg()
if OrgHasUnsafeRune(owner) {
owner = ""
}
// FAIL CLOSED on a token that names no home org (no `orgs` claim: minted
// before IAM v1.33.0, or a machine token). Falling back to claims.Owner
// would reinstate the app-selected tenant this fix removes, so an
// unresolvable home org grants NO scoping at all: the request continues
// org-less and every org() gate refuses it. Logged, not silent — a real
// legacy principal must be visible to us, not merely denied. Bounded by
// token TTL: a re-auth mints the claim.
// WARN, not Debug: the expected rate is ~zero (IAM has minted `orgs` since
// v1.33.0 and live is far past it), so if this ever becomes noisy the noise
// IS the alarm — it means legacy principals are real and being denied, which
// we must learn immediately rather than infer from support tickets. Carries
// the AUDIENCE so the offending CLIENT is named, not just the user: IAM sets
// aud to the minting app's clientId (oidc/jwt.go audienceFor).
if owner == "" {
slog.Warn("identity: token names no home org (orgs claim absent or unsafe) — refusing org scope",
"sub", claims.Subject, "aud", claims.Audience)
}
if id := claims.userID(); id != "" {
req.Header.Set("X-User-Id", id)
}
@@ -234,9 +262,28 @@ func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
var effOrg string
switch {
case owner != "" && owner == adminOrg && !isMachinePrincipal(claims):
// SuperAdmin ⟺ the principal's org IS the reserved admin org AND the
// principal is HUMAN. The org equality is the SAME one IAM's canonical
// User.IsSuperAdmin() uses (user.Owner == conf.AdminOrg); the human gate
// SuperAdmin ⟺ the principal's HOME org IS the reserved admin org AND the
// principal is HUMAN.
//
// `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.
//
// 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
// (!isMachinePrincipal) is the necessary companion once the audience is no
// longer a gate — otherwise ANY admin-org client_credentials app (type ==
// "application"), not just the KMS-sync one, would inherit platform-admin and
+212
View File
@@ -0,0 +1,212 @@
package cloud
// The home org must be the USER's, never the minting APPLICATION's.
//
// IAM stamps the app's organization into the `owner` claim (oidc/jwt.go Sign:
// `Owner: app.Organization`). Cloud read that claim as the user's tenant, so the
// same person authenticating through two different apps resolved two different
// orgs — and since ONE value fed both the billing anchor and the SuperAdmin
// predicate, that made the tenant a caller-selectable choice and the platform-admin
// gate a property of the app you logged in through.
//
// Every existing test mints through a SINGLE app, so `owner` and the user's org were
// always equal in the fixtures and no assertion could tell them apart. These pin the
// difference: each token below has an app org that DISAGREES with the user's home
// org, which is exactly the shape production produces and the suite never did.
import (
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"testing"
"time"
model "github.com/hanzoai/iam/pkg/model"
"github.com/zap-proto/zip"
)
// crossAppClaims builds a token the way IAM really mints one: `owner` (and the
// audience) belong to the APP, while the signed `orgs` set is the USER's own
// tenancy, home first. appOrg and userHome are deliberately allowed to differ —
// that divergence is the entire subject of this file.
func crossAppClaims(appOrg, aud, userHome string, isAdmin bool, memberOf ...string) idClaims {
c := tokenClaims(aud, appOrg, "alice@example.test", isAdmin, time.Now().Add(time.Hour))
c.Name = "alice"
c.Subject = "u-alice" // ONE person, whichever app mints the token
c.Orgs = []model.OrgRef{{Org: userHome, Role: "member"}}
for _, o := range memberOf {
c.Orgs = append(c.Orgs, model.OrgRef{Org: o, Role: "member"})
}
return c
}
// orgFor runs a token through the boundary and reports the resolved effective org
// and whether platform admin was granted.
func orgFor(t *testing.T, claims idClaims, selected string) (org string, admin bool) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
tok := signWith(t, key, claims)
app := zip.New(zip.Config{})
app.Use(SanitizeIdentity(v, "admin"))
app.Get("/probe", func(c *zip.Ctx) error {
org, admin = c.Org(), c.IsAdmin()
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set("Authorization", "Bearer "+tok)
if selected != "" {
req.Header.Set("X-Org-Id", selected)
}
if _, err := app.Fiber().Test(req); err != nil {
t.Fatalf("probe: %v", err)
}
return org, admin
}
// TestHomeOrgIsAppIndependent is THE bug in one assertion: one person, two apps in
// DIFFERENT orgs, one anchor. Before the fix the anchor followed whichever app
// minted the token, so the user chose their own tenant by choosing a login route.
func TestHomeOrgIsAppIndependent(t *testing.T) {
viaHanzo, adminH := orgFor(t, crossAppClaims("hanzo", "hanzo-cloud", "gotham-labs", false), "")
viaLux, adminL := orgFor(t, crossAppClaims("lux", "lux-cloud", "gotham-labs", false), "")
if viaHanzo != "gotham-labs" || viaLux != "gotham-labs" {
t.Fatalf("anchor followed the APP: via hanzo-cloud=%q via lux-cloud=%q, want %q both",
viaHanzo, viaLux, "gotham-labs")
}
if viaHanzo != viaLux {
t.Fatalf("same user resolved two tenants: %q vs %q", viaHanzo, viaLux)
}
if adminH || adminL {
t.Fatalf("a plain member was granted admin (hanzo=%v lux=%v)", adminH, adminL)
}
}
// TestCrossBrandAppBillsTheUsersOrg is the tenancy half of the same bug. lux-cloud,
// zoo-cloud and pars-cloud are all live accepted audiences, so a hanzo user could
// authenticate through a sibling BRAND's app and spend that brand's ledger. This
// asserts the money, not just the header: billOrg/billUser are what the gate checks
// and the meter debits.
func TestCrossBrandAppBillsTheUsersOrg(t *testing.T) {
billOrg, billUser, dataOrg, ok := walletProbe(t, crossAppClaims("lux", "lux-cloud", "hanzo", false), "")
if !ok {
t.Fatalf("no wallet resolved for a valid principal")
}
if billOrg != "hanzo" {
t.Fatalf("a hanzo user via a lux app billed %q, want %q — cross-brand tenancy breach", billOrg, "hanzo")
}
if billUser == "lux" || dataOrg == "lux" {
t.Fatalf("lux leaked into the wallet: billUser=%q dataOrg=%q", billUser, dataOrg)
}
}
// TestAppOrgAdminDoesNotGrantSuperAdmin is the privilege-escalation guard. An app
// owned by the reserved admin org (admin-console, hanzo-admin-guard — both live,
// both with the tenant gate orgChoiceMode=None) must not confer platform admin on
// an ordinary user who happens to authenticate through it.
//
// Reading the USER's home org is the WHOLE guard here, and deliberately so: the
// isAdmin bit is not a second term (admin-org membership IS the predicate — see
// TestSuperAdminGate_IsAdminOrgMembership). The two `isAdmin` values below therefore
// prove the guard holds on the org alone, whichever way that bit falls.
func TestAppOrgAdminDoesNotGrantSuperAdmin(t *testing.T) {
for _, isAdmin := range []bool{false, true} {
org, admin := orgFor(t, crossAppClaims("admin", "admin-console", "gotham-labs", isAdmin), "")
if admin {
t.Fatalf("isAdmin=%v: an ordinary user became SuperAdmin by authenticating through an admin-org app", isAdmin)
}
if org != "gotham-labs" {
t.Fatalf("isAdmin=%v: effective org = %q, want %q (the USER's org, not the app's)", isAdmin, org, "gotham-labs")
}
}
}
// TestRealAdminOrgMemberStillSuperAdmin is the lockout guard, and the reason the
// isAdmin bit was NOT added as a second term: a genuine operator whose HOME org is
// the admin org must keep platform sudo, including when their user row carries no
// isAdmin bit. The admin org holds only SuperAdmins (provisioned in, never
// promoted), so membership is the fact. Breaking this locks every operator out of
// admin.hanzo.ai.
func TestRealAdminOrgMemberStillSuperAdmin(t *testing.T) {
for _, isAdmin := range []bool{false, true} {
// App org is `hanzo` while the USER's home org is `admin` — sudo must follow
// the person, not the login route.
org, admin := orgFor(t, crossAppClaims("hanzo", "hanzo-cloud", "admin", isAdmin), "")
if !admin {
t.Fatalf("isAdmin=%v: a real admin-org member was DENIED SuperAdmin — operator lockout", isAdmin)
}
if org != "admin" {
t.Fatalf("isAdmin=%v: effective org = %q, want %q", isAdmin, org, "admin")
}
}
}
// TestMasqueradeStillBillsAdminLedger: a real SuperAdmin acting in a customer org
// still spends their OWN books. The carve-out must survive both changes.
func TestMasqueradeStillBillsAdminLedger(t *testing.T) {
billOrg, _, dataOrg, ok := walletProbe(t, crossAppClaims("hanzo", "hanzo-cloud", "admin", true), "victim")
if !ok {
t.Fatalf("no wallet resolved for the admin principal")
}
if billOrg != "admin" {
t.Fatalf("masquerade billed %q, want %q — an admin must never spend the customer's money", billOrg, "admin")
}
if dataOrg != "victim" {
t.Fatalf("data scope = %q, want %q (DATA follows the masquerade, money does not)", dataOrg, "victim")
}
}
// TestNonMemberSwitchPinsToResolvedHome: the membership gate must measure a
// requested switch against the USER's set and fall back to the USER's home — not to
// the app's org, which is what the fallback used to be.
func TestNonMemberSwitchPinsToResolvedHome(t *testing.T) {
org, _ := orgFor(t, crossAppClaims("lux", "lux-cloud", "gotham-labs", false, "acme"), "victim")
if org != "gotham-labs" {
t.Fatalf("non-member switch resolved %q, want home %q (and never the app org %q)", org, "gotham-labs", "lux")
}
// The control: a switch the signed set DOES contain is still honored.
if org, _ := orgFor(t, crossAppClaims("lux", "lux-cloud", "gotham-labs", false, "acme"), "acme"); org != "acme" {
t.Fatalf("member switch resolved %q, want %q", org, "acme")
}
}
// TestLegacyOrgsClaimFailsClosed: a token with no `orgs` (minted before IAM
// v1.33.0) resolves NO org rather than falling back to the app's org. Denied is
// recoverable by re-auth within a token TTL; silently billing the app's tenant is
// not.
func TestLegacyOrgsClaimFailsClosed(t *testing.T) {
legacy := tokenClaims("hanzo-cloud", "hanzo", "old@example.test", false, time.Now().Add(time.Hour))
legacy.Orgs = nil // pre-claim token
org, admin := orgFor(t, legacy, "")
if org != "" {
t.Fatalf("legacy token resolved org %q, want \"\" — it must never fall back to the app org", org)
}
if admin {
t.Fatalf("legacy token granted admin")
}
// And it must not be rescuable by naming an org on the request either.
if org, _ := orgFor(t, legacy, "hanzo"); org != "" {
t.Fatalf("legacy token + requested org resolved %q, want \"\"", org)
}
}
// TestLegacyAdminOrgTokenIsNotSuperAdmin is the escalation twin of the test above:
// the fail-closed path must not hand admin to a claim-less token minted by an
// admin-org app, which is precisely the combination the old code granted sudo to.
func TestLegacyAdminOrgTokenIsNotSuperAdmin(t *testing.T) {
legacy := tokenClaims("admin-console", "admin", "old@example.test", true, time.Now().Add(time.Hour))
legacy.Orgs = nil
if org, admin := orgFor(t, legacy, ""); admin || org != "" {
t.Fatalf("legacy admin-org token: org=%q admin=%v, want \"\" and false", org, admin)
}
}
+21 -5
View File
@@ -79,6 +79,11 @@ func walletProbe(t *testing.T, claims idClaims, selected string) (billOrg, billU
func memberClaims(owner string, orgs ...string) idClaims {
c := tokenClaims("hanzo-cloud", owner, owner+"@example.test", false, time.Now().Add(time.Hour))
c.Name = "alice"
// Take FULL control of the membership set: tokenClaims seeds the ordinary
// app-org == user-org case, but this helper states the tenancy explicitly, and
// `memberClaims(owner)` with no orgs must keep meaning a pre-claim LEGACY token
// (the empty set), not a token that quietly inherited a home org.
c.Orgs = nil
for _, o := range orgs {
c.Orgs = append(c.Orgs, model.OrgRef{Org: o, Role: "member"})
}
@@ -140,14 +145,25 @@ func TestNonMemberSelectionIgnored(t *testing.T) {
}
// TestLegacyTokenCannotSwitch: a token minted before IAM shipped the `orgs` claim
// carries an EMPTY membership set, so it can select nothing and stays pinned to
// home. Same for an opaque key and a client_credentials machine, for which IAM never
// mints the claim at all. The switch is strictly additive — no token gains reach.
// carries an EMPTY membership set, so it can select nothing — AND, since the home
// org is now read from that same set, it resolves no home either. It bills nothing.
//
// CONTRACT CHANGE, deliberate. This test previously asserted such a token "stays
// pinned to home" and expected `hanzo` — but that value came from the `owner`
// claim, which carries the minting APPLICATION's org, not the user's. Pinning to it
// is the vulnerability, not the safe default: it is what let a caller choose their
// tenant (and, when the app belonged to the admin org, their privilege) by choosing
// a login route. So the fallback is gone and this token now resolves NOTHING.
//
// The test's original intent — "no token gains reach" — is strengthened rather than
// weakened: it previously reached one org, and now reaches none. Denial is
// recoverable by re-authenticating within a token TTL; silently billing whichever
// tenant owned the app is not. See idClaims.homeOrg.
func TestLegacyTokenCannotSwitch(t *testing.T) {
noClaim := memberClaims("hanzo") // no orgs at all
billOrg, _, dataOrg, ok := walletProbe(t, noClaim, "acme")
if !ok || billOrg != "hanzo" || dataOrg != "hanzo" {
t.Fatalf("legacy token switched: bill=%q data=%q ok=%v, want hanzo/hanzo/true", billOrg, dataOrg, ok)
if ok || billOrg != "" || dataOrg != "" {
t.Fatalf("legacy token resolved a tenant: bill=%q data=%q ok=%v, want empty/empty/false", billOrg, dataOrg, ok)
}
}
+18 -1
View File
@@ -18,6 +18,7 @@ import (
gojose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
model "github.com/hanzoai/iam/pkg/model"
"github.com/zap-proto/zip"
)
@@ -57,7 +58,21 @@ func signWith(t *testing.T, key *rsa.PrivateKey, c idClaims) string {
return raw
}
// tokenClaims builds an IAM-shaped claim set.
// tokenClaims builds an IAM-shaped claim set for the ORDINARY case: the minting
// app's org and the user's own org AGREE, so `owner` and orgs[0] carry the same
// value.
//
// The `orgs` seed is not decoration — it is what makes this fixture resemble a real
// token. IAM has minted the claim since v1.33.0, and cloud reads the USER's org from
// it (idClaims.homeOrg), never from `owner`, which carries the APPLICATION's org.
// Every fixture here previously set `owner` alone, so the whole suite exercised a
// token shape production no longer emits — and, because the two values were always
// equal in the fixtures, no assertion could distinguish them. That is exactly how a
// caller-selectable tenant and a caller-selectable SuperAdmin gate survived.
//
// Tests that need the two to DISAGREE (the cross-app cases in
// middleware_identity_homeorg_test.go) override Orgs explicitly, as does any test
// modelling a pre-claim legacy token by clearing it.
func tokenClaims(aud, owner, email string, isAdmin bool, exp time.Time) idClaims {
return idClaims{
Claims: jwt.Claims{
@@ -70,6 +85,7 @@ func tokenClaims(aud, owner, email string, isAdmin bool, exp time.Time) idClaims
Owner: owner,
Email: email,
IsAdmin: isAdmin,
Orgs: []model.OrgRef{{Org: owner, Role: "member"}},
}
}
@@ -605,6 +621,7 @@ func TestIdentityValidator(t *testing.T) {
// one org can never reach the global (all-orgs) admin surface — only the
// operator org's admins do. This is what keeps SuperAdmin the operator's, and
// what "isAdmin required" keeps from being every operator-org user.
//
// TestSuperAdminGate_IsAdminOrgMembership locks THE ONE predicate:
// SuperAdmin ⟺ the principal's org IS the reserved admin org (owner == adminOrg).
// The same equality IAM's canonical User.IsSuperAdmin() uses — cloud adds no second
+4
View File
@@ -54,6 +54,10 @@ func TestVerifiedIdentityLegacyNoOrgs(t *testing.T) {
v := &TokenValidator{v: newIdentityValidator(testIssuer, jwks.URL, 0)}
claims := tokenClaims("hanzo-team", "acme", "ada@example.com", false, time.Now().Add(time.Hour))
// tokenClaims seeds the ordinary case (orgs[0] == owner) because that is what a
// real IAM token carries. This test is specifically about the PRE-CLAIM shape, so
// it clears the set explicitly rather than relying on the fixture's default.
claims.Orgs = nil
id, err := v.Validate(signWith(t, key, claims))
if err != nil {
t.Fatalf("validate: %v", err)