`hanzo status` could not see the cloud because the token it carries could not pass the gate. The reason was not the token. SanitizeIdentity granted platform sudo on `claims.homeOrg() == adminOrg`, which is `Claims.Orgs[0].Org` — a POSITIONAL read. IAM's MemberOrgRefs always writes the user's own org at index 0 and appends every granted membership after it, so that test could only ever be true for someone whose USER ROW lives in the admin org. An operator anchored in a brand org and GRANTED admin-org membership — the deliberate, signed, revocable way operators are actually made — was structurally unreachable by it. z@hanzo.ai carries orgs:[{hanzo,admin},{admin,admin}, {lux,admin},{pars,admin},{zoo,admin}] and was refused every superAdminOf surface because `admin` sits at index 1. This widens nothing. The authority was already signed by IAM and already guarded on the write side: memberships.mayGrant refuses to create a membership into a reserved org unless the caller is already a SuperAdmin, on the stated grounds that it "seeds admin-org (SuperAdmin) tenancy". IAM protects the grant as platform authority; this gate now honors it as platform authority. The two agreeing is the fix. Both admin scopes now ask the predicates hanzoai/authz publishes — the issuer's own statement of what its claims mean — narrowed by the one denial only cloud can make (the per-org KMS-sync machine, named by its owner-bound audience; authz decides machine-ness from an empty membership set, which a machine carrying memberships would defeat). cloud's private re-derivations are deleted rather than kept beside them, because two readings of one claim is the condition that package exists to end. The org-admin bit was the same defect one scope down: `claims.IsAdmin || isOrgAdmin(...)` is an UNSCOPED disjunct, so a token carrying the bit would have been org-admin in whatever org it switched INTO. authz.Claims.OrgAdmin scopes it to the home org. The term is inert against IAM today, which is exactly why it could sit there reading wrong — a dead term cannot fail a test. Closes the standing "scope the legacy isAdmin bit to home org" item by adopting the published predicate rather than patching the local one. There is no isAdmin CLAIM in any of this, in either direction. IAM mints one into NEITHER token: internal/oidc/jwt.go's Claims struct has no such field, and (*Signer).claims is the single place an Identity becomes a claim set, so the access token and the id_token differ only in aud/tokenType/nonce. The bit exists only as a user-row column that userinfo and whoami report in a response body. Copying it into the access token would have changed nothing, because no gate reads it. The adminOrg parameter is gone. The reserved org is the ISSUER's constant — IAM hardcodes `owner == "admin"` — so a consumer-side knob could only ever let cloud disagree with the contract it is reading. This file's own test doc asserted "Hanzo pins it to hanzo", which, had anyone set IAM_ADMIN_ORG that way, would have handed platform sudo to every member of the hanzo org while IAM considered none of them a SuperAdmin. Production never set it, so the default carried the truth by luck. TestPlatformSudoIsMembershipNotPosition pins the fact end to end through real JWKS-validated tokens, including z's live membership set verbatim. It fails against the positional predicate and passes against the set one; the negative cases (admin of every brand org but no reserved membership, a look-alike "Admin", an empty set) pass under both, which is how the change is shown to widen nothing. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
274 lines
9.9 KiB
Go
274 lines
9.9 KiB
Go
package cloud
|
|
|
|
// Tests for the org SUB-SCOPE half of the identity trust boundary: X-Project-Id
|
|
// is MINTED from the validated `project` claim (never a raw client header), a
|
|
// forged client X-Project-Id is ignored and cannot override the claim, a claim
|
|
// FOREIGN to the acted-as org is refused (admin org-switch), X-App-Id rides as a
|
|
// caller label, and every sub-scope is dropped on the anonymous path — while the
|
|
// existing X-Org-Id anti-forgery is untouched. Reuses the JWKS + token helpers
|
|
// from middleware_identity_test.go (same package).
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"errors"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// fakeScopeResolver answers ownership from a static project→org map. mine iff the
|
|
// project is owned by org; other iff it is owned by some different org.
|
|
type fakeScopeResolver struct{ owner map[string]string }
|
|
|
|
func (f fakeScopeResolver) ProjectOwnership(_ context.Context, org, id string) (mine, other bool, err error) {
|
|
o, ok := f.owner[id]
|
|
switch {
|
|
case !ok:
|
|
return false, false, nil
|
|
case o == org:
|
|
return true, false, nil
|
|
default:
|
|
return false, true, nil
|
|
}
|
|
}
|
|
|
|
// errScopeResolver always fails — exercises the fail-closed path.
|
|
type errScopeResolver struct{}
|
|
|
|
func (errScopeResolver) ProjectOwnership(context.Context, string, string) (bool, bool, error) {
|
|
return false, false, errors.New("registry down")
|
|
}
|
|
|
|
// withResolvers installs rs as the boundary's resolver set for one test and
|
|
// restores the prior set on cleanup (package-global, so isolate carefully).
|
|
func withResolvers(t *testing.T, rs ...OrgScopeResolver) {
|
|
t.Helper()
|
|
orgResolverMu.Lock()
|
|
old := orgResolvers
|
|
orgResolvers = append([]OrgScopeResolver(nil), rs...)
|
|
orgResolverMu.Unlock()
|
|
t.Cleanup(func() {
|
|
orgResolverMu.Lock()
|
|
orgResolvers = old
|
|
orgResolverMu.Unlock()
|
|
})
|
|
}
|
|
|
|
// TestProjectIsForeign is the pure decision, independent of HTTP.
|
|
func TestProjectIsForeign(t *testing.T) {
|
|
owns := fakeScopeResolver{owner: map[string]string{"site-a": "acme", "secret": "beta"}}
|
|
withResolvers(t, owns)
|
|
|
|
cases := []struct {
|
|
name string
|
|
org, project string
|
|
want bool
|
|
}{
|
|
{"empty org", "", "site-a", false},
|
|
{"empty project", "acme", "", false},
|
|
{"own project", "acme", "site-a", false},
|
|
{"another org's project", "acme", "secret", true},
|
|
{"unregistered free-form label", "acme", "team-scratch", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := projectIsForeign(context.Background(), tc.org, tc.project); got != tc.want {
|
|
t.Fatalf("projectIsForeign(%q,%q)=%v want %v", tc.org, tc.project, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProjectIsForeign_FailClosed: a registry error with no confirming "mine"
|
|
// refuses the claim; a second registry that DOES own it wins (never refused).
|
|
func TestProjectIsForeign_FailClosed(t *testing.T) {
|
|
t.Run("error alone → foreign", func(t *testing.T) {
|
|
withResolvers(t, errScopeResolver{})
|
|
if !projectIsForeign(context.Background(), "acme", "anything") {
|
|
t.Fatal("registry error with no owner must fail CLOSED (foreign)")
|
|
}
|
|
})
|
|
t.Run("error + owning registry → not foreign", func(t *testing.T) {
|
|
withResolvers(t, errScopeResolver{}, fakeScopeResolver{owner: map[string]string{"p": "acme"}})
|
|
if projectIsForeign(context.Background(), "acme", "p") {
|
|
t.Fatal("a registry that OWNS the project must win over a peer's error")
|
|
}
|
|
})
|
|
t.Run("no registries → never foreign", func(t *testing.T) {
|
|
withResolvers(t) // empty
|
|
if projectIsForeign(context.Background(), "acme", "whatever") {
|
|
t.Fatal("with no registry, nothing is foreign")
|
|
}
|
|
})
|
|
}
|
|
|
|
// scopeCap records the sub-scope headers a downstream handler observes.
|
|
type scopeCap struct {
|
|
org, user, project, app string
|
|
admin bool
|
|
}
|
|
|
|
// newScopeApp wires the REAL SanitizeIdentity in front of a probe that records
|
|
// the sanitized identity + sub-scopes exactly as a subsystem would read them.
|
|
func newScopeApp(t *testing.T, v *identityValidator) (*zip.App, *scopeCap) {
|
|
t.Helper()
|
|
got := &scopeCap{}
|
|
app := zip.New(zip.Config{})
|
|
app.Use(SanitizeIdentity(v))
|
|
app.Get("/probe", func(c *zip.Ctx) error {
|
|
got.org = c.Org()
|
|
got.user = c.User()
|
|
got.admin = c.IsAdmin()
|
|
got.project = c.Header("X-Project-Id")
|
|
got.app = c.Header("X-App-Id")
|
|
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
|
})
|
|
return app, got
|
|
}
|
|
|
|
func setHdr(kv map[string]string) func(*http.Request) {
|
|
return func(r *http.Request) {
|
|
for k, v := range kv {
|
|
r.Header.Set(k, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func both(fns ...func(*http.Request)) func(*http.Request) {
|
|
return func(r *http.Request) {
|
|
for _, f := range fns {
|
|
f(r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeIdentity_SubScopes(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)
|
|
|
|
// acme owns "site-a"; beta owns "secret".
|
|
withResolvers(t, fakeScopeResolver{owner: map[string]string{"site-a": "acme", "secret": "beta"}})
|
|
|
|
// withProject signs a token for owner carrying a `project` claim — the ONLY
|
|
// source X-Project-Id is ever minted from (a client header is never trusted).
|
|
withProject := func(owner, email string, isAdmin bool, project string) string {
|
|
c := tokenClaims("hanzo-console", owner, email, isAdmin, future)
|
|
c.Project = project
|
|
return signWith(t, key, c)
|
|
}
|
|
|
|
t.Run("own project claim is minted", func(t *testing.T) {
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, bearer(withProject("acme", "joe@acme.io", false, "site-a")))
|
|
if got.org != "acme" || got.project != "site-a" {
|
|
t.Fatalf("own project claim must bind X-Project-Id: org=%q project=%q", got.org, got.project)
|
|
}
|
|
})
|
|
|
|
t.Run("unregistered free-form project claim survives", func(t *testing.T) {
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, bearer(withProject("acme", "joe@acme.io", false, "team-scratch")))
|
|
if got.project != "team-scratch" {
|
|
t.Fatalf("unregistered within-org claim must survive, got %q", got.project)
|
|
}
|
|
})
|
|
|
|
t.Run("default project claim mints no header", func(t *testing.T) {
|
|
// No project claim ⟹ default ⟹ header absent (minimal-canonical form).
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, bearer(withProject("acme", "joe@acme.io", false, "")))
|
|
if got.project != "" {
|
|
t.Fatalf("absent project claim must mint no header, got %q", got.project)
|
|
}
|
|
// The literal "default" is likewise omitted.
|
|
app2, got2 := newScopeApp(t, v)
|
|
probe(t, app2, bearer(withProject("acme", "joe@acme.io", false, "default")))
|
|
if got2.project != "" {
|
|
t.Fatalf("literal default project must mint no header, got %q", got2.project)
|
|
}
|
|
})
|
|
|
|
t.Run("forged client X-Project-Id is IGNORED (never a source)", func(t *testing.T) {
|
|
// Token has NO project claim; the client forges X-Project-Id. It must NOT
|
|
// survive — the header binds ONLY the validated claim.
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, both(bearer(withProject("acme", "joe@acme.io", false, "")),
|
|
setHdr(map[string]string{"X-Project-Id": "site-a"})))
|
|
if got.project != "" {
|
|
t.Fatalf("forged client X-Project-Id must be ignored, got %q", got.project)
|
|
}
|
|
})
|
|
|
|
t.Run("client X-Project-Id cannot override the claim (evade defense)", func(t *testing.T) {
|
|
// Token claims "site-a"; the client tries to relabel to "team-scratch" to
|
|
// evade the site-a cap. The CLAIM wins; the client value is dropped.
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, both(bearer(withProject("acme", "joe@acme.io", false, "site-a")),
|
|
setHdr(map[string]string{"X-Project-Id": "team-scratch"})))
|
|
if got.project != "site-a" {
|
|
t.Fatalf("claim must win over a forged client X-Project-Id, got %q", got.project)
|
|
}
|
|
})
|
|
|
|
t.Run("app rides as a caller label on the validated path", func(t *testing.T) {
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, both(bearer(withProject("acme", "joe@acme.io", false, "")),
|
|
setHdr(map[string]string{"X-App-Id": "web"})))
|
|
if got.app != "web" {
|
|
t.Fatalf("app label must survive the validated path, got %q", got.app)
|
|
}
|
|
})
|
|
|
|
t.Run("anonymous request strips every sub-scope", func(t *testing.T) {
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, setHdr(map[string]string{
|
|
"X-Org-Id": "victim", // Phase-1 data residual — but never validated
|
|
"X-Project-Id": "site-a", // forged; no principal
|
|
"X-App-Id": "web",
|
|
}))
|
|
if got.user != "" {
|
|
t.Fatalf("anonymous must carry no validated user, got %q", got.user)
|
|
}
|
|
if got.project != "" || got.app != "" {
|
|
t.Fatalf("anonymous sub-scopes must be stripped: project=%q app=%q", got.project, got.app)
|
|
}
|
|
})
|
|
|
|
t.Run("SuperAdmin org-switch refuses a cross-org project claim", func(t *testing.T) {
|
|
// SuperAdmin acts as beta but its token claims acme's "site-a" → the claim
|
|
// is FOREIGN to the acted-as org (beta) → stripped.
|
|
app, got := newScopeApp(t, v)
|
|
probe(t, app, both(bearer(withProject("admin", "z@hanzo.ai", true, "site-a")),
|
|
setHdr(map[string]string{"X-Org-Id": "beta"})))
|
|
if got.org != "beta" || !got.admin || got.project != "" {
|
|
t.Fatalf("admin-as-beta with acme's project must strip it: org=%q admin=%v project=%q", got.org, got.admin, got.project)
|
|
}
|
|
// When the admin's claim IS beta's project it is non-foreign to beta → kept.
|
|
app2, got2 := newScopeApp(t, v)
|
|
probe(t, app2, both(bearer(withProject("admin", "z@hanzo.ai", true, "secret")),
|
|
setHdr(map[string]string{"X-Org-Id": "beta"})))
|
|
if got2.org != "beta" || got2.project != "secret" {
|
|
t.Fatalf("admin-as-beta with beta's project must keep it: org=%q project=%q", got2.org, got2.project)
|
|
}
|
|
})
|
|
|
|
t.Run("X-Org-Id forgery still blocked (no regression)", func(t *testing.T) {
|
|
app, got := newScopeApp(t, v)
|
|
// A normal user cannot widen org by asserting X-Org-Id: victim.
|
|
probe(t, app, both(bearer(withProject("acme", "joe@acme.io", false, "")),
|
|
setHdr(map[string]string{"X-Org-Id": "victim"})))
|
|
if got.org != "acme" || got.admin {
|
|
t.Fatalf("client X-Org-Id must not widen scope: org=%q admin=%v", got.org, got.admin)
|
|
}
|
|
})
|
|
}
|