Files
commerce/middleware/platformonly_test.go
zeekay f7aabf72f7 SuperAdmin is the identity edge's verdict, and commerce compares no org
IsSuperAdmin folded the case of the home org and fell back to the owner
claim, the org of the application that minted the token, and the money
bits were granted on X-User-Owner == admin. Cloud writes X-User-Owner for
an admin-org application and an admin-org person's API key as well as for
a person, so either could mint money and edit the catalog here while
being nobody in the rest of the same process. Every gate now reads
X-User-IsAdmin, which the edge strips and writes from authz.Claims.Sudo
alone; the standalone EdgeAuth decides it with that predicate over the
verified token's signed membership set. X-User-IsAdmin no longer doubles
as the org-admin bit, which is X-User-IsOrgAdmin. No machine is
SuperAdmin. Global admin is SuperAdmin wherever it named this predicate.
2026-10-18 22:08:47 -07:00

146 lines
5.8 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/auth"
"github.com/hanzoai/commerce/util/bit"
"github.com/hanzoai/commerce/util/permission"
"github.com/hanzoai/commerce/util/test/ae"
)
// runMintGate drives the EXACT production money-mint middleware chain —
// TokenRequired(permission.Admin) THEN PlatformOnly() — that api/billing.Route
// mounts on /credit, /deposit, /credits and
// /customer-balance/adjustments, to a sentinel handler. Returns the HTTP status and whether the
// sentinel was reached. seed pre-sets context state exactly as the real upstream
// middleware would (EdgeAuth/IAMTokenRequired mint iam_authenticated + permissions
// + iam_claims).
func runMintGate(t *testing.T, seed func(*zip.Ctx), reqSetup func(*http.Request)) (int, bool) {
t.Helper()
reached := false
app := zip.New(zip.Config{DisableStartupMessage: true})
if seed != nil {
app.Use(zip.H(func(c *zip.Ctx) error { seed(c); return c.Next() }))
}
app.Use(TokenRequired(permission.Admin))
app.Use(PlatformOnly())
app.Raw(http.MethodPost, "/x", func(c *zip.Ctx) error { reached = true; return c.NoContent(http.StatusOK) })
req := httptest.NewRequest(http.MethodPost, "/x", nil)
if reqSetup != nil {
reqSetup(req)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
return resp.StatusCode, reached
}
func iamIdentity(perms bit.Field, claims *auth.IAMClaims) func(*zip.Ctx) {
return func(c *zip.Ctx) {
c.Locals("iam_authenticated", true)
c.Locals("permissions", perms)
c.Locals("iam_claims", claims)
}
}
// TestPlatformOnly_OrgAdminDeniedMint is THE C1 proof at the gate: an ORG-level
// admin (org owner — org-level IsAdmin=true, so the gateway mints Admin|Live, but
// IsSuperAdmin()=false) PASSES TokenRequired(Admin) yet is DENIED (403) by
// PlatformOnly on the money-mint chain; the handler is never reached. This is the
// exact principal (e.g. maxpower) that could previously self-mint unlimited balance.
func TestPlatformOnly_OrgAdminDeniedMint(t *testing.T) {
orgAdmin := &auth.IAMClaims{Owner: "maxpower", IsAdmin: true} // org owner, NOT global
status, reached := runMintGate(t, iamIdentity(bit.Field(permission.Admin|permission.Live), orgAdmin), nil)
if status != http.StatusForbidden || reached {
t.Fatalf("org-admin on money-mint chain: status=%d reached=%v, want 403 & not-reached (this was the C1 hole)", status, reached)
}
}
// TestPlatformOnly_SuperAdminMints proves a SuperAdmin — the edge's verdict, in
// any org it acts in — passes the same chain (the legitimate human path RED's spec
// requires to still work).
func TestPlatformOnly_SuperAdminMints(t *testing.T) {
for _, gc := range []*auth.IAMClaims{
{Owner: "hanzo", Sudo: true}, // acting in a tenant
{Owner: "admin", Sudo: true}, // acting in its own org
} {
status, reached := runMintGate(t, iamIdentity(bit.Field(permission.Admin|permission.Live), gc), nil)
if status != http.StatusOK || !reached {
t.Fatalf("SuperAdmin %+v: status=%d reached=%v, want 200 & reached", gc, status, reached)
}
}
}
// TestPlatformOnly_AdminOrgMachineDenied: an application IAM minted under the admin
// org is a MACHINE, and no machine is SuperAdmin (authz.Claims.Sudo refuses every
// one; owner decision D1 is "no" until the owner says otherwise). It used to pass
// this chain on the org alone, which also passed an admin-org person's API key —
// both carry owner "admin" and neither is a SuperAdmin anywhere else.
func TestPlatformOnly_AdminOrgMachineDenied(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
app := &auth.IAMClaims{Owner: "admin"}
app.Subject = "app_platform"
identity := iamIdentity(bit.Field(permission.Admin|permission.Live), app)
seed := func(c *zip.Ctx) { c.SetContext(ctx); identity(c) }
status, reached := runMintGate(t, seed, func(r *http.Request) {
r.Header.Set("X-Org-Id", "svc-org")
})
if status != http.StatusForbidden || reached {
t.Fatalf("admin-org machine: status=%d reached=%v, want 403 & not reached", status, reached)
}
}
// TestPlatformOnly_AdminBitAloneDenied pins the gate's core invariant in isolation:
// holding the org-level Admin bit — or being merely IAM-authenticated as an org
// owner — is NOT sufficient; only SuperAdmin (owner=="admin") passes.
// This is the anti-conflation that closes C1: a legacy per-org access token and an
// org owner, both of which carry Admin, are refused. Fail-closed on empty context.
func TestPlatformOnly_AdminBitAloneDenied(t *testing.T) {
cases := []struct {
name string
seed func(*zip.Ctx)
}{
{"nothing set (fail-closed)", func(c *zip.Ctx) {}},
{"org-level Admin permission bit only (legacy access token)", func(c *zip.Ctx) {
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
}},
{"iam org-admin, not global", func(c *zip.Ctx) {
c.Locals("iam_authenticated", true)
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme", IsAdmin: true})
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached := false
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Use(zip.H(func(c *zip.Ctx) error { tc.seed(c); return c.Next() }))
app.Use(PlatformOnly())
app.Raw(http.MethodPost, "/x", func(c *zip.Ctx) error {
reached = true
return c.NoContent(http.StatusOK)
})
resp, err := app.Test(httptest.NewRequest(http.MethodPost, "/x", nil))
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusForbidden || reached {
t.Fatalf("%s: status=%d reached=%v, want 403 & not-reached", tc.name, resp.StatusCode, reached)
}
})
}
}