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.
164 lines
6.9 KiB
Go
164 lines
6.9 KiB
Go
// Copyright (c) 2014-present Hanzo AI, Inc.
|
|
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
|
|
|
|
package billing
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/models/subscription"
|
|
"github.com/hanzoai/commerce/util/bit"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// invokeSub drives a subscription handler with an org + datastore context + a
|
|
// caller identity (org-admin vs mint principal), reproducing the production reach.
|
|
func invokeSub(org *organization.Organization, ctx context.Context, identity func(*zip.Ctx), h zip.Handler, body string) *http.Response {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/subscriptions", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return driveSeeded(func(c *zip.Ctx) {
|
|
c.Locals("organization", org)
|
|
c.SetContext(ctx)
|
|
identity(c)
|
|
}, "/v1/billing/subscriptions", req, h)
|
|
}
|
|
|
|
// c1OrgAdmin models the adversary: an org owner (org-level isAdmin) who is NOT a
|
|
// SuperAdmin and NOT the platform's application → MayMintMoney false.
|
|
func c1OrgAdmin(c *zip.Ctx) {
|
|
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme", IsAdmin: true})
|
|
}
|
|
|
|
// c1MintPrincipal models the platform's own application — an IAM identity under
|
|
// the reserved admin org — → MayMintMoney true.
|
|
func c1MintPrincipal(c *zip.Ctx) { platformApp(c) }
|
|
|
|
// TestCreateSubscription_OrgAdminPaidTier_Denied is C1-a: an org admin must NOT
|
|
// self-create a PAID-tier ("max-5x": $100/mo, $25/mo included allotment) Active
|
|
// subscription with zero payment. Before the fix this returned 201, and the
|
|
// forged Active sub then unlocked the "max" allotment mint.
|
|
func TestCreateSubscription_OrgAdminPaidTier_Denied(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("acme")
|
|
|
|
body := `{"userId":"acme/self","planId":"max-5x"}`
|
|
w := invokeSub(org, ctx, c1OrgAdmin, CreateBillingSubscription, body)
|
|
if w.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("org-admin self-subscribe to paid 'max': status=%d body=%s, want 403 (no free paid tier)", w.StatusCode, func() string { b, _ := io.ReadAll(w.Body); return string(b) }())
|
|
}
|
|
}
|
|
|
|
// TestCreateSubscription_OrgAdminFreeTier_Allowed — control: a FREE tier
|
|
// ("developer": $0, no included allotment) stays self-serve for an org admin.
|
|
func TestCreateSubscription_OrgAdminFreeTier_Allowed(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("acme")
|
|
|
|
body := `{"userId":"acme/self","planId":"developer"}`
|
|
w := invokeSub(org, ctx, c1OrgAdmin, CreateBillingSubscription, body)
|
|
if w.StatusCode == http.StatusForbidden {
|
|
t.Fatalf("org-admin self-subscribe to FREE 'developer': status=403 body=%s, want allowed (free tier is self-serve)", func() string { b, _ := io.ReadAll(w.Body); return string(b) }())
|
|
}
|
|
}
|
|
|
|
// TestCreateSubscription_MintPrincipalPaidTier_Allowed — control: cloud-api (the
|
|
// internal service token) may still create a paid-tier subscription (e.g. after a
|
|
// real payment). The gate narrows WHO, not the legitimate path.
|
|
func TestCreateSubscription_MintPrincipalPaidTier_Allowed(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("acme")
|
|
|
|
body := `{"userId":"acme/self","planId":"max-5x"}`
|
|
w := invokeSub(org, ctx, c1MintPrincipal, CreateBillingSubscription, body)
|
|
if w.StatusCode == http.StatusForbidden {
|
|
t.Fatalf("service-token create paid 'max': status=403 body=%s, want allowed (authorized mint principal)", func() string { b, _ := io.ReadAll(w.Body); return string(b) }())
|
|
}
|
|
}
|
|
|
|
// seedSub writes a subscription for user with the given plan slug, status, provider
|
|
// type, and (optionally) a linked invoice id.
|
|
func seedSub(t *testing.T, db *datastore.Datastore, user, slug string, status subscription.Status, providerType, invoiceID string) {
|
|
t.Helper()
|
|
s := subscription.New(db)
|
|
s.UserId = user
|
|
s.Status = status
|
|
s.ProviderType = providerType
|
|
s.CurrentInvoiceId = invoiceID
|
|
s.Plan.Slug = slug
|
|
s.PlanId = slug
|
|
if err := s.Create(); err != nil {
|
|
t.Fatalf("seed subscription: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSubscriptionPlanSlug_PaymentBackedOnly is C1-a's entitlement clamp: a PAID
|
|
// tier's plan is returned as the mintable entitlement ONLY when the subscription
|
|
// is payment-backed. A self-created internal Active 'max' sub yields "" (so the
|
|
// allotment clamps to $0); a Stripe-backed or invoiced one yields "max".
|
|
func TestSubscriptionPlanSlug_PaymentBackedOnly(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("acme")
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
|
|
// Forged: internal, Active, paid tier, NO invoice → NOT entitlement-bearing.
|
|
seedSub(t, db, "acme/forger", "max", subscription.Active, "internal", "")
|
|
if got := subscriptionPlanSlug(db, "acme/forger", false); got != "" {
|
|
t.Fatalf("forged internal Active 'max' sub: subscriptionPlanSlug=%q, want \"\" (not payment-backed → no minted allotment)", got)
|
|
}
|
|
|
|
// Real: Stripe-managed Active paid sub → entitlement-bearing.
|
|
seedSub(t, db, "acme/payer", "max", subscription.Active, "stripe", "")
|
|
if got := subscriptionPlanSlug(db, "acme/payer", false); got != "max" {
|
|
t.Fatalf("stripe-backed 'max' sub: subscriptionPlanSlug=%q, want \"max\"", got)
|
|
}
|
|
|
|
// Real: internal but INVOICED (billing cycle collected) → entitlement-bearing.
|
|
seedSub(t, db, "acme/invoiced", "max", subscription.Active, "internal", "inv_123")
|
|
if got := subscriptionPlanSlug(db, "acme/invoiced", false); got != "max" {
|
|
t.Fatalf("invoiced internal 'max' sub: subscriptionPlanSlug=%q, want \"max\"", got)
|
|
}
|
|
|
|
// A FREE tier needs no backing (mints nothing) → returned as-is.
|
|
seedSub(t, db, "acme/freeuser", "developer", subscription.Active, "internal", "")
|
|
if got := subscriptionPlanSlug(db, "acme/freeuser", false); got != "developer" {
|
|
t.Fatalf("free 'developer' sub: subscriptionPlanSlug=%q, want \"developer\"", got)
|
|
}
|
|
}
|
|
|
|
// TestPlanForGrant_OrgAdminCannotAnchorOnForgedSub proves the clamp end-to-end:
|
|
// an org admin (not a mint principal) with a forged internal Active 'max' sub
|
|
// cannot have the grant resolve to 'max', even naming it explicitly.
|
|
func TestPlanForGrant_OrgAdminCannotAnchorOnForgedSub(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("acme")
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
seedSub(t, db, "acme/forger", "max", subscription.Active, "internal", "")
|
|
|
|
// Build an org-admin request context (MayMintMoney false).
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
c := app.TestCtx("POST", "/")
|
|
c1OrgAdmin(c)
|
|
|
|
if got := planForGrant(c, db, "acme/forger", "max", false); got != "" {
|
|
t.Fatalf("org-admin planForGrant with forged 'max' sub + explicit 'max': got %q, want \"\" (clamped — allotment mints $0)", got)
|
|
}
|
|
}
|