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.
299 lines
12 KiB
Go
299 lines
12 KiB
Go
package billing
|
|
|
|
// mint_gates_test.go proves the three C1-reopened mint surfaces are closed:
|
|
// - ZAP billing.deposit (the /zap dispatch mint method)
|
|
// - /allotment/grant (the subscription-clamped plan)
|
|
// - /me/welcome (the TOCTOU double-mint)
|
|
// plus that each legitimate (service-token / privileged) path still mints.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/subscription"
|
|
"github.com/hanzoai/commerce/util/bit"
|
|
"github.com/hanzoai/commerce/util/nscontext"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// orgAdminSeed sets the exact C1 adversary: an org-level admin (org-level
|
|
// isAdmin, NOT a SuperAdmin) — a gateway-minted org owner or legacy per-org
|
|
// Admin token. NOT the internal service token, NOT a SuperAdmin.
|
|
// platformApp stamps the identity the platform's own service work carries: an
|
|
// application IAM minted under the reserved admin org (client_credentials), which
|
|
// IsSuperAdmin() admits because its home owner is "admin". It stands where a shared
|
|
// COMMERCE_SERVICE_TOKEN used to, and it is the same locals the boundary mints.
|
|
func platformApp(c *zip.Ctx) {
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
|
|
cl := &auth.IAMClaims{Owner: "admin", Sudo: true}
|
|
cl.Subject = "app_platform"
|
|
c.Locals("iam_claims", cl)
|
|
}
|
|
|
|
func orgAdminSeed(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})
|
|
}
|
|
|
|
// ── ZAP billing.deposit ─────────────────────────────────────────────────────
|
|
|
|
// TestZapDeposit_OrgAdminDenied is THE acceptance test for the [CRITICAL] miss:
|
|
// an org admin POSTing the ZAP billing.deposit method is 403 — it can no longer
|
|
// mint iam-user balance from a client amount via /zap (while /deposit already
|
|
// 403'd it). Reproduces RED's exact vector (amount 100000000, no ceiling).
|
|
func respBodyStr(r *http.Response) string {
|
|
b, _ := io.ReadAll(r.Body)
|
|
return string(b)
|
|
}
|
|
|
|
func TestZapDeposit_OrgAdminDenied(t *testing.T) {
|
|
eng := engineWithSeed(orgAdminSeed)
|
|
|
|
body := `{"method":"billing.deposit","params":{"user":"acme","amount":100000000,"currency":"usd"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/zap", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("Test: %v", terr)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("org-admin /zap billing.deposit: status=%d body=%s, want 403", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// TestZapDeposit_PlatformAppMints proves the legitimate path still mints: the
|
|
// internal service token reaches zapDeposit and creates the deposit (200 with a
|
|
// result). cloud-api authenticates exactly this way.
|
|
func TestZapDeposit_PlatformAppMints(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
body := `{"method":"billing.deposit","id":"z1","params":{"user":"zapmintorg","amount":250,"currency":"usd"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/zap", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Org-Id", "zapmintorg")
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("Test: %v", terr)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("service-token /zap billing.deposit: status=%d body=%s, want 200", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
var zapResp struct {
|
|
Result map[string]any `json:"result"`
|
|
Error *struct{ Message string } `json:"error"`
|
|
}
|
|
raw := respBodyStr(resp)
|
|
if err := json.Unmarshal([]byte(raw), &zapResp); err != nil {
|
|
t.Fatalf("bad zap response: %v (%s)", err, raw)
|
|
}
|
|
if zapResp.Error != nil {
|
|
t.Fatalf("service-token deposit returned zap error: %s", zapResp.Error.Message)
|
|
}
|
|
if tid, _ := zapResp.Result["transactionId"].(string); tid == "" {
|
|
t.Fatalf("service-token deposit missing transactionId: %v", zapResp.Result)
|
|
}
|
|
}
|
|
|
|
// TestZapReads_OrgAdminNotBlocked proves the gate does NOT over-block: an org
|
|
// admin can still call the ZAP read method (billing.getBalance) — only the mint
|
|
// method is gated.
|
|
func TestZapReads_OrgAdminNotBlocked(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
org := &organization.Organization{}
|
|
org.Name = "acme"
|
|
org.Live = true
|
|
eng := engineWithSeed(func(c *zip.Ctx) {
|
|
c.SetContext(ctx)
|
|
c.Locals("organization", org)
|
|
orgAdminSeed(c)
|
|
})
|
|
|
|
body := `{"method":"billing.getBalance","id":"r1","params":{"user":"acme/alice","currency":"usd"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/zap", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("Test: %v", terr)
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
t.Fatalf("org-admin /zap billing.getBalance was 403 — the mint gate over-blocked a read")
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("org-admin /zap billing.getBalance: status=%d body=%s, want 200", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// ── /allotment/grant plan clamp ─────────────────────────────────────────────
|
|
|
|
// TestAllotment_OrgAdminCannotInflatePlan is the acceptance test for the [HIGH]
|
|
// miss: an org admin naming the paid "team" plan (which grants $100/mo) for a
|
|
// subject with NO matching subscription is clamped to their real entitlement
|
|
// (0 cents) — they cannot mint the higher tier's allotment for free.
|
|
func TestAllotment_OrgAdminCannotInflatePlan(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
org := &organization.Organization{}
|
|
org.Name = "acme"
|
|
org.Live = true
|
|
eng := engineWithSeed(func(c *zip.Ctx) {
|
|
c.SetContext(ctx)
|
|
c.Locals("organization", org)
|
|
orgAdminSeed(c)
|
|
})
|
|
|
|
body := `{"user":"acme","plan":"team"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/allotment/grant", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("Test: %v", terr)
|
|
}
|
|
|
|
// The route is deliberately NOT platform-only (an org grants its OWN
|
|
// allotment); the defense is the clamp, so it must be reachable, not 403.
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
t.Fatalf("allotment/grant should be reachable for the org (clamped), got 403")
|
|
}
|
|
var out map[string]any
|
|
raw := respBodyStr(resp)
|
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
t.Fatalf("bad response: %v (%s)", err, raw)
|
|
}
|
|
if amt, _ := out["amountCents"].(float64); amt != 0 {
|
|
t.Fatalf("org-admin named 'team' without subscription: amountCents=%v, want 0 (clamped to real entitlement)", amt)
|
|
}
|
|
}
|
|
|
|
// TestAllotment_OrgAdminMatchingSubscriptionHonored proves the clamp is not
|
|
// over-strict: an org admin granting the allotment for a subject who REALLY IS
|
|
// subscribed to "team" — via a PAYMENT-BACKED subscription — gets the team plan's
|
|
// declared credit. This is RED's intended behavior — "an org
|
|
// legitimately grants its own allotment, it just must match the paid plan" — and
|
|
// validates that subscriptionPlanSlug actually finds the subscription (the
|
|
// Ancestor(synckey) fix). C1-a: the anchor MUST be payment-backed (external
|
|
// provider or invoiced), never a zero-payment internal Active sub — proven by
|
|
// TestSubscriptionPlanSlug_PaymentBackedOnly + TestPlanForGrant_OrgAdminCannotAnchorOnForgedSub.
|
|
func TestAllotment_OrgAdminMatchingSubscriptionHonored(t *testing.T) {
|
|
want := mintingTeam(t)
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
const ns = "submatch"
|
|
org := &organization.Organization{}
|
|
org.Name = ns
|
|
org.Live = true
|
|
|
|
// The subject really is subscribed to "team", and it is PAYMENT-BACKED (an
|
|
// external provider manages billing) — the only kind of paid-tier sub whose
|
|
// allotment may be minted. Seed it in the org's namespace.
|
|
sub := subscription.New(datastore.New(nscontext.WithNamespace(ctx, ns)))
|
|
sub.UserId = ns
|
|
sub.Plan.Slug = "team"
|
|
sub.Status = subscription.Active
|
|
sub.ProviderType = "stripe"
|
|
sub.PeriodStart = time.Now()
|
|
if err := sub.Create(); err != nil {
|
|
t.Fatalf("seed subscription: %v", err)
|
|
}
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) {
|
|
c.SetContext(ctx)
|
|
c.Locals("organization", org)
|
|
orgAdminSeed(c)
|
|
})
|
|
body := `{"user":"` + ns + `","plan":"team"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/allotment/grant", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("Test: %v", terr)
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
t.Fatalf("allotment/grant for a real subscriber should be reachable, got 403")
|
|
}
|
|
var out map[string]any
|
|
raw := respBodyStr(resp)
|
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
t.Fatalf("bad response: %v (%s)", err, raw)
|
|
}
|
|
if amt, _ := out["amountCents"].(float64); int64(amt) != want {
|
|
t.Fatalf("org-admin with a real 'team' subscription: amountCents=%v, want %d (clamp honors the matching paid plan)", amt, want)
|
|
}
|
|
}
|
|
|
|
// TestAllotment_PlatformAppMayNameAnyPlan proves the privileged override is
|
|
// preserved: the platform's own application (comps/backfills) may name "team" and
|
|
// mint its declared allotment.
|
|
func TestAllotment_PlatformAppMayNameAnyPlan(t *testing.T) {
|
|
want := mintingTeam(t)
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
body := `{"user":"allotorg","plan":"team"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/allotment/grant", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Org-Id", "allotorg")
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("Test: %v", terr)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("service-token allotment/grant: status=%d body=%s, want 200/201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
var out map[string]any
|
|
raw := respBodyStr(resp)
|
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
t.Fatalf("bad response: %v (%s)", err, raw)
|
|
}
|
|
if amt, _ := out["amountCents"].(float64); int64(amt) != want {
|
|
t.Fatalf("service-token named 'team': amountCents=%v, want %d (privileged override honored)", amt, want)
|
|
}
|
|
}
|
|
|
|
// The former /me/welcome mint surface (self-service welcome credit) was DELETED
|
|
// in the "one way to grant credit" consolidation: credit now enters an org ledger
|
|
// ONLY via the mint-gated POST /v1/billing/credit (see credit_test.go), so a user
|
|
// can no longer self-mint even the fixed starter amount. The TOCTOU / faucet
|
|
// invariants those tests protected are obsolete — the route is gone.
|
|
|
|
// mintingTeam gives team a $5-a-seat allotment for the life of one test and
|
|
// returns what it mints, in cents. The published catalog mints no credit — a
|
|
// plan covers AI instead — so the grant's clamp is exercised on a fixture rung.
|
|
func mintingTeam(t *testing.T) int64 {
|
|
t.Helper()
|
|
p := lookupPlan("team")
|
|
if p == nil || p.Limits == nil {
|
|
t.Fatal("team missing from the catalog")
|
|
}
|
|
was := p.Limits.IncludedCloudCreditsPerUser
|
|
five := 5
|
|
p.Limits.IncludedCloudCreditsPerUser = &five
|
|
t.Cleanup(func() { p.Limits.IncludedCloudCreditsPerUser = was })
|
|
return IncludedMonthlyCents("team")
|
|
}
|