cloud auth: IAM-native trust — drop the per-app audience allowlist

Trust becomes exactly what IAM asserts: a valid SIGNATURE from a trusted ISSUER
(the brand set) plus EXPIRY. The audience (a minting app's client_id) is now
INFORMATIONAL, not an access gate — cloud no longer keeps a hand-maintained mirror
of IAM's app registry. That mirror was the lone non-IAM-native gate and it drifted:
every new first-party app (most recently hanzo-commerce, breaking the commerce-admin
AI assistant) silently 401'd until someone edited GATEWAY_ALLOWED_AUDIENCES. A new
first-party app now 'just works' with zero cloud change.

Removed: identityValidator.audiences, Config.JWTAudiences, jwtAudiencesFromEnv,
defaultJWTAudiences, BrandAudiences, unionStrings. validate() enforces issuer + expiry
via jwt.Expected{} (empty AnyAudience skips ONLY the audience match; go-jose still
checks exp/nbf against time.Now). Fail-secure on an empty ISSUER set is preserved.

PRESERVED (unchanged): owner-claim org scoping on every guard; SuperAdmin =
owner==adminOrg AND !isKMSMachinePrincipal; the KMS-machine SuperAdmin-denial
(isKMSMachinePrincipal reads claims.Audience directly, not the removed allowlist);
OrgHasUnsafeRune. Tests reframed to the new invariant and all green, incl. the KMS
red adversarial suite (multi-value aud, admin-slip, owner-bound machine-aud, trim/
unsafe owner). One new test proves any aud from a trusted issuer validates while a
wrong issuer / expired token still rejects.

The user-facing behavior change: a real admin (owner==adminOrg, isAdmin) is now
SuperAdmin from ANY first-party app, not only allowlisted ones — owner is the
authority, which is correct for an internal IAM where every app is first-party.
This commit is contained in:
antje
2026-07-24 00:32:47 -07:00
parent c549074bb8
commit f429d7228d
15 changed files with 246 additions and 343 deletions
+43 -52
View File
@@ -125,43 +125,41 @@ var jwtSigAlgs = []gojose.SignatureAlgorithm{
// (cert-hanzo/cert-lux/cert-zoo/...), keyed by the token kid, so a single
// jwksURL verifies all brands. Only the issuer-string comparison had to widen.
type identityValidator struct {
issuers []string
audiences []string
cache *jwksCache
keys keyResolver // resolves an opaque API key to a principal; nil ⟹ keys stay anonymous
issuers []string
cache *jwksCache
keys keyResolver // resolves an opaque API key to a principal; nil ⟹ keys stay anonymous
}
// newIdentityValidator builds a validator whose trusted-issuer set is the primary
// issuer UNIONED with every white-label brand issuer (BrandIssuers) plus any
// WHITELABEL_ISSUERS override. ttl<=0 uses the 15m JWKS default. The union is
// fail-secure: it only ADDS the known-good brand issuers, never an arbitrary one.
func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.Duration) *identityValidator {
//
// Trust is IAM-native: signature (JWKS) + issuer (this set) + expiry. There is NO
// per-app audience allowlist — the `aud` (a minting app's client_id) is IAM's to
// assign, not cloud's to mirror, so a new first-party app needs zero cloud change.
func newIdentityValidator(issuer, jwksURL string, ttl time.Duration) *identityValidator {
return &identityValidator{
issuers: trustedIssuers(issuer),
audiences: audiences,
cache: newJWKSCache(jwksURL, ttl),
keys: sharedKeys(), // ONE resolver+cache, shared with OrgForKey (analytics capture)
issuers: trustedIssuers(issuer),
cache: newJWKSCache(jwksURL, ttl),
keys: sharedKeys(), // ONE resolver+cache, shared with OrgForKey (analytics capture)
}
}
// kmsMachineAudSuffix is the fixed suffix of a per-org PaaS-KMS sync machine
// identity's audience. Each org's KMS sync authenticates as a dedicated,
// NON-shared IAM application named "<org>-platform-kms" (Organization=<org>,
// client_credentials grant), so IAM stamps the token's aud == the app's own
// clientId == "<org>-platform-kms" (a non-shared app's audience is its clientId,
// object/token_jwt.go tokenAudience) and owner == <org>
// (object/token_oauth.go GetClientCredentialsToken sets owner = app.Organization).
// identity's audience. Each org's KMS sync authenticates as a dedicated, NON-shared
// IAM application named "<org>-platform-kms" (Organization=<org>, client_credentials
// grant), so IAM stamps the token's aud == the app's own clientId == "<org>-platform-kms"
// (object/token_jwt.go tokenAudience) and owner == <org> (object/token_oauth.go
// GetClientCredentialsToken sets owner = app.Organization).
//
// That audience is, by construction, absent from CLOUD_JWT_AUDIENCES — it is
// per-org, not a fixed app — which is EXACTLY why the sync stayed pending: the
// machine token failed the audience check below, SanitizeIdentity treated it as
// anonymous, and the /v1/kms org-scope guard 403'd it before the store. The fix is
// to accept this one audience, but ONLY when it equals the token's OWN owner claim
// plus this suffix, so it certifies "the KMS sync identity for its own org" and
// grants nothing wider. Org-scoping is still enforced downstream by owner at the guard
// (owner == :org); this only lets a legitimately-minted, owner-scoped machine token
// clear validation. A per-org application means a per-org clientSecret — never
// a shared platform-wide reader, which would be a cross-org hole.
// Validation no longer consults the audience at all (trust is signature + issuer +
// expiry), so a machine token clears validate() like any other. This suffix survives
// for the OPPOSITE reason: to RECOGNISE a machine principal (isKMSMachinePrincipal) so
// SanitizeIdentity can DENY it SuperAdmin even when it carries owner==adminOrg — a
// client_credentials machine identity must never wield platform-admin. The match is
// bound to the token's OWN owner claim (<owner>-platform-kms), so it certifies "the
// KMS sync identity for its own org" and grants nothing wider.
const kmsMachineAudSuffix = "-platform-kms"
// kmsMachineAudience returns the audience an org org's PaaS-KMS sync identity
@@ -214,14 +212,13 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
return nil, err
}
// Fail SECURE on a misconfigured (empty) trust set: an empty issuer OR audience
// allowlist must REJECT every token, never silently disable that axis. In
// production both are always resolved non-empty (BrandIssuers + the baked
// audience defaults, unioned in config.go so they are "never empty"), so this
// fires ONLY on an operator misconfiguration (CLOUD_JWT_AUDIENCES="" emptying the
// resolved set, or an empty issuer set) — and then it denies, it never admits (I2).
if len(v.issuers) == 0 || len(v.audiences) == 0 {
return nil, fmt.Errorf("identity validator misconfigured: empty issuer or audience allowlist")
// Fail SECURE on a misconfigured (empty) trust set: with no trusted issuer every
// token must be REJECTED, never silently admitted. In production the set is always
// non-empty (the primary issuer + BrandIssuers, unioned in config.go so it is
// "never empty"), so this fires ONLY on an operator misconfiguration — and then it
// denies, it never admits (I2).
if len(v.issuers) == 0 {
return nil, fmt.Errorf("identity validator misconfigured: empty issuer set")
}
// Reject a missing issuer: an empty issuer must never pass the set check.
@@ -234,28 +231,22 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
if claims.Expiry == nil {
return nil, fmt.Errorf("missing expiry")
}
// Issuer must be one of the trusted brand issuers. go-jose's jwt.Expected
// checks a SINGLE issuer, so the issuer is validated here against the set and
// left out of Expected (audience + expiry stay with Expected).
// Issuer must be one of the trusted brand issuers. go-jose's jwt.Expected checks a
// SINGLE issuer, so the issuer is validated here against the set and left out of
// Expected (only expiry/not-before stay with Expected).
if !issuerAllowed(claims.Issuer, v.issuers) {
return nil, fmt.Errorf("untrusted issuer %q", claims.Issuer)
}
// Audience: the static allowlist (CLOUD_JWT_AUDIENCES / brand app client_ids)
// PLUS the per-org PaaS-KMS sync machine audience bound to THIS token's own
// owner (<owner>-platform-kms). The machine audience is added only when the
// allowlist is active (non-empty — always so in production) and only for the
// token's own org, so accepting it never widens org-scoping: the /v1/kms guard still
// gates on owner == :org. Without this, a real client_credentials machine token
// (aud == its per-org clientId, never in the allowlist) fails here and the
// sync silently stays pending — the activation blocker.
// The audience allowlist is guaranteed non-empty (checked above), so the
// audience axis is ALWAYS enforced — never silently skipped.
auds := v.audiences
if mach := kmsMachineAudience(claims.Owner); mach != "" {
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
}
expected := jwt.Expected{AnyAudience: jwt.Audience(auds)}
if err := claims.Claims.ValidateWithLeeway(expected, 2*time.Minute); err != nil {
// Audience is NOT an access gate. A valid signature from a trusted issuer (both
// checked above) proves IAM minted this token for one of ITS OWN registered apps;
// the `aud` merely names which app. Cloud does not keep a per-app allowlist to
// mirror IAM's registry — that mirror drifted and silently 401'd every new
// first-party app until hand-edited. Org scope is the `owner` claim, enforced by
// every downstream guard; SuperAdmin is owner==adminOrg AND !isKMSMachinePrincipal
// (SanitizeIdentity). Expiry + not-before are STILL enforced here: Expected{} with a
// zero Time validates against time.Now(); an empty AnyAudience skips ONLY the
// audience match (go-jose/v4 jwt/validation.go).
if err := claims.Claims.ValidateWithLeeway(jwt.Expected{}, 2*time.Minute); err != nil {
return nil, fmt.Errorf("claims: %w", err)
}
return &claims, nil
+52
View File
@@ -0,0 +1,52 @@
package cloud
import (
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
// TestValidate_AudienceIsNotAGate proves the IAM-native trust model: a token signed
// by a trusted issuer validates REGARDLESS of its `aud` (the minting app's client_id).
// Cloud keeps no per-app audience allowlist mirroring IAM's registry — so a brand-new
// first-party app works with zero cloud change, and the specific app tokens the old
// per-app tests pinned (admin-guard, world, team, commerce) are accepted by the SAME
// rule as everything else. Trust is signature + issuer + expiry; org scope is the
// owner claim, enforced downstream. Replaces the three audience_*_test.go files that
// asserted a static allowlist which no longer exists.
func TestValidate_AudienceIsNotAGate(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
// Every audience — the apps the deleted per-app tests pinned AND a never-registered
// one — validates identically, because aud is not an access gate.
for _, aud := range []string{
"hanzo-admin-guard", // admin.hanzo.ai forward-auth cockpit
"hanzo-world", // world.hanzo.ai analyst tokens
"hanzo-team", // hanzo.team wallet page
"hanzo-commerce", // commerce.hanzo.ai admin AI assistant
"a-brand-new-first-party-app-never-listed-anywhere",
} {
tok := signWith(t, key, tokenClaims(aud, "acme", "", false, future))
id, err := v.validate(tok)
if err != nil {
t.Fatalf("aud=%q from a trusted issuer must validate (no allowlist), got %v", aud, err)
}
if id.Owner != "acme" {
t.Errorf("aud=%q: owner must be carried through, got %q", aud, id.Owner)
}
}
// Expiry is STILL enforced (dropping the aud gate must not disable time checks):
// a token expired beyond the 2m leeway is rejected whatever its aud.
expired := signWith(t, key, tokenClaims("hanzo-commerce", "acme", "", false, time.Now().Add(-time.Hour)))
if _, err := v.validate(expired); err == nil {
t.Error("expired token must be REJECTED even though audience is no longer gated")
}
}
+49 -35
View File
@@ -1,15 +1,15 @@
package cloud
// V6 (the activation blocker) — the identity validator must accept a per-org
// PaaS-KMS sync machine token: a client_credentials JWT whose aud is the org's
// own IAM application clientId "<owner>-platform-kms" (a per-org value, NEVER in
// CLOUD_JWT_AUDIENCES) — but ONLY when that audience is bound to the token's OWN
// owner claim. Before the fix the machine token failed the audience check,
// SanitizeIdentity resolved anonymous, and the /v1/kms guard 403'd it, so the sync
// silently stayed pending. These are white-box unit tests of validate() itself;
// the end-to-end proof through SanitizeIdentity + the real guard lives in
// clients/kms (v6_aud_e2e_test.go). Reuses the jwksServer/signWith/tokenClaims
// helpers from middleware_identity_test.go (same package).
// The per-org PaaS-KMS sync identity authenticates as its own IAM application
// "<owner>-platform-kms" (client_credentials), so its token carries owner=<org> and
// aud=<owner>-platform-kms. Validation no longer gates on the audience at all (trust
// is signature + issuer + expiry), so a machine token clears validate() like any
// other. The owner-bound machine aud survives only to IDENTIFY such a principal
// (isKMSMachinePrincipal) so SanitizeIdentity can DENY it SuperAdmin even in the admin
// org — a client_credentials machine identity must never wield platform-admin. These
// are white-box unit tests of that identification; the end-to-end proof through
// SanitizeIdentity + the real guard lives in clients/kms (v6_aud_e2e_test.go). Reuses
// the jwksServer/signWith/tokenClaims helpers from middleware_identity_test.go.
import (
"crypto/rand"
@@ -18,19 +18,16 @@ import (
"time"
)
func TestIdentityValidator_KMSMachineAudience(t *testing.T) {
func TestIdentityValidator_KMSMachinePrincipal(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
// The static allowlist deliberately contains NO *-platform-kms audience, so any
// acceptance below can come ONLY from the owner-bound machine-aud rule, not the
// allowlist — this is what makes it a fix and not a config workaround.
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
t.Run("machine token for its own org is accepted", func(t *testing.T) {
t.Run("own-org machine token validates and is recognised as a machine principal", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("maxpower-platform-kms", "maxpower", "", false, future)))
if err != nil {
t.Fatalf("machine token rejected: %v", err)
@@ -38,34 +35,51 @@ func TestIdentityValidator_KMSMachineAudience(t *testing.T) {
if c.Owner != "maxpower" {
t.Fatalf("owner=%q, want maxpower", c.Owner)
}
})
t.Run("machine aud for a DIFFERENT org is rejected (owner-bound)", func(t *testing.T) {
// owner=maxpower but aud=acme-platform-kms: the accepted machine aud is bound
// to the token's OWN owner (maxpower-platform-kms), so this must fail — it is
// not a blanket "*-platform-kms" wildcard.
if _, err := v.validate(signWith(t, key, tokenClaims("acme-platform-kms", "maxpower", "", false, future))); err == nil {
t.Fatal("cross-org machine audience must be rejected (owner-bound)")
if !isKMSMachinePrincipal(c) {
t.Fatal("aud==<owner>-platform-kms must be recognised as a machine principal")
}
})
t.Run("arbitrary audience still rejected (fix is scoped, not a disable)", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("some-random-app", "maxpower", "", false, future))); err == nil {
t.Fatal("an arbitrary audience must still be rejected")
t.Run("admin-org machine token is recognised so SuperAdmin is denied", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("admin-platform-kms", "admin", "", true, future)))
if err != nil {
t.Fatalf("admin machine token rejected: %v", err)
}
if !isKMSMachinePrincipal(c) {
t.Fatal("admin-org machine token must be recognised (SanitizeIdentity denies it SuperAdmin)")
}
})
t.Run("machine aud with empty owner is rejected (fail closed)", func(t *testing.T) {
// aud="-platform-kms" with owner="": kmsMachineAudience("")=="" so no machine
// audience is granted and the bare suffix is not in the allowlist.
if _, err := v.validate(signWith(t, key, tokenClaims("-platform-kms", "", "", false, future))); err == nil {
t.Fatal("machine aud with empty owner must be rejected")
t.Run("machine aud bound to a DIFFERENT org is not this owner's machine principal", func(t *testing.T) {
// owner=maxpower, aud=acme-platform-kms: the machine-principal match is bound to
// the token's OWN owner (maxpower-platform-kms), not a "*-platform-kms" wildcard.
// It validates (aud is not gated) and is owner-scoped to maxpower downstream.
c, err := v.validate(signWith(t, key, tokenClaims("acme-platform-kms", "maxpower", "", false, future)))
if err != nil {
t.Fatalf("token rejected: %v", err)
}
if isKMSMachinePrincipal(c) {
t.Fatal("a cross-org machine aud must not count as this owner's machine principal")
}
})
t.Run("normal static-allowlist token still accepted (regression)", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("hanzo-console", "maxpower", "", false, future))); err != nil {
t.Fatalf("static-allowlist token rejected: %v", err)
t.Run("ordinary app token is not a machine principal", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("hanzo-console", "maxpower", "", false, future)))
if err != nil {
t.Fatalf("token rejected: %v", err)
}
if isKMSMachinePrincipal(c) {
t.Fatal("an ordinary app token is not a machine principal")
}
})
t.Run("empty-owner token is never a machine principal (fail closed)", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("-platform-kms", "", "", false, future)))
if err != nil {
t.Fatalf("token rejected: %v", err)
}
if isKMSMachinePrincipal(c) {
t.Fatal(`empty-owner token must never be a machine principal (kmsMachineAudience("")=="")`)
}
})
+8 -74
View File
@@ -9,10 +9,10 @@ import (
)
// TestValidate_FailSecureOnEmptyTrustSet proves I2: a validator whose resolved
// issuer OR audience allowlist is empty REJECTS an otherwise-valid, correctly
// signed token — the axis is never silently disabled. Production always resolves
// non-empty sets; this guards the misconfiguration path (CLOUD_JWT_AUDIENCES=""
// or an empty issuer set), which must fail closed, not open.
// issuer set is empty REJECTS an otherwise-valid, correctly signed token — the axis
// is never silently disabled. Production always resolves a non-empty set; this
// guards the misconfiguration path (an empty issuer set), which must fail closed,
// not open.
func TestValidate_FailSecureOnEmptyTrustSet(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -23,20 +23,15 @@ func TestValidate_FailSecureOnEmptyTrustSet(t *testing.T) {
tok := signWith(t, key, tokenClaims("hanzo-console", "acme", "", false, future))
// Sanity: a properly configured validator accepts the token.
if _, err := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0).validate(tok); err != nil {
if _, err := newIdentityValidator(testIssuer, jwks.URL, 0).validate(tok); err != nil {
t.Fatalf("baseline valid token must be accepted, got %v", err)
}
// Empty audience set → deny.
if _, err := newIdentityValidator(testIssuer, jwks.URL, nil, 0).validate(tok); err == nil {
t.Error("empty audience allowlist must REJECT (fail-secure), not accept")
}
// Empty issuer set → deny (construct directly; trustedIssuers never yields empty
// with a primary, so bypass it to exercise the guard).
vEmptyIss := &identityValidator{issuers: nil, audiences: []string{"hanzo-console"}, cache: newJWKSCache(jwks.URL, 0), keys: newIAMKeys()}
vEmptyIss := &identityValidator{issuers: nil, cache: newJWKSCache(jwks.URL, 0), keys: newIAMKeys()}
if _, err := vEmptyIss.validate(tok); err == nil {
t.Error("empty issuer allowlist must REJECT (fail-secure), not accept")
t.Error("empty issuer set must REJECT (fail-secure), not accept")
}
}
@@ -112,7 +107,7 @@ func TestBrandIssuers(t *testing.T) {
// full brand set, so a lux token would pass the issuer gate on the hanzo binary.
func TestNewIdentityValidator_MultiIssuer(t *testing.T) {
os.Unsetenv("WHITELABEL_ISSUERS")
v := newIdentityValidator("https://hanzo.id", "http://iam.hanzo.svc/v1/iam/.well-known/jwks", []string{"hanzo-cloud", "lux-cloud"}, 0)
v := newIdentityValidator("https://hanzo.id", "http://iam.hanzo.svc/v1/iam/.well-known/jwks", 0)
if !issuerAllowed("https://lux.id", v.issuers) {
t.Fatalf("validator must trust the lux issuer, set=%v", v.issuers)
}
@@ -123,64 +118,3 @@ func TestNewIdentityValidator_MultiIssuer(t *testing.T) {
t.Fatalf("validator must reject an untrusted issuer, set=%v", v.issuers)
}
}
// TestBrandAudiences proves every brand's cloud audience (<brand>-cloud) is derived
// from the brands registry — one source of truth, mirroring BrandIssuers.
func TestBrandAudiences(t *testing.T) {
got := BrandAudiences()
for _, want := range []string{"hanzo-cloud", "lux-cloud", "zoo-cloud", "pars-cloud", "bootnode-cloud"} {
found := false
for _, g := range got {
if g == want {
found = true
break
}
}
if !found {
t.Errorf("BrandAudiences()=%v missing %q", got, want)
}
}
}
// TestJWTAudiencesFromEnv_BrandUnion proves the resolved audience allowlist ALWAYS
// includes every brand's <brand>-cloud aud (so a lux token validates), whether the
// list comes from the baked default or a hanzo-only env override — and that an
// env-supplied entry is not duplicated.
func TestJWTAudiencesFromEnv_BrandUnion(t *testing.T) {
has := func(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
// Baked default path (no env).
os.Unsetenv("CLOUD_JWT_AUDIENCES")
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
def := jwtAudiencesFromEnv()
for _, want := range []string{"hanzo-cloud", "lux-cloud", "zoo-cloud", "pars-cloud"} {
if !has(def, want) {
t.Errorf("baked audiences %v missing brand aud %q", def, want)
}
}
// A legacy hanzo-only env override must STILL accept lux-cloud (brand union),
// with no duplicate of the env-supplied hanzo-cloud.
os.Setenv("GATEWAY_ALLOWED_AUDIENCES", "hanzo-app,hanzo-console,hanzo-cloud")
defer os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
got := jwtAudiencesFromEnv()
if !has(got, "lux-cloud") {
t.Fatalf("hanzo-only env override must still accept lux-cloud, got %v", got)
}
n := 0
for _, s := range got {
if s == "hanzo-cloud" {
n++
}
}
if n != 1 {
t.Fatalf("hanzo-cloud must appear exactly once (no duplicate), got %d in %v", n, got)
}
}
-16
View File
@@ -137,19 +137,3 @@ func BrandIssuers() []string {
}
return out
}
// BrandAudiences returns the OAuth `aud` (== IAM client_id == app name) of every
// white-label brand's cloud login app: `<brand>-cloud` (hanzo-cloud, lux-cloud,
// zoo-cloud, pars-cloud, bootnode-cloud). A brand's session token carries
// aud=<brand>-cloud (HIP-0111: client_id == app == aud), so the audience allowlist
// must include each to accept a lux/zoo/pars token on the ONE binary. Derived from
// the same `brands` registry as BrandIssuers — one source of truth, no hand-listing.
func BrandAudiences() []string {
out := make([]string, 0, len(brands))
for id := range brands {
if id != "" {
out = append(out, id+"-cloud")
}
}
return out
}
+13 -14
View File
@@ -14,7 +14,7 @@ package kms_test
// So a 200 on victimPath == "the token got SuperAdmin"; 403 == "admin denied".
// The whole test reduces the slip question to a single observable status code.
//
// Harness (e2eCfg): AdminOrg="admin", static allowlist JWTAudiences=["hanzo-console"].
// Harness (e2eCfg): AdminOrg="admin"; audience is not gated (trust = signature+issuer+expiry).
// Reuses mintRed / getWithBearer / getBearerHdr / sealPlatformSecret from the e2e +
// red_v6_adversarial files (same kms_test package).
@@ -103,11 +103,10 @@ func TestRed_MultiValueAud_AdminSlip(t *testing.T) {
// a real admin whose aud carries a FOREIGN tenant's machine aud (NOT its own) must
// KEEP SuperAdmin. kmsMachineAudience(owner="admin")="admin-platform-kms"; the set
// carries "maxpower-platform-kms", which is NOT the owner's machine aud, so
// isKMSMachinePrincipal returns false and admin is retained. This is CORRECT (not a
// hole): the V6 widening only ever admits a token via its OWN <owner>-platform-kms,
// so a foreign machine aud never enabled validation-via-widening; this token
// validated purely via the static "hanzo-console" member and was a bona-fide admin
// pre-V6. Denying it would be an over-block that breaks multi-aud admin tokens.
// isKMSMachinePrincipal returns false and admin is retained. This is CORRECT: the
// admin-deny gate is OWNER-BOUND — it fires only on the owner's own machine aud, so a
// foreign machine aud in the set never strips a bona-fide admin. Denying it would be
// an over-block that breaks multi-aud admin tokens.
func TestRed_ForeignMachineAudInSet_RealAdminKept(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -127,13 +126,13 @@ func TestRed_ForeignMachineAudInSet_RealAdminKept(t *testing.T) {
"(a foreign machine aud must NOT strip admin; that would be an over-block)", resp.StatusCode)
}
// Sanity contrast on the SAME app: owner=admin + isAdmin=true + only the FOREIGN
// machine aud (NO static member). This does NOT validate — the widening adds only
// admin-platform-kms, and maxpower-platform-kms is not in the static allowlist →
// anonymous → 403. Proves the foreign machine aud grants no validation of its own.
faOnly := mintRed(t, key, "admin", []string{paasOrgA + "-platform-kms"}, true, future)
if resp := getWithBearer(t, app, victimPath, faOnly); resp.StatusCode != 403 {
t.Fatalf("owner=admin, aud=[maxpower-platform-kms] only, isAdmin=true → victim = %d, "+
"want 403 (foreign machine aud is not owner-bound; token must not validate)", resp.StatusCode)
// Contrast the DISCRIMINATOR is the owner's OWN machine aud, not any machine aud:
// swap the foreign maxpower-platform-kms for admin's OWN admin-platform-kms and the
// SAME shape becomes a machine principal → isKMSMachinePrincipal fires → admin stripped
// → victim read 403. So a FOREIGN machine aud keeps admin (fa above); the OWN machine
// aud strips it — the gate is owner-bound.
ownMach := mintRed(t, key, "admin", []string{"hanzo-console", "admin-platform-kms"}, true, future)
if resp := getWithBearer(t, app, victimPath, ownMach); resp.StatusCode != 403 {
t.Fatalf("owner=admin carrying its OWN machine aud → victim = %d, want 403 (own machine aud strips admin)", resp.StatusCode)
}
}
+23 -24
View File
@@ -88,13 +88,12 @@ func getBearerHdr(t *testing.T, app *zip.App, path, token string, hdr map[string
return resp
}
// ── Vector 1: multi-value aud with a static-allowlist member ────────────────────
// ── Vector 1: multi-value aud carrying a victim's machine aud ────────────────────
//
// acme presents aud = ["hanzo-console" (STATIC-allowlisted), "maxpower-platform-kms"
// (the VICTIM's machine aud)]. AnyAudience OR-matches, so this token VALIDATES via
// "hanzo-console". The attack: the presence of the victim-bound machine aud, or the
// intersection semantics, must NOT let acme reach maxpower. Owner (=acme, signed)
// governs.
// acme presents aud = ["hanzo-console", "maxpower-platform-kms" (the VICTIM's machine
// aud)]. The token validates (audience is not a gate). The attack: the presence of the
// victim-bound machine aud in the set must NOT let acme reach maxpower. Owner (=acme,
// signed) governs.
func TestRed_MultiValueAud_OwnerStillGoverns(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -130,13 +129,12 @@ func TestRed_MultiValueAud_OwnerStillGoverns(t *testing.T) {
// must NOT thereby become SuperAdmin — owner==adminOrg ALONE is not admin; the
// code requires isAdmin=true. So it can read only the admin org's own secrets.
//
// (b) The RESIDUAL: the SAME token but isAdmin=TRUE. This models an isAdmin-bearing
// (b) The machine-principal exception: the SAME owner==AdminOrg but isAdmin=TRUE. A
//
// token whose ONLY audience is the per-tenant machine aud (not in the static
// allowlist) — pre-V6 that 403s at validation; POST-V6 the machine-aud
// acceptance admits it to the SuperAdmin path and it reads EVERY tenant. The
// in-binary code does NOT defend against this; the sole barrier is the external
// invariant "IAM never stamps isAdmin=true on a machine-aud token."
// real admin is SuperAdmin from any app (audience is not a gate), but a MACHINE
// principal — identified by its OWN <owner>-platform-kms aud — is DENIED SuperAdmin
// by isKMSMachinePrincipal and pinned to its own org. A client_credentials machine
// identity must never wield platform-admin.
func TestRed_AdminOrgMachineToken(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -167,20 +165,21 @@ func TestRed_AdminOrgMachineToken(t *testing.T) {
t.Fatalf("admin-org machine token + X-Org-Id:maxpower switch = %d, want 403", resp.StatusCode)
}
// (b) RESIDUAL — NOW CLOSED by Blue's fix (isKMSMachinePrincipal): isAdmin=TRUE,
// aud == the machine aud ONLY (NOT in the static allowlist).
// Contrast probe: isAdmin=true with an ARBITRARY aud is 403 (validation never
// admits it, so isAdmin is never consulted).
// (b) The DISCRIMINATOR is isKMSMachinePrincipal, not the audience. A REAL admin
// (isAdmin=true) gets SuperAdmin whatever app minted the token — audience is not a
// gate, owner==adminOrg + isAdmin is the authority — so an admin token with an
// arbitrary aud reads the victim cross-org → 200.
arbAdminTrue := mintRed(t, key, "admin", []string{"some-random-app"}, true, future)
if resp := getWithBearer(t, app, victimPath, arbAdminTrue); resp.StatusCode != 403 {
t.Fatalf("isAdmin=true + arbitrary aud → victim = %d, want 403 (not admitted)", resp.StatusCode)
if resp := getWithBearer(t, app, victimPath, arbAdminTrue); resp.StatusCode != 200 {
t.Fatalf("isAdmin=true + arbitrary aud → victim = %d, want 200 (a real admin is admin from any app)", resp.StatusCode)
}
// Swap the arbitrary aud for the machine aud: the token now VALIDATES (V6), but
// SanitizeIdentity denies SuperAdmin to a MACHINE principal, so it is pinned to
// owner=admin and CANNOT read the victim → 403 (was 200 pre-fix — residual closed).
// The ONE exception: a MACHINE principal (its OWN <owner>-platform-kms aud present)
// is DENIED SuperAdmin by isKMSMachinePrincipal even with isAdmin=true, so it is
// pinned to owner=admin and CANNOT read the victim → 403 (a machine identity must
// never wield platform-admin).
machAdminTrue := mintRed(t, key, "admin", []string{"admin-platform-kms"}, true, future)
if resp := getWithBearer(t, app, victimPath, machAdminTrue); resp.StatusCode != 403 {
t.Fatalf("RESIDUAL must be CLOSED: isAdmin=true + machine aud → victim = %d, want 403 "+
t.Fatalf("machine principal (isAdmin=true + own machine aud) → victim = %d, want 403 "+
"(a machine principal must NEVER receive SuperAdmin)", resp.StatusCode)
}
// The fix gates ONLY the admin grant: the machine principal still reads its OWN
@@ -215,8 +214,8 @@ func TestRed_TrimCollapseOwner_FailsClosed(t *testing.T) {
}
}
// Empty owner with the bare-suffix aud: kmsMachineAudience("")=="" so no machine
// aud is added; the bare "-platform-kms" is not in the allowlist → anonymous → 403.
// Empty owner with the bare-suffix aud: the token validates (audience is not a gate)
// but owner is empty → no org scope → the guard 403s the victim read (fail closed).
empty := mintRed(t, key, "", []string{"-platform-kms"}, false, future)
if resp := getWithBearer(t, app, victimPath, empty); resp.StatusCode != 403 {
t.Fatalf("empty-owner bare-suffix aud = %d, want 403 (fail closed)", resp.StatusCode)
+25 -28
View File
@@ -1,21 +1,19 @@
package kms_test
// V6 END-TO-END (the activation blocker). A REAL, RSA-signed client_credentials-
// style bearer — owner=<org>, aud=<org>-platform-kms (the tenant's own IAM
// application clientId, which is by construction NEVER in the configured audience
// allowlist) — flows through cloud's ACTUAL SanitizeIdentity middleware
// (cloud.IdentityMiddleware) and the ACTUAL /v1/kms org-scope guard.
// KMS MACHINE-TOKEN END-TO-END. A REAL, RSA-signed client_credentials-style bearer —
// owner=<org>, aud=<org>-platform-kms (the tenant's own IAM application clientId) —
// flows through cloud's ACTUAL SanitizeIdentity middleware (cloud.IdentityMiddleware)
// and the ACTUAL /v1/kms org-scope guard.
//
// This is the gap Red flagged: the existing kms_test/paas_sync_test do() helper
// HEADER-INJECTS X-Org-Id / X-User-Id and never exercises validation. Here the org
// is derived by SanitizeIdentity from the SIGNED owner claim, exactly as in
// Audience is NOT an access gate: trust is signature + issuer + expiry, and reach is
// the SIGNED owner claim (guard: owner == :org). So a machine token validates like any
// other and is isolated to its own org by owner. The existing kms_test/paas_sync_test
// do() helper HEADER-INJECTS X-Org-Id / X-User-Id and never exercises validation; here
// the org is derived by SanitizeIdentity from the signed owner claim, exactly as in
// production, so the test proves the whole chain:
//
// real signed machine token → SanitizeIdentity (audience accepted, owner derived)
// real signed machine token → SanitizeIdentity (signature/issuer/expiry, owner derived)
// → /v1/kms guard (owner == :org) → 200 own org / 403 else
//
// If the V6 audience fix regressed, case (1) would 403 (machine aud rejected →
// anonymous → guard denies) and the sync would stay pending — the failure this locks.
import (
"crypto/rand"
@@ -93,11 +91,8 @@ func e2eCfg(t *testing.T, jwksURL string) *cloud.Config {
return &cloud.Config{
Brand: "hanzo",
Domain: "api.hanzo.ai",
IAMIssuer: e2eIssuer,
JWKSURL: jwksURL,
// The machine aud (<org>-platform-kms) is deliberately ABSENT here, so a 200
// below proves the OWNER-BOUND machine-aud FIX, not a broadened allowlist.
JWTAudiences: []string{"hanzo-console"},
IAMIssuer: e2eIssuer,
JWKSURL: jwksURL,
AdminOrg: "admin",
DataDir: t.TempDir(),
Enable: []string{"kms"},
@@ -148,11 +143,11 @@ func TestPaaSSyncMachineTokenEndToEnd(t *testing.T) {
aPath := "/v1/kms/orgs/" + paasOrgA + paasEnvPath
future := time.Now().Add(time.Hour)
// (1) maxpower's REAL machine token reads maxpower's secret → 200. Its aud
// (maxpower-platform-kms) is NOT in the allowlist; acceptance is the V6 fix.
// (1) maxpower's REAL machine token reads maxpower's secret → 200. Audience is not an
// access gate (trust is signature + issuer + expiry); owner=maxpower is the scope.
own := mintMachineToken(t, key, paasOrgA, paasOrgA+"-platform-kms", future)
if resp := getWithBearer(t, app, aPath, own); resp.StatusCode != 200 {
t.Fatalf("machine token → own org = %d, want 200 (activation blocker still open?)", resp.StatusCode)
t.Fatalf("machine token → own org = %d, want 200", resp.StatusCode)
} else if got := decode(t, resp.Body)["value"]; got != paasValueA {
t.Fatalf("read value=%v, want the sealed secret", got)
}
@@ -164,16 +159,18 @@ func TestPaaSSyncMachineTokenEndToEnd(t *testing.T) {
t.Fatalf("cross-tenant machine token (acme→maxpower) = %d, want 403", resp.StatusCode)
}
// (3) Owner-bound: a maxpower token bearing acme's machine aud is INVALID — the
// audience is bound to the token's OWN owner, so validation fails → anonymous →
// guard 403. Proves the aud is not a blanket "*-platform-kms" wildcard.
if resp := getWithBearer(t, app, aPath, mintMachineToken(t, key, paasOrgA, paasOrgB+"-platform-kms", future)); resp.StatusCode != 403 {
t.Fatalf("owner-mismatched machine aud = %d, want 403", resp.StatusCode)
// (3) Audience is not a gate: a token with a brand-new, never-registered aud still
// reads its OWN org (owner=maxpower) → 200 — the "new first-party app just works with
// zero cloud change" invariant. The aud never widens reach beyond the owner.
if resp := getWithBearer(t, app, aPath, mintMachineToken(t, key, paasOrgA, "a-brand-new-app-never-registered", future)); resp.StatusCode != 200 {
t.Fatalf("never-registered aud reading OWN org = %d, want 200 (aud is not a gate)", resp.StatusCode)
}
// (4) A token with an arbitrary audience is not accepted → 403 (fix is scoped).
if resp := getWithBearer(t, app, aPath, mintMachineToken(t, key, paasOrgA, "some-random-app", future)); resp.StatusCode != 403 {
t.Fatalf("arbitrary-audience token = %d, want 403", resp.StatusCode)
// (4) …and the aud still cannot cross tenants: owner=maxpower bearing acme's machine
// aud reading ACME's path is denied by owner-scope → 403 (owner governs, not aud).
bPath := "/v1/kms/orgs/" + paasOrgB + paasEnvPath
if resp := getWithBearer(t, app, bPath, mintMachineToken(t, key, paasOrgA, paasOrgB+"-platform-kms", future)); resp.StatusCode != 403 {
t.Fatalf("owner=maxpower token reading acme path = %d, want 403 (owner scopes, not aud)", resp.StatusCode)
}
// (5) An expired machine token is anonymous → 403 (fail closed on expiry).
-65
View File
@@ -73,11 +73,6 @@ type Config struct {
// (HIP-0111); override with CLOUD_JWKS_URL.
JWKSURL string
// JWTAudiences is the audience allowlist the sanitizer accepts (OR semantics).
// Defaults to the known Hanzo IAM client_ids; override with CLOUD_JWT_AUDIENCES
// (comma-separated) or GATEWAY_ALLOWED_AUDIENCES.
JWTAudiences []string
// KMSMasterKeyRef is the base64-encoded 32-byte KMS master key (KEK) the
// embedded luxfi/kms store seals every secret's DEK under. The operator
// injects it from a K8s Secret as CLOUD_KMS_MASTER_KEY_REF; cloud reads it
@@ -513,7 +508,6 @@ func LoadConfig() *Config {
if cfg.JWKSURL == "" {
cfg.JWKSURL = jwksURLFor(cfg.IAMIssuer)
}
cfg.JWTAudiences = jwtAudiencesFromEnv()
// Browser ZAP-over-WS Origin allowlist. Default to the console SPA hosts so
// the console can connect cross-origin; override with CLOUD_ZAP_WEB_ORIGINS.
@@ -644,65 +638,6 @@ func getenv(key, dflt string) string {
return dflt
}
// defaultJWTAudiences mirrors github.com/hanzoai/gateway/v2/iamauth.DefaultAudiences
// (the known Hanzo IAM client_ids — each app's `aud` is its client_id) plus
// hanzo-cloud, cloud's own session client. Forwards-only: append new client_ids,
// never remove. Non-hanzo brands set CLOUD_JWT_AUDIENCES to their own client_ids.
var defaultJWTAudiences = []string{
"hanzo-app",
"hanzo-console",
"hanzo-chat",
"hanzo-id",
"hanzo-admin-guard",
"admin-console",
"hanzo-cloud",
"hanzo-world",
"hanzo-team",
"cowork",
"https://api.hanzo.ai",
}
// jwtAudiencesFromEnv resolves the JWT audience allowlist for the identity
// sanitizer. CLOUD_JWT_AUDIENCES wins; GATEWAY_ALLOWED_AUDIENCES (the gateway's
// own override, shared so both agree) is honored next; otherwise the baked
// default. The white-label brand cloud audiences (BrandAudiences: <brand>-cloud)
// are ALWAYS unioned in — baked like BrandIssuers so ONE binary accepts a
// lux/zoo/pars token (aud=<brand>-cloud) even when the env override predates the
// brands (fail-secure: only ADDS known-good brand client_ids, never an arbitrary
// aud). Never empty, so the audience check is always enforced.
func jwtAudiencesFromEnv() []string {
base := append([]string(nil), defaultJWTAudiences...)
for _, key := range []string{"CLOUD_JWT_AUDIENCES", "GATEWAY_ALLOWED_AUDIENCES"} {
if list := splitTrim(os.Getenv(key)); len(list) > 0 {
base = list
break
}
}
return unionStrings(base, BrandAudiences())
}
// unionStrings appends every value of add not already present in base, preserving
// order and dropping empties. Used to fold the always-trusted brand audiences into
// the resolved allowlist without duplicating an env-supplied entry.
func unionStrings(base, add []string) []string {
out := append([]string(nil), base...)
seen := make(map[string]struct{}, len(out)+len(add))
for _, s := range out {
seen[s] = struct{}{}
}
for _, s := range add {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// splitTrim splits a comma-separated list, trimming and dropping empties.
func splitTrim(s string) []string {
if strings.TrimSpace(s) == "" {
+1 -1
View File
@@ -292,7 +292,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, cfg.JWTAudiences, 0), cfg.AdminOrg)
return SanitizeIdentity(newIdentityValidator(cfg.IAMIssuer, cfg.JWKSURL, 0), cfg.AdminOrg)
}
// sanitizeSubScopes re-injects the org SUB-SCOPES (X-Project-Id, X-App-Id) for a
+2 -2
View File
@@ -31,7 +31,7 @@ func billingClaims(owner, name, billingAccount string) idClaims {
// X-Billing-Account-Id a downstream handler observes.
func identityProbe(t *testing.T, key *rsa.PrivateKey, jwksURL string, tok string, mutate func(*http.Request)) string {
t.Helper()
v := newIdentityValidator(testIssuer, jwksURL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwksURL, 0)
var got string
app := zip.New(zip.Config{})
app.Use(SanitizeIdentity(v, "admin"))
@@ -150,7 +150,7 @@ func TestSanitizeIdentity_AnonymousCarriesNoBillingAccount(t *testing.T) {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
var got string
app := zip.New(zip.Config{})
+1 -1
View File
@@ -152,7 +152,7 @@ func TestSanitizeIdentity_SubScopes(t *testing.T) {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
// acme owns "site-a"; beta owns "secret".
+20 -21
View File
@@ -120,7 +120,7 @@ func TestSanitizeIdentity(t *testing.T) {
t.Fatalf("genkey2: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
past := time.Now().Add(-time.Hour)
@@ -129,7 +129,7 @@ func TestSanitizeIdentity(t *testing.T) {
normalUser := signWith(t, key, tokenClaims("hanzo-console", "acme", "joe@acme.io", false, future))
expiredAdmin := signWith(t, key, tokenClaims("hanzo-console", "admin", "z@hanzo.ai", true, past))
wrongKeyAdmin := signWith(t, otherKey, tokenClaims("hanzo-console", "admin", "z@hanzo.ai", true, future))
wrongAudAdmin := signWith(t, key, tokenClaims("evil-app", "admin", "z@hanzo.ai", true, future))
arbitraryAudAdmin := signWith(t, key, tokenClaims("some-other-first-party-app", "admin", "z@hanzo.ai", true, future))
// Owners whose IAM name carries whitespace — the RED CRIT-2 residual vector.
// The whitespace rides in the JWT `owner` claim (JSON-preserved, so it is
// transport-independent, unlike a header which fasthttp OWS-trims), so a
@@ -211,17 +211,21 @@ func TestSanitizeIdentity(t *testing.T) {
wantOrg: "",
},
{
name: "wrong-audience admin token is anonymous",
mutate: bearer(wrongAudAdmin),
wantAdmin: false,
wantOrg: "",
// Audience is not an access gate: an admin-org token validates whatever app
// minted it (aud is informational), so a real admin gets SuperAdmin from ANY
// first-party app — owner==adminOrg is the authority, not the aud. The only
// admin-org token DENIED SuperAdmin is a KMS-sync machine principal (next case).
name: "admin token with an arbitrary audience still gets SuperAdmin",
mutate: bearer(arbitraryAudAdmin),
wantAdmin: true,
wantOrg: "admin",
},
{
// V6 residual close: a KMS-sync MACHINE principal (aud=<owner>-platform-kms)
// in the admin org with isAdmin=true VALIDATES (V6 accepts the machine aud)
// but is DENIED SuperAdmin — pinned to its own org, never cross-org. So
// the machine-audience widening cannot be leveraged into an admin bypass.
name: "admin-org machine principal is denied SuperAdmin (V6 decoupling)",
// A KMS-sync MACHINE principal (aud=<owner>-platform-kms) in the admin org
// with isAdmin=true VALIDATES like any token but is DENIED SuperAdmin —
// isKMSMachinePrincipal gates it out, pinned to its own org, never cross-org.
// A client_credentials machine identity must never wield platform-admin.
name: "admin-org machine principal is denied SuperAdmin",
mutate: bearer(signWith(t, key, tokenClaims("admin-platform-kms", "admin", "z@hanzo.ai", true, future))),
wantAdmin: false,
wantOrg: "admin",
@@ -305,7 +309,7 @@ func TestSanitizeIdentity_OrgAdminHeader(t *testing.T) {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
var gotAdmin, gotOrgAdmin, gotOrg string
@@ -416,7 +420,7 @@ func TestSanitizeIdentity_StampsUserName(t *testing.T) {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
c := tokenClaims("hanzo-console", "hanzo", "z@hanzo.ai", false, time.Now().Add(time.Hour))
c.Subject = "2d4d67ab-30f1-474e-b81f-f60461852259" // the JWT subject: a UUID
@@ -454,7 +458,7 @@ func TestSanitizeIdentity_UserNameForgeryStripped(t *testing.T) {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
c := tokenClaims("hanzo-console", "hanzo", "z@hanzo.ai", false, time.Now().Add(time.Hour))
c.Name = "z"
@@ -519,7 +523,7 @@ func TestIdentityValidator(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
other, _ := rsa.GenerateKey(rand.Reader, 2048)
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
t.Run("valid token", func(t *testing.T) {
@@ -545,11 +549,6 @@ func TestIdentityValidator(t *testing.T) {
t.Fatal("missing issuer must be rejected")
}
})
t.Run("wrong audience rejected", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("evil-app", "admin", "", true, future))); err == nil {
t.Fatal("wrong audience must be rejected")
}
})
t.Run("expired rejected", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("hanzo-console", "admin", "", true, time.Now().Add(-time.Hour)))); err == nil {
t.Fatal("expired token must be rejected")
@@ -604,7 +603,7 @@ func TestSuperAdminGate_IsAdminOrgMembership(t *testing.T) {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
app, got := newIdentityApp(t, v) // adminOrg = "admin"
future := time.Now().Add(time.Hour)
+7 -8
View File
@@ -22,8 +22,8 @@ import (
model "github.com/hanzoai/iam/pkg/model"
)
// VerifiedIdentity is what a token PROVED, after signature, issuer, audience and
// expiry all checked out. It is deliberately the small subset a caller can act on;
// VerifiedIdentity is what a token PROVED, after signature, issuer and expiry all
// checked out. It is deliberately the small subset a caller can act on;
// the authorization decision still belongs to the caller (deploy compares Owner to
// the admin org) and, independently, to SanitizeIdentity on every later request.
type VerifiedIdentity struct {
@@ -58,14 +58,13 @@ type VerifiedIdentity struct {
// Safe for concurrent use; the underlying JWKS cache is shared and stale-on-error.
type TokenValidator struct{ v *identityValidator }
// NewTokenValidator builds a validator bound to issuer, with the SAME JWKS
// endpoint and the SAME audience allowlist SanitizeIdentity uses — jwksURLFor and
// jwtAudiencesFromEnv are the single source for both, so a token this accepts is a
// token the boundary accepts, and the two can never drift apart into a mint-then-
// refuse loop.
// NewTokenValidator builds a validator bound to issuer, with the SAME JWKS endpoint
// SanitizeIdentity uses — jwksURLFor is the single source for both, so a token this
// accepts is a token the boundary accepts, and the two can never drift apart into a
// mint-then-refuse loop.
func NewTokenValidator(issuer string) *TokenValidator {
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
return &TokenValidator{v: newIdentityValidator(issuer, jwksURLFor(issuer), jwtAudiencesFromEnv(), 0)}
return &TokenValidator{v: newIdentityValidator(issuer, jwksURLFor(issuer), 0)}
}
// Validate verifies raw and returns what it proved. The error is the real reason
+2 -2
View File
@@ -22,7 +22,7 @@ func TestVerifiedIdentityCarriesOrgs(t *testing.T) {
t.Fatal(err)
}
jwks := jwksServer(t, &key.PublicKey)
v := &TokenValidator{v: newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-team"}, 0)}
v := &TokenValidator{v: newIdentityValidator(testIssuer, jwks.URL, 0)}
claims := tokenClaims("hanzo-team", "maxpower", "dave@example.com", false, time.Now().Add(time.Hour))
claims.Orgs = []model.OrgRef{
@@ -51,7 +51,7 @@ func TestVerifiedIdentityLegacyNoOrgs(t *testing.T) {
t.Fatal(err)
}
jwks := jwksServer(t, &key.PublicKey)
v := &TokenValidator{v: newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-team"}, 0)}
v := &TokenValidator{v: newIdentityValidator(testIssuer, jwks.URL, 0)}
claims := tokenClaims("hanzo-team", "acme", "ada@example.com", false, time.Now().Add(time.Hour))
id, err := v.Validate(signWith(t, key, claims))