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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user