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:
+43
-52
@@ -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
|
||||
|
||||
@@ -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
@@ -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("")=="")`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user