Files
commerce/api/catalog/gate_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

120 lines
4.5 KiB
Go

package catalog
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/auth"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/util/bit"
"github.com/hanzoai/commerce/util/permission"
"github.com/hanzoai/commerce/util/test/ae"
)
// probeGate drives the REAL chain AdminRoute mounts — TokenRequired(permission.Admin)
// — to a sentinel that reports whether the named gate admitted the caller. The
// service-token branch is exercised the way production reaches it, a real Bearer
// against a real env, never by stamping the marker by hand: the marker is
// middleware's own private state and a test that writes it directly would keep
// passing after the real branch stopped setting it.
func probeGate(t *testing.T, gate func(*zip.Ctx) bool, seed func(*zip.Ctx), bearer string) (int, bool) {
t.Helper()
admitted := 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(middleware.TokenRequired(permission.Admin))
app.Raw(http.MethodPost, "/x", func(c *zip.Ctx) error {
if !gate(c) {
return nil
}
admitted = true
return c.NoContent(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/x", nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
return resp.StatusCode, admitted
}
func iamIdentity(claims *auth.IAMClaims) func(*zip.Ctx) {
return func(c *zip.Ctx) {
c.Locals("iam_authenticated", true)
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
c.Locals("iam_claims", claims)
}
}
// TestSyncGate_AdmitsThePlatformApp is the regression. The sync endpoints were
// gated on SuperAdmin CLAIMS, which the internal service token does not carry,
// so the scheduled run this catalog depends on could only ever 403 — and the
// model rows were in fact empty in production. A sync a human has to start is a
// catalog that goes stale.
func TestSyncGate_AdmitsThePlatformApp(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
app := &auth.IAMClaims{Owner: "admin", Sudo: true}
app.Subject = "app_catalog_sync"
status, admitted := probeGate(t, requirePlatform, iamIdentity(app), "")
if status != http.StatusOK || !admitted {
t.Fatalf("platform app on the sync gate: status=%d admitted=%v, want 200 & admitted", status, admitted)
}
}
// TestSyncGate_RefusesAnOrgAdmin holds the boundary the widening must not cross:
// an org owner carries org-level IsAdmin and clears TokenRequired(Admin), but the
// platform catalog is cross-tenant data and is not theirs to sync.
func TestSyncGate_RefusesAnOrgAdmin(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
orgAdmin := &auth.IAMClaims{Owner: "acme", IsAdmin: true}
status, admitted := probeGate(t, requirePlatform, iamIdentity(orgAdmin), "")
if status != http.StatusForbidden || admitted {
t.Fatalf("org admin on the sync gate: status=%d admitted=%v, want 403 & refused", status, admitted)
}
}
// TestEditGate_RefusesAnOpaqueBearer is the other half of the widening, and the
// one that keeps it honest: a bearer that is not a validated identity lands nowhere,
// on the sync endpoint or here. Editing an entry — which is how a retail price is
// set — takes an admin-org identity like the sync does, and the difference between
// a person and the platform's application is IAM's to draw, not a secret's.
// nothing more. Editing an entry — which is how a retail price is set — stays a
// named human in the admin org, so possession of a service credential can never
// change what a customer is charged.
func TestEditGate_RefusesAnOpaqueBearer(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
status, admitted := probeGate(t, requireSuperAdmin, nil, "not-an-identity")
if status == http.StatusOK || admitted {
t.Fatalf("opaque bearer on the entry-edit gate: status=%d admitted=%v, want refused", status, admitted)
}
}
// TestSyncGate_AdmitsASuperAdmin: a platform admin keeps the access they had, so
// the change is purely additive to who may run a sync.
func TestSyncGate_AdmitsASuperAdmin(t *testing.T) {
tc := ae.NewContext()
defer tc.Close()
superAdmin := &auth.IAMClaims{Owner: "admin", Sudo: true, IsAdmin: true}
status, admitted := probeGate(t, requirePlatform, iamIdentity(superAdmin), "")
if status != http.StatusOK || !admitted {
t.Fatalf("super admin on the sync gate: status=%d admitted=%v, want 200 & admitted", status, admitted)
}
}