mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
146 lines
5.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|