feat(billing): thread validated-project bool through ResourceMeter.Gate (#262)

principal.ValidatedProject(c) now flows through ResourceMeter.Gate into
metering.AuthInput.ProjectValidated, so resource-creation caps harden
consistently with the edge BillingGate. Removes the hardcoded
ProjectValidated:false at resource_billing.go.

- Gate signature gains projectValidated bool (adjacent to project, mirroring
  principal.ValidatedProject's return + AuthInput's field order).
- Every *zip.Ctx caller passes principal.ValidatedProject(c); the three
  no-principal/client-body paths (metered_ai LLM decorator, background agent
  run, content studio in.Project) pass false — unvalidated stays soft, never
  fabricated true.
- Meter/Record path unchanged: Usage carries no ProjectValidated; cap
  enforcement is Gate-only, so threading it there would be dead code.

Stays SOFT in prod today: ValidatedProject is true only for a validated NAMED
project, and IAM seeds none yet, so no org has a project claim. No behavior
change now; named-project caps auto-harden per-org as IAM seeds them.

Co-authored-by: Hanzo <dev@hanzo.ai>
This commit is contained in:
antje
2026-07-11 21:44:51 -07:00
committed by GitHub
co-authored by hanzo-dev
parent 1c3206dcbf
commit e3c544de2b
13 changed files with 94 additions and 37 deletions
+3 -2
View File
@@ -649,8 +649,9 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
fee := cloud.ResourceFeeCents(agentFeeEnvPrefix, meterKind)
// Gate the AGENT's own org — never a caller default, never another tenant.
// fee<=0 or unconfigured billing makes this a no-op (allows).
if err := s.State.bill.Gate(ctx, a.Org, "", meterKind, fee); err != nil {
// fee<=0 or unconfigured billing makes this a no-op (allows). Background run
// path: no request principal, so the project axis is empty + unvalidated (soft).
if err := s.State.bill.Gate(ctx, a.Org, "", false, meterKind, fee); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "balance gate denied")
return Run{}, err
+2 -1
View File
@@ -272,7 +272,8 @@ func run(s *cloud.Service[state], c *zip.Ctx) error {
// enforced on a bot launch exactly as on the request edge. fee<=0 or
// unconfigured billing makes this a no-op (allow).
fee := cloud.ResourceFeeCents(botFeeEnvPrefix, meterKind)
if gateErr := s.State.bill.Gate(c.Context(), org, principal.Project(c), meterKind, fee); gateErr != nil {
project, projectValidated := principal.ValidatedProject(c)
if gateErr := s.State.bill.Gate(c.Context(), org, project, projectValidated, meterKind, fee); gateErr != nil {
return cloud.DenyResource(c, gateErr)
}
+3 -1
View File
@@ -139,8 +139,10 @@ func (g *aiStudioGenerator) draftAsset(ctx context.Context, org string, in Gener
project := strings.TrimSpace(in.Project)
// Gate the render to the brand's org BEFORE the compute (fail-closed 402 out of funds).
// project rides the request body (in.Project), not a server-minted identity claim,
// so it is unvalidated → a project-scoped cap stays soft (anti project-spoof).
fee := cloud.ResourceFeeCents("CONTENT_STUDIO_FEE_CENTS", kind)
if err := g.bill.Gate(ctx, org, project, "asset", fee); err != nil {
if err := g.bill.Gate(ctx, org, project, false, "asset", fee); err != nil {
return nil, err
}
+2 -2
View File
@@ -180,7 +180,8 @@ func invoke(s *cloud.Service[state], c *zip.Ctx) error {
// charge can never target another org. fee is computed once and reused by
// the post-success debit; fee==0 or unconfigured billing makes this a no-op.
fee := cloud.ResourceFeeCents(invokeFeeEnvPrefix, "invoke")
if err := s.Bill.Gate(c.Context(), org, principal.Project(c), "invoke", fee); err != nil {
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), org, project, projectValidated, "invoke", fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -225,7 +226,6 @@ func invoke(s *cloud.Service[state], c *zip.Ctx) error {
// Either is independently free (fee 0 → no-op), so an operator can bill by
// request alone, compute alone, or both.
if runErr == nil {
project := principal.Project(c)
s.Bill.Meter(org, project, "invoke", fee, c.RequestID(), cloud.ClientIP(c))
gbSecCents := gbSecondsCents(dur, memLimitMB(f.MemoryLimit), cloud.ResourceFeeCents(gbSecFeeEnvPrefix, "gbsec"))
s.Bill.MeterUsage(org, "gbsec", metering.Usage{
+2 -1
View File
@@ -267,7 +267,8 @@ func create(s *cloud.Service[state], k resourceKind) zip.Handler {
// so billing can never target another tenant. fee is reused by the
// post-success debit; fee==0 or unconfigured billing makes this a no-op.
fee := cloud.ResourceFeeCents(computeFeeEnvPrefix, k.kind)
if err := s.State.bill.Gate(c.Context(), org, project, k.kind, fee); err != nil {
_, projectValidated := principal.ValidatedProject(c)
if err := s.State.bill.Gate(c.Context(), org, project, projectValidated, k.kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
+2 -1
View File
@@ -105,7 +105,8 @@ func run(s *cloud.Service[state], c *zip.Ctx) error {
// resolved above is sent as both the commerce user and X-Org-Id), never a
// default — the anti-cross-tenant billing property (resource_billing.go).
fee := cloud.ResourceFeeCents(runFeeEnvPrefix, runKind)
if err := s.Bill.Gate(c.Context(), org, principal.Project(c), runKind, fee); err != nil {
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), org, project, projectValidated, runKind, fee); err != nil {
return cloud.DenyResource(c, err)
}
+2 -1
View File
@@ -305,7 +305,8 @@ func create(s *cloud.Service[state], kind string) zip.Handler {
// reused by the post-success debit; fee==0 or unconfigured billing makes
// this a no-op. Applies to BOTH strategies.
fee := cloud.ResourceFeeCents(provisionFeeEnvPrefix, kind)
if err := s.Bill.Gate(ctx, org, principal.Project(c), kind, fee); err != nil {
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(ctx, org, project, projectValidated, kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
+2 -1
View File
@@ -180,7 +180,8 @@ func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
ctx.Locals(orgKey, org)
fee := cloud.ResourceFeeCents(opFeeEnvPrefix, "op")
if err := s.State.bill.Gate(ctx.Context(), org, principal.Project(ctx), "op", fee); err != nil {
project, projectValidated := principal.ValidatedProject(ctx)
if err := s.State.bill.Gate(ctx.Context(), org, project, projectValidated, "op", fee); err != nil {
return cloud.DenyResource(ctx, err)
}
if err := h(ctx); err != nil {
+4 -2
View File
@@ -220,7 +220,8 @@ func createProject(s *cloud.Service[state], c *zip.Ctx) error {
kind := "project"
fee := createFeeCents(kind)
if err := s.Bill.Gate(c.Context(), org, principal.Project(c), kind, fee); err != nil {
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), org, project, projectValidated, kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -415,7 +416,8 @@ func createIssue(s *cloud.Service[state], c *zip.Ctx) error {
kind := "issue"
fee := createFeeCents(kind)
if err := s.Bill.Gate(c.Context(), org, principal.Project(c), kind, fee); err != nil {
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), org, project, projectValidated, kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
+2 -1
View File
@@ -58,7 +58,8 @@ func attachCluster(s *cloud.Service[state], c *zip.Ctx) error {
// Nominal management-fee gate (fail-closed, per-org — billing keys on the paying
// org, not the project sub-scope).
fee := cloud.ResourceFeeCents("CLOUD_COMPUTE_FEE_CENTS", byoClusterKind)
if err := s.State.bill.Gate(c.Context(), org, principal.Project(c), byoClusterKind, fee); err != nil {
_, projectValidated := principal.ValidatedProject(c)
if err := s.State.bill.Gate(c.Context(), org, principal.Project(c), projectValidated, byoClusterKind, fee); err != nil {
return cloud.DenyResource(c, err)
}
rec, err := s.State.fleet.Register(c.Context(), org, project(c), name, req.Kubeconfig, req.Provider, req.Default)
+5 -1
View File
@@ -122,7 +122,11 @@ func (m *meteredAI) gate(ctx context.Context, org, project string, tokens int) e
if org == "" {
return nil
}
return m.meter.Gate(ctx, org, project, aiMeterProvider, m.cents(tokens))
// project rides the ChatRequest/EmbedRequest value (internal S2S), not a
// server-minted identity claim, so it is unvalidated here → a project-scoped
// cap stays soft. The request-edge BillingGate already hardens the validated
// project axis for the inbound LLM path.
return m.meter.Gate(ctx, org, project, false, aiMeterProvider, m.cents(tokens))
}
// record debits the EXACT micro-USD cost to the org's billing account, attributed
+12 -13
View File
@@ -96,24 +96,23 @@ func (rm *ResourceMeter) Enabled() bool { return rm != nil && rm.m != nil && rm.
// would authorize an arbitrarily expensive charge (the debit still lands, taking
// the ledger negative). This mirrors what a prepaid gate must do: refuse a
// request the balance cannot cover BEFORE the work runs.
// project is the caller's validated org SUB-SCOPE (principal.Project(c)) — the
// scope's project axis — and service is intrinsically this meter's provider, so
// a per-scope spend cap (issue #70) on (project, provider) is enforced on resource
// creation exactly as it is on the request edge. Pass "" for project on a
// background/no-principal path (the resource is then gated only by org- and
// service-scoped caps).
func (rm *ResourceMeter) Gate(ctx context.Context, org, project, kind string, costCents int64) error {
// project + projectValidated are the caller's org SUB-SCOPE and whether it is
// bound to a VALIDATED identity claim — principal.ValidatedProject(c), the SAME
// signal the edge BillingGate threads. When validated, a project-scoped spend cap
// (issue #70) on (project, provider) HARD-enforces (402) on resource creation
// exactly as on the request edge; when not, it DEGRADES to soft (org- and
// service-scoped caps stay hard), so a forgeable X-Project-Id can neither
// hard-stop nor be evaded. service is intrinsically this meter's provider. Pass
// ("", false) on a background/no-principal path (org- and service-scoped caps only).
func (rm *ResourceMeter) Gate(ctx context.Context, org, project string, projectValidated bool, kind string, costCents int64) error {
if !rm.Enabled() || costCents <= 0 {
return nil
}
return rm.m.Authorize(ctx, metering.AuthInput{
User: org, Org: org, AmountCents: costCents,
// Service (=provider) is server-set → validated. Project is the caller's
// X-Project-Id, NOT yet claim-bound, so ProjectValidated stays false: a
// project-scoped cap on a resource DEGRADES to soft (org- and
// service-scoped caps stay hard), matching the edge posture. When IAM mints
// a project claim, thread principal.ValidatedProject through here to harden.
Project: project, ProjectValidated: false, Service: rm.provider,
// Service (=provider) is server-set → always validated. Project hardens iff
// it is claim-bound (projectValidated) — the anti project-spoof gate.
Project: project, ProjectValidated: projectValidated, Service: rm.provider,
})
}
+53 -10
View File
@@ -99,7 +99,7 @@ func TestResourceMeter_GateAllowsFundedCallerOrg(t *testing.T) {
fc := &recCommerce{balanceAvailable: 5000}
rm := meterFor(t, fc.server(t).URL, "mainnet", false)
if err := rm.Gate(t.Context(), "acme", "", "sql", 100); err != nil {
if err := rm.Gate(t.Context(), "acme", "", false, "sql",100); err != nil {
t.Fatalf("Gate(funded) = %v, want nil", err)
}
if got := fc.lastBalanceOrg(); got != "acme" {
@@ -112,7 +112,7 @@ func TestResourceMeter_GateRefusesAtZero(t *testing.T) {
fc := &recCommerce{balanceAvailable: 0}
rm := meterFor(t, fc.server(t).URL, "mainnet", false)
if err := rm.Gate(t.Context(), "acme", "", "sql", 100); err != metering.ErrInsufficientBalance {
if err := rm.Gate(t.Context(), "acme", "", false, "sql",100); err != metering.ErrInsufficientBalance {
t.Fatalf("Gate(zero balance) = %v, want ErrInsufficientBalance", err)
}
}
@@ -123,7 +123,7 @@ func TestResourceMeter_GateFreeKindNoCommerceCall(t *testing.T) {
fc := &recCommerce{balanceAvailable: 0}
rm := meterFor(t, fc.server(t).URL, "mainnet", false)
if err := rm.Gate(t.Context(), "acme", "", "sql", 0); err != nil {
if err := rm.Gate(t.Context(), "acme", "", false, "sql",0); err != nil {
t.Fatalf("Gate(free kind) = %v, want nil", err)
}
if n := fc.balances(); n != 0 {
@@ -138,7 +138,7 @@ func TestResourceMeter_GateFailClosedOnCommerceError(t *testing.T) {
fc := &recCommerce{balanceStatus: http.StatusInternalServerError}
rm := meterFor(t, fc.server(t).URL, "mainnet", false)
err := rm.Gate(t.Context(), "acme", "", "sql", 100)
err := rm.Gate(t.Context(), "acme", "", false, "sql",100)
if err == nil {
t.Fatal("Gate(commerce 5xx, fail-closed) = nil, want a deny error (no free provisioning on outage)")
}
@@ -152,7 +152,7 @@ func TestResourceMeter_GateFailOpenOnCommerceError(t *testing.T) {
fc := &recCommerce{balanceStatus: http.StatusInternalServerError}
rm := meterFor(t, fc.server(t).URL, "mainnet", true /* fail-open */)
if err := rm.Gate(t.Context(), "acme", "", "sql", 100); err != nil {
if err := rm.Gate(t.Context(), "acme", "", false, "sql",100); err != nil {
t.Fatalf("Gate(commerce 5xx, fail-open) = %v, want nil", err)
}
}
@@ -198,7 +198,7 @@ func TestResourceMeter_GateIsolatesTenants(t *testing.T) {
fc := &recCommerce{balanceAvailable: 5000}
rm := meterFor(t, fc.server(t).URL, "mainnet", false)
if err := rm.Gate(t.Context(), "globex", "", "vector", 100); err != nil {
if err := rm.Gate(t.Context(), "globex", "", false, "vector",100); err != nil {
t.Fatalf("Gate(globex) = %v, want nil", err)
}
if got := fc.lastBalanceOrg(); got != "globex" {
@@ -206,13 +206,56 @@ func TestResourceMeter_GateIsolatesTenants(t *testing.T) {
}
}
// A VALIDATED named project makes resource creation HARD; a default/unvalidated
// project stays SOFT — the resource-side mirror of the edge BillingGate's project
// hardening (issue #70). The fake commerce is funded (so gating reaches the scope
// cap) and enforces the project cap ONLY when it sees pv=1, exactly modelling
// commerce's project-spoof degrade. This proves principal.ValidatedProject threads
// through ResourceMeter.Gate into AuthInput.ProjectValidated: a claim-bound project
// forwards pv=1 and 402s on the cap, while a forgeable/absent one forwards no pv and
// is allowed, so a spoofed X-Project-Id can neither hard-stop nor evade a cap.
func TestResourceMeter_GateProjectValidatedHardens(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/billing/balance":
_, _ = io.WriteString(w, `{"available":100000}`) // funded — proceed to the scope cap.
case "/v1/billing/spend-alerts/authorize":
if r.URL.Query().Get("pv") == "1" { // validated project → cap HARD-enforces.
_ = json.NewEncoder(w).Encode(map[string]any{
"allow": false, "reason": "spend_cap", "capCents": 100, "spentCents": 100,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"allow": true}) // unvalidated → soft (degraded).
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
rm := meterFor(t, srv.URL, "mainnet", false)
// Validated NAMED project → pv=1 → the project-scoped cap HARD-enforces (402).
if err := rm.Gate(t.Context(), "acme", "acme-prod", true, "sql", 100); err != metering.ErrSpendCapExceeded {
t.Fatalf("Gate(validated named project) = %v, want ErrSpendCapExceeded (project cap must HARD-enforce)", err)
}
// Default/unvalidated project → no pv → the SAME cap degrades to soft (allow).
if err := rm.Gate(t.Context(), "acme", "", false, "sql", 100); err != nil {
t.Fatalf("Gate(default/unvalidated) = %v, want nil (unvalidated project cap must stay soft)", err)
}
// A NAMED project the caller did not prove (validated=false) also stays soft —
// a forgeable label can neither hard-stop nor be weaponised to evade a cap.
if err := rm.Gate(t.Context(), "acme", "acme-prod", false, "sql", 100); err != nil {
t.Fatalf("Gate(unvalidated named project) = %v, want nil (forgeable label must not hard-enforce)", err)
}
}
// Env never bypasses billing: on testnet AND devnet, a zero balance still
// refuses. test/dev are sandbox-but-billed, not free.
func TestResourceMeter_EnvNeverBypassesGate(t *testing.T) {
for _, env := range []string{"testnet", "devnet"} {
fc := &recCommerce{balanceAvailable: 0}
rm := meterFor(t, fc.server(t).URL, env, false)
if err := rm.Gate(t.Context(), "acme", "", "sql", 100); err != metering.ErrInsufficientBalance {
if err := rm.Gate(t.Context(), "acme", "", false, "sql",100); err != metering.ErrInsufficientBalance {
t.Fatalf("env=%s: Gate(zero) = %v, want ErrInsufficientBalance (test/dev must still bill)", env, err)
}
}
@@ -226,7 +269,7 @@ func TestResourceMeter_UnconfiguredIsNoop(t *testing.T) {
if rm.Enabled() {
t.Fatal("ResourceMeter with empty commerce URL must not be Enabled()")
}
if err := rm.Gate(t.Context(), "acme", "", "sql", 100); err != nil {
if err := rm.Gate(t.Context(), "acme", "", false, "sql",100); err != nil {
t.Fatalf("Gate(unconfigured) = %v, want nil (no-op)", err)
}
rm.Meter("acme", "", "sql", 100, "r", "") // must not panic
@@ -239,7 +282,7 @@ func TestResourceMeter_NilSafe(t *testing.T) {
if rm.Enabled() {
t.Fatal("nil ResourceMeter must report !Enabled()")
}
if err := rm.Gate(t.Context(), "acme", "", "sql", 100); err != nil {
if err := rm.Gate(t.Context(), "acme", "", false, "sql",100); err != nil {
t.Fatalf("nil Gate = %v, want nil", err)
}
rm.Meter("acme", "", "sql", 100, "r", "") // must not panic
@@ -248,7 +291,7 @@ func TestResourceMeter_NilSafe(t *testing.T) {
if rm2.Enabled() {
t.Fatal("ResourceMeter with nil client must report !Enabled()")
}
if err := rm2.Gate(t.Context(), "acme", "", "sql", 100); err != nil {
if err := rm2.Gate(t.Context(), "acme", "", false, "sql",100); err != nil {
t.Fatalf("nil-client Gate = %v, want nil", err)
}
}