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.
114 lines
4.9 KiB
Go
114 lines
4.9 KiB
Go
package costs
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
"github.com/hanzoai/commerce/util/bit"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
)
|
|
|
|
// testApp backs the synthetic request contexts the gate tests run against.
|
|
var testApp = zip.New(zip.Config{DisableStartupMessage: true})
|
|
|
|
// gateCtx builds a zip test context that optionally carries IAM claims and/or a
|
|
// permissions bit-field, mirroring what the auth middleware would have set.
|
|
func gateCtx(claims *auth.IAMClaims, perms *bit.Field) *zip.Ctx {
|
|
// A real request always carries headers; GetIAMClaims reads them as a fallback,
|
|
// so mirror production and give it one (no identity headers set).
|
|
c := testApp.TestCtx(http.MethodGet, "/v1/costs")
|
|
if claims != nil {
|
|
c.Locals("iam_claims", claims)
|
|
}
|
|
if perms != nil {
|
|
c.Locals("permissions", *perms)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// iamUser builds claims for a JWT-verified IAM user (a non-empty Subject, the sub
|
|
// the gateway always mints). Subject is promoted from the embedded StandardClaims.
|
|
// superAdmin is the identity edge's verdict (Sudo, written as X-User-IsAdmin), set
|
|
// independently of owner (the effective org) so a case can model a SuperAdmin
|
|
// acting in any org.
|
|
func iamUser(subject, owner string, isAdmin, superAdmin bool) *auth.IAMClaims {
|
|
cl := &auth.IAMClaims{Owner: owner, IsAdmin: isAdmin, Sudo: superAdmin}
|
|
cl.Subject = subject
|
|
return cl
|
|
}
|
|
|
|
func adminPerms() *bit.Field {
|
|
f := bit.Field(permission.Admin | permission.Live)
|
|
return &f
|
|
}
|
|
|
|
// TestRequireCostsAdmin is the CRITICAL authz gate: the vendor-cost god-view is
|
|
// platform spend, so ONLY a SuperAdmin (or the trusted M2M service token) may
|
|
// read it. The route-level TokenRequired(permission.Admin) is a no-op on the IAM
|
|
// path, so this in-handler gate is the real boundary.
|
|
func TestRequireCostsAdmin(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
claims *auth.IAMClaims
|
|
perms *bit.Field
|
|
want bool
|
|
}{
|
|
// A SuperAdmin is admitted, in any org it acts in.
|
|
{"superadmin (acting in acme)", iamUser("u1", "acme", false, true), adminPerms(), true},
|
|
{"superadmin (acting in admin)", iamUser("u1", "admin", false, true), adminPerms(), true},
|
|
// The org "admin" without the edge's verdict is not: an admin-org
|
|
// application or API key carries it too.
|
|
{"org admin without the verdict — REJECT", iamUser("app_platform", "admin", false, false), adminPerms(), false},
|
|
// The trusted M2M service token: Admin bit AND no IAM user identity (empty
|
|
// Subject). This is the console's own SuperAdmin-gated proxy forwarding.
|
|
{"legacy token (Admin bit, no IAM user) — REJECT", &auth.IAMClaims{}, adminPerms(), false},
|
|
{"no claims at all, Admin bit — REJECT", nil, adminPerms(), false},
|
|
|
|
// ── The attacks that MUST be refused ──
|
|
// An org-level admin tenant: the gateway mints permission.Admin from IsAdmin
|
|
// (edgeauth.permsHeader), so the Admin bit is present — but the caller carries
|
|
// an IAM Subject, so the service-token branch must NOT admit, and IsAdmin is
|
|
// NOT a SuperAdmin. This is the headline privilege-escalation the gate closes.
|
|
{"org admin tenant (Admin bit + IAM subject) — REJECT", iamUser("u2", "acme", true, false), adminPerms(), false},
|
|
// A plain tenant with no admin at all.
|
|
{"plain tenant — REJECT", iamUser("u3", "acme", false, false), nil, false},
|
|
// An IAM user with NO permissions bit set is still refused.
|
|
{"iam user, no perms — REJECT", iamUser("u4", "acme", true, false), nil, false},
|
|
// No auth context at all (handler mounted without the token gate): fail closed.
|
|
{"no auth context — REJECT", nil, nil, false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c := gateCtx(tc.claims, tc.perms)
|
|
got := requireCostsAdmin(c)
|
|
status := c.Fiber().Response().StatusCode()
|
|
body := string(c.Fiber().Response().Body())
|
|
if got != tc.want {
|
|
t.Fatalf("requireCostsAdmin = %v, want %v (status=%d body=%s)", got, tc.want, status, body)
|
|
}
|
|
if !tc.want && status != 403 {
|
|
t.Fatalf("rejected caller status = %d, want 403 (body=%s)", status, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestModelCostCentsClampsNegativeTokens: a malformed usage row with negative
|
|
// token counts must NOT produce negative COGS (which would understate cost and
|
|
// dishonestly inflate margin). Negatives clamp to 0; COGS never subtracts.
|
|
func TestModelCostCentsClampsNegativeTokens(t *testing.T) {
|
|
// 250c/Mtok in, 1000c/Mtok out.
|
|
cat := map[string]costRate{"gpt-4o": {InputCentsPerMTok: 250, OutputCentsPerMTok: 1000}}
|
|
if got, ok := modelCostCents(cat, "gpt-4o", -1_000_000, -1_000_000); !ok || got != 0 {
|
|
t.Errorf("negative tokens = %d ok=%v, want 0 true (clamped, never negative)", got, ok)
|
|
}
|
|
// A mixed row: negative prompt clamps to 0, positive completion still counts.
|
|
if got, ok := modelCostCents(cat, "gpt-4o", -5, 1_000_000); !ok || got != 1000 {
|
|
t.Errorf("mixed neg/pos tokens = %d ok=%v, want 1000 true", got, ok)
|
|
}
|
|
}
|