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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user