Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47eba96cbc | ||
|
|
5359686cfc |
@@ -217,6 +217,29 @@ func isMachinePrincipal(claims *idClaims) bool {
|
||||
return claims.Type == "application" || isKMSMachinePrincipal(claims)
|
||||
}
|
||||
|
||||
// isMember reports whether org is in the token's signed membership set — the
|
||||
// `orgs` claim IAM mints for a USER token, home org first. It is the ONE test that
|
||||
// turns a client's org SELECTION into an effective org (SanitizeIdentity), and
|
||||
// therefore into the ledger that pays (principal.BillingOrg).
|
||||
//
|
||||
// The comparison is VERBATIM, no folding, for the same reason the owner claim is
|
||||
// taken verbatim: "acme" and "ACME" are DISTINCT orgs in IAM, and a fold would let
|
||||
// a member of one select the other. An empty org is never a member, so an absent
|
||||
// selection leaves the caller in their home org. An empty set (a legacy token, an
|
||||
// opaque key, a machine principal — IAM never mints `orgs` for a client_credentials
|
||||
// token) admits nothing, which is exactly the pre-claim behavior.
|
||||
func isMember(orgs []model.OrgRef, org string) bool {
|
||||
if org == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range orgs {
|
||||
if o.Org == org {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validate parses raw, verifies its signature against the JWKS, and enforces
|
||||
// issuer/audience/expiry. Returns the claims on success, an error otherwise.
|
||||
func (v *identityValidator) validate(raw string) (*idClaims, error) {
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ func narrate(s *cloud.Service[*state], c *zip.Ctx, question string, facts []Fact
|
||||
Model: s.State.model,
|
||||
Prompt: narratePrompt(question, facts, tmpl),
|
||||
Org: org,
|
||||
BillingOrg: principal.HomeOrg(c),
|
||||
BillingOrg: principal.Ledger(c),
|
||||
})
|
||||
if err != nil || res == nil || strings.TrimSpace(res.Content) == "" {
|
||||
return tmpl
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ const (
|
||||
// BEFORE any work, so an out-of-funds caller gets a clean 402, never a half stream.
|
||||
func serveWeb(s *cloud.Service[*state], c *zip.Ctx, in AskRequest, q string) error {
|
||||
dataOrg, _ := principal.Org(c) // gated non-empty at askHandler entry
|
||||
payer := principal.HomeOrg(c)
|
||||
payer := principal.Ledger(c)
|
||||
if payer == "" {
|
||||
payer = dataOrg
|
||||
}
|
||||
|
||||
@@ -873,7 +873,7 @@ func recordRunEnd(s *cloud.Service[state], ctx context.Context, in RunEndInput)
|
||||
|
||||
// meterUnit records one metered unit for an HTTP caller's org. Nil/disabled meter → no-op.
|
||||
func meterUnit(s *cloud.Service[state], org string, c *zip.Ctx) {
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.Ledger(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
|
||||
}
|
||||
|
||||
// meterRun records one metered unit for a flow run from the durable path (no HTTP
|
||||
|
||||
@@ -316,7 +316,7 @@ func narrateAsk(s *cloud.Service[*state], c *zip.Ctx, org, question string, resp
|
||||
Model: s.State.model,
|
||||
Prompt: prompt,
|
||||
Org: org,
|
||||
BillingOrg: principal.HomeOrg(c),
|
||||
BillingOrg: principal.Ledger(c),
|
||||
})
|
||||
if err != nil || res == nil {
|
||||
return ""
|
||||
|
||||
@@ -120,7 +120,7 @@ func scanHandler(s *cloud.Service[*state], c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "books open failed")
|
||||
}
|
||||
ex, err := scanExtract(c.Context(), s.State.ai, s.State.model, org, principal.HomeOrg(c), text)
|
||||
ex, err := scanExtract(c.Context(), s.State.ai, s.State.model, org, principal.Ledger(c), text)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "scan extraction failed: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ package cloudflare
|
||||
// (cloud.BYOInferenceFeeMicros), never the full inference cost (that double-bills).
|
||||
// - O11Y (one, shared): it emits ONE gen_ai client span through clients.StartGenAISpan
|
||||
// (gen_ai.system = "cloudflare"), the same span plane every LLM/embedding call uses.
|
||||
// - PAYER: the HOME org (principal.HomeOrg) is billed, so a SuperAdmin acting in
|
||||
// another org spends from the admin ledger — the token, though, is the EFFECTIVE
|
||||
// org's. Same split the LLM meter enforces.
|
||||
// - PAYER: the SELECTED org (principal.Ledger) is billed — the org the caller
|
||||
// switched into, which is also the org whose token is used. A SuperAdmin
|
||||
// masquerading is the one exception and spends from the admin ledger. Same rule
|
||||
// the LLM meter enforces.
|
||||
//
|
||||
// A run needs only a validated org (authClient), not org admin: it is gated by BALANCE
|
||||
// like every model call, not by the admin bit that guards destructive verbs.
|
||||
@@ -99,7 +100,7 @@ func aiRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// Billing PAYER = the HOME org (X-User-Owner; falls back to the effective org for a
|
||||
// normal caller). Project narrows the scope + its validated cap, exactly as the
|
||||
// edge/LLM meters thread it.
|
||||
payer := principal.HomeOrg(c)
|
||||
payer := principal.Ledger(c)
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
|
||||
// BALANCE/FREEZE gate BEFORE any Cloudflare contact. The BYO fee is FLOORED
|
||||
|
||||
@@ -199,7 +199,7 @@ func (s *service) handleSearch(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
|
||||
eng, err := s.engineFor(org, principal.Ledger(c), principal.Project(c))
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
@@ -239,7 +239,7 @@ func (s *service) handleContext(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
|
||||
eng, err := s.engineFor(org, principal.Ledger(c), principal.Project(c))
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func (s *service) handleAsk(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
|
||||
eng, err := s.engineFor(org, principal.Ledger(c), principal.Project(c))
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func (s *service) handleIndex(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
res, err := s.indexRepo(c.Context(), org, principal.HomeOrg(c), principal.Project(c), store, repo, body.Files, body.Prune)
|
||||
res, err := s.indexRepo(c.Context(), org, principal.Ledger(c), principal.Project(c), store, repo, body.Files, body.Prune)
|
||||
if err != nil {
|
||||
s.log.Warn("code index failed", "org", org, "repo", repo, "err", err)
|
||||
return zip.ErrInternal("index failed")
|
||||
|
||||
@@ -181,7 +181,7 @@ func invoke(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// the post-success debit; fee==0 or unconfigured billing makes this a no-op.
|
||||
fee := cloud.ResourceFeeCents(invokeFeeEnvPrefix, "invoke")
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, "invoke", fee); err != nil {
|
||||
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, "invoke", fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
@@ -232,9 +232,9 @@ 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 {
|
||||
s.Bill.Meter(principal.HomeOrg(c), project, "invoke", fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.Ledger(c), project, "invoke", fee, c.RequestID(), cloud.ClientIP(c))
|
||||
gbSecCents := gbSecondsCents(dur, memLimitMB(f.MemoryLimit), cloud.ResourceFeeCents(gbSecFeeEnvPrefix, "gbsec"))
|
||||
s.Bill.MeterUsage(principal.HomeOrg(c), "gbsec", metering.Usage{
|
||||
s.Bill.MeterUsage(principal.Ledger(c), "gbsec", metering.Usage{
|
||||
Model: "gbsec", // the billed unit: GB-seconds of compute.
|
||||
AmountCents: gbSecCents,
|
||||
Project: project,
|
||||
|
||||
@@ -544,7 +544,7 @@ func doStep(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
payer := principal.HomeOrg(c)
|
||||
payer := principal.Ledger(c)
|
||||
id := idParam(c)
|
||||
store, cur, _, rows, err := snapshotFor(s, c.Context(), org)
|
||||
if err != nil {
|
||||
|
||||
@@ -210,7 +210,7 @@ func chat(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
|
||||
// narrate runs ONE grounded AI completion for the caller, billed to the caller's own
|
||||
// payer (principal.HomeOrg) and scoped to the caller's own org — so a suggestion/chat
|
||||
// payer (principal.Ledger) and scoped to the caller's own org — so a suggestion/chat
|
||||
// can never spend another tenant's budget. Returns "" when no AI plane is wired or
|
||||
// the call errors (the caller falls back to the deterministic output). A free
|
||||
// function (Go forbids methods on the external cloud.Service) — the ONE
|
||||
@@ -219,7 +219,7 @@ func narrate(s *cloud.Service[state], c *zip.Ctx, org, prompt string) string {
|
||||
if s.State.ai == nil || strings.TrimSpace(prompt) == "" {
|
||||
return ""
|
||||
}
|
||||
payer := principal.HomeOrg(c)
|
||||
payer := principal.Ledger(c)
|
||||
res, err := s.State.ai.ChatCompletion(c.Context(), &cloud.ChatRequest{
|
||||
Model: s.State.model,
|
||||
Prompt: prompt,
|
||||
|
||||
@@ -320,8 +320,23 @@ func (c *Client) AuthorizeVerdict(ctx context.Context, in AuthInput) (Verdict, e
|
||||
}
|
||||
return Verdict{}, fmt.Errorf("metering: empty user")
|
||||
}
|
||||
// No LEDGER -> cannot bill either, and the client default is NOT a substitute.
|
||||
// orgFor falls back to c.org (the deployment's BRAND org, "hanzo"), which is the
|
||||
// right default for a CONFIG read — spend-alert rules, plan tier, cap rows are
|
||||
// scoped by the X-Org-Id header and the brand owns the platform's own. It is the
|
||||
// WRONG default for money: an org-less principal gated against the brand's balance
|
||||
// reads a wallet it has no claim on, and every unattributable request in the fleet
|
||||
// would be authorized by whatever Hanzo happens to be holding. An unresolvable org
|
||||
// refuses; it never charges — or clears — someone else.
|
||||
org := strings.TrimSpace(in.Org)
|
||||
if org == "" {
|
||||
if c.failOpen {
|
||||
return Verdict{Allow: true}, nil
|
||||
}
|
||||
return Verdict{}, fmt.Errorf("metering: empty org")
|
||||
}
|
||||
|
||||
available, err := c.fetchAvailable(ctx, user, c.orgFor(in.Org), currencyOr(in.Currency))
|
||||
available, err := c.fetchAvailable(ctx, user, org, currencyOr(in.Currency))
|
||||
if err != nil {
|
||||
if c.failOpen {
|
||||
return Verdict{Allow: true}, nil
|
||||
@@ -611,6 +626,14 @@ func (c *Client) Record(ctx context.Context, u Usage) (*RecordResult, error) {
|
||||
if strings.TrimSpace(u.User) == "" {
|
||||
return nil, fmt.Errorf("metering: Record requires a user")
|
||||
}
|
||||
// The DEBIT names its ledger or it does not happen. Same rule as the gate above,
|
||||
// and the same reason: c.org would silently make the brand org pay for work it
|
||||
// never asked for. The native path already refuses an empty org inside finance,
|
||||
// but the HTTP path would post it to commerce under the brand header — so state it
|
||||
// once, here, where both paths pass.
|
||||
if strings.TrimSpace(u.Org) == "" {
|
||||
return nil, fmt.Errorf("metering: Record requires an org")
|
||||
}
|
||||
if u.Currency == "" {
|
||||
u.Currency = "usd"
|
||||
}
|
||||
@@ -644,7 +667,7 @@ func (c *Client) Record(ctx context.Context, u Usage) (*RecordResult, error) {
|
||||
return nil, fmt.Errorf("metering: encode usage: %w", err)
|
||||
}
|
||||
|
||||
body, err := c.post(ctx, pathUsage, payload, c.orgFor(u.Org))
|
||||
body, err := c.post(ctx, pathUsage, payload, u.Org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -710,6 +733,11 @@ func (c *Client) do(req *http.Request, org string) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// orgFor picks the X-Org-Id a CONFIG read is scoped by: the per-call org, else the
|
||||
// deployment's own (brand) org. It is for reads whose absence of an org means "the
|
||||
// platform's own settings" — spend-alert rules, cap rows, plan tier — and it is
|
||||
// deliberately NOT reachable from the gate or the debit. Money has no default payer:
|
||||
// AuthorizeVerdict and Record refuse an empty org before they ever get here.
|
||||
func (c *Client) orgFor(perCall string) string {
|
||||
if perCall = strings.TrimSpace(perCall); perCall != "" {
|
||||
return perCall
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestAuthorize_Allows_WhenAvailablePositive(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{})
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
|
||||
t.Fatalf("Authorize allowed should be nil, got %v", err)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestAuthorize_Denies_WhenAvailableZero(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{})
|
||||
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"})
|
||||
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"})
|
||||
if err != metering.ErrInsufficientBalance {
|
||||
t.Fatalf("want ErrInsufficientBalance, got %v", err)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestAuthorize_FailClosed_OnCommerceError(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{})
|
||||
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"})
|
||||
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"})
|
||||
if err == nil {
|
||||
t.Fatal("fail-closed: commerce 500 must deny, got nil")
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestAuthorize_FailOpen_OnCommerceError(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{FailOpen: true})
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
|
||||
t.Fatalf("fail-open: commerce down must allow, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestAuthorize_NotConfigured_Allows(t *testing.T) {
|
||||
if c.Enabled() {
|
||||
t.Fatal("client with no BaseURL should report Enabled()=false")
|
||||
}
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
|
||||
t.Fatalf("not-configured Authorize must allow, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TestAuthorize_TierAware_UsesEffectiveAvailable(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{TierAware: true})
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
|
||||
t.Fatalf("tier-aware allow (included allotment) should be nil, got %v", err)
|
||||
}
|
||||
if fc.path != "/v1/billing/tier" {
|
||||
@@ -182,7 +182,7 @@ func TestAuthorize_TierAware_DeniesWhenEffectiveZero(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{TierAware: true})
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != metering.ErrInsufficientBalance {
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != metering.ErrInsufficientBalance {
|
||||
t.Fatalf("tier-aware exhausted must deny with ErrInsufficientBalance, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestTestMode_SendsTestHeader(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{Test: true})
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "meter-sandbox"}); err != nil {
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "meter-sandbox", Org: "hanzo"}); err != nil {
|
||||
t.Fatalf("Authorize: %v", err)
|
||||
}
|
||||
if fc.testHdr != "true" {
|
||||
@@ -207,7 +207,7 @@ func TestLiveMode_OmitsTestHeader(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{}) // Test=false (production default)
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
|
||||
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
|
||||
t.Fatalf("Authorize: %v", err)
|
||||
}
|
||||
if fc.testHdr != "" {
|
||||
@@ -235,6 +235,7 @@ func TestRecord_PostsCanonicalPayload(t *testing.T) {
|
||||
c := newClient(t, srv, metering.Config{})
|
||||
res, err := c.Record(context.Background(), metering.Usage{
|
||||
User: "hanzo/alice",
|
||||
Org: "hanzo",
|
||||
AmountCents: 250,
|
||||
Provider: "search",
|
||||
RequestID: "req-9",
|
||||
@@ -297,7 +298,7 @@ func TestRecord_ZeroAmount_IsNoOp(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{})
|
||||
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", AmountCents: 0})
|
||||
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", Org: "hanzo", AmountCents: 0})
|
||||
if err != nil || res != nil {
|
||||
t.Fatalf("zero-amount Record should be (nil,nil), got (%v,%v)", res, err)
|
||||
}
|
||||
@@ -308,7 +309,7 @@ func TestRecord_ZeroAmount_IsNoOp(t *testing.T) {
|
||||
|
||||
func TestRecord_NotConfigured_IsNoOp(t *testing.T) {
|
||||
c, _ := metering.New(metering.Config{})
|
||||
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", AmountCents: 100})
|
||||
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", Org: "hanzo", AmountCents: 100})
|
||||
if err != nil || res != nil {
|
||||
t.Fatalf("not-configured Record should be (nil,nil), got (%v,%v)", res, err)
|
||||
}
|
||||
@@ -373,7 +374,7 @@ func TestContractMatchesGateway(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(t, srv, metering.Config{})
|
||||
_ = c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"})
|
||||
_ = c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"})
|
||||
|
||||
// Gateway: GET {base}/v1/billing/balance?user=hanzo%2Falice¤cy=usd
|
||||
if !strings.HasPrefix(gotURL, "/v1/billing/balance?") {
|
||||
|
||||
+2
-2
@@ -268,7 +268,7 @@ func create(s *cloud.Service[state], k resourceKind) zip.Handler {
|
||||
// post-success debit; fee==0 or unconfigured billing makes this a no-op.
|
||||
fee := cloud.ResourceFeeCents(computeFeeEnvPrefix, k.kind)
|
||||
_, projectValidated := principal.ValidatedProject(c)
|
||||
if err := s.State.bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, k.kind, fee); err != nil {
|
||||
if err := s.State.bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, k.kind, fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ func create(s *cloud.Service[state], k resourceKind) zip.Handler {
|
||||
// Resource created — debit the caller's org ledger for the compute
|
||||
// submission (per-org, env-attributed, async best-effort). Ongoing
|
||||
// GPU-hour cost reuses s.State.bill.Meter from a future runtime usage watcher.
|
||||
s.State.bill.Meter(principal.HomeOrg(c), project, k.kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.State.bill.Meter(principal.Ledger(c), project, k.kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
return c.JSON(http.StatusCreated, view(out, true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func run(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// default — the anti-cross-tenant billing property (resource_billing.go).
|
||||
fee := cloud.ResourceFeeCents(runFeeEnvPrefix, runKind)
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, runKind, fee); err != nil {
|
||||
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, runKind, fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func run(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
ensureSecretSync(s, c.Context(), org, a)
|
||||
|
||||
// Record the paid unit on the run's OWN org ledger (fire-and-forget).
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), runKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.Ledger(c), principal.Project(c), runKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
|
||||
s.Log.Info("run (container-serverless)", "org", org, "app", slug, "ns", tenantNamespace(org),
|
||||
"image", image, "min", minScale, "max", maxScale, "actor", c.User(), "requestID", c.RequestID())
|
||||
|
||||
@@ -144,46 +144,61 @@ func Owner(c *zip.Ctx) string {
|
||||
return strings.Clone(owner)
|
||||
}
|
||||
|
||||
// BillingOrg resolves the org whose ledger PAYS for this request — the HOME org
|
||||
// (Owner). It is the ONE "who pays" resolver: the edge gate, the AI meter, and the
|
||||
// resource meter all key their balance CHECK and their DEBIT on it, so a platform
|
||||
// SuperAdmin masquerading into another org spends from the admin org's balance and
|
||||
// the debit lands on the admin ledger — never the org being acted on. DATA scope
|
||||
// keeps using Org (the effective org); this splits "who pays" (home) from "whose
|
||||
// data" (effective), which the old code conflated onto one org value.
|
||||
// BillingOrg resolves the org whose ledger PAYS for this request — the org the
|
||||
// caller SELECTED, i.e. the effective org (Org). It is the ONE "who pays" resolver:
|
||||
// the edge gate, the AI meter, and the resource meter all key their balance CHECK
|
||||
// and their DEBIT on it.
|
||||
//
|
||||
// Gated on Validated like Org: an unvalidated request bills nothing (("", false)),
|
||||
// so an off-gateway forge can neither probe nor drain a ledger. Falls back to the
|
||||
// effective Org when the home header is absent — a normal caller has home==effective
|
||||
// so the fallback is EXACT for them, and it preserves today's behavior on a gateway
|
||||
// that has not yet minted X-User-Owner; only an admin org-switch differs, and that
|
||||
// path always carries X-User-Owner once minted. Returns the resolved payer + true,
|
||||
// or ("", false) when the request may not be billed.
|
||||
// THE ORG IS THE PAYER OF RECORD. A person belongs to several orgs, picks one in the
|
||||
// switcher, and that org's wallet funds the work — the same org whose data they are
|
||||
// looking at, and the same org their top-up credited. Splitting "who pays" (home)
|
||||
// from "whose data" (effective) is what made the switcher a lie: a member of `acme`
|
||||
// could act in `acme` all day while every cent came out of their home org's books.
|
||||
// The two are ONE value again, and the trust boundary is what makes that safe:
|
||||
// SanitizeIdentity only ever sets X-Org-Id to an org the validated `orgs` claim says
|
||||
// the caller belongs to (isMember), so an unselected, stale, or forged org can never
|
||||
// become the effective org — and therefore can never become the payer.
|
||||
//
|
||||
// THE ONE EXCEPTION IS MASQUERADE, and it is not a selection. A platform SuperAdmin
|
||||
// may act in ANY org, membership or not (that is what platform sudo means), so its
|
||||
// effective org is not a statement about who should pay. It spends from its OWN
|
||||
// books: the debit lands on the admin ledger, never on the org being inspected. The
|
||||
// predicate needs no new header — the boundary already mints X-User-IsAdmin for
|
||||
// exactly that identity, and a SuperAdmin acting at home has Owner == Org anyway.
|
||||
//
|
||||
// FAIL CLOSED. An unvalidated request bills nothing, so an off-gateway forge can
|
||||
// neither probe nor drain a ledger; and an org that does not RESOLVE (absent, over
|
||||
// MaxOrgLen) bills nothing either, rather than falling through to a default. There
|
||||
// is no substitute payer: an unresolvable org must refuse, never charge someone else.
|
||||
func BillingOrg(c *zip.Ctx) (string, bool) {
|
||||
if !Validated(c) {
|
||||
org, ok := Org(c) // composes Validated; empty/oversized org ⟹ (", false) ⟹ refuse
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if owner := Owner(c); owner != "" {
|
||||
return owner, true
|
||||
if IsSuperAdmin(c) {
|
||||
// Masquerade (or a SuperAdmin at home, where owner == org): spend own books.
|
||||
if owner := Owner(c); owner != "" {
|
||||
return owner, true
|
||||
}
|
||||
}
|
||||
return Org(c) // rollout fallback: no home header yet ⟹ today's effective-org billing.
|
||||
return org, true
|
||||
}
|
||||
|
||||
// HomeOrg is the bare-string form of BillingOrg for the in-handler resource meters
|
||||
// Ledger is the bare-string form of BillingOrg for the in-handler resource meters
|
||||
// (ResourceMeter.Gate/Meter/MeterUsage), which take an org string rather than the
|
||||
// ctx. It returns the HOME org that PAYS (X-User-Owner, effective-org fallback), or
|
||||
// "" when unvalidated. Call it ONLY after the caller has already resolved AND gated
|
||||
// the effective org via Org (every resource handler does), so "" cannot occur on a
|
||||
// live path; the meter also no-ops on an empty org, so an unexpected "" bills nothing
|
||||
// rather than mis-billing. Use it for the billing key; keep Org for the data namespace.
|
||||
// ctx. It returns the SELECTED org that PAYS, or "" when the request may not be
|
||||
// billed. Call it ONLY after the caller has already resolved AND gated the effective
|
||||
// org via Org (every resource handler does), so "" cannot occur on a live path; the
|
||||
// meter also no-ops on an empty org, so an unexpected "" bills nothing rather than
|
||||
// mis-billing. Use it for the billing key; keep Org for the data namespace.
|
||||
//
|
||||
// It was called Payer, which now means something else: hanzoai/account.Payer returns
|
||||
// the ACCOUNT that pays, and this returns the ORG whose ledger holds it. Those are
|
||||
// different values on the same request — a person in the shared signup org pays from
|
||||
// account "hanzo/alice" held in ledger "hanzo" — so one name for both invited exactly
|
||||
// the confusion that let the gate key the pool while the debit spent the person.
|
||||
// An org names a ledger; an account names a wallet within it.
|
||||
func HomeOrg(c *zip.Ctx) string {
|
||||
// It is called Ledger, not Payer and no longer HomeOrg. Payer means something else
|
||||
// (hanzoai/account.Payer returns the ACCOUNT that pays, and this returns the ORG
|
||||
// whose ledger holds it), and HomeOrg became a lie the moment the SELECTED org
|
||||
// started paying — a name that states the wrong fact is how a gate ends up keying
|
||||
// one wallet while the debit spends another. An org names a ledger; an account names
|
||||
// a wallet within it; this is the ledger.
|
||||
func Ledger(c *zip.Ctx) string {
|
||||
if org, ok := BillingOrg(c); ok {
|
||||
return org
|
||||
}
|
||||
|
||||
+17
-12
@@ -42,22 +42,27 @@ type Wallet struct {
|
||||
}
|
||||
|
||||
// WalletOf resolves the wallet this request spends from, or ok=false when the
|
||||
// request may not touch money at all (no validated principal — never key a ledger
|
||||
// on a restored, client-forged X-Org-Id, or an anonymous caller could probe and
|
||||
// drain a victim org's balance).
|
||||
// request may not touch money at all: no validated principal (never key a ledger on
|
||||
// a restored, client-forged X-Org-Id, or an anonymous caller could probe and drain a
|
||||
// victim org's balance), or no resolvable org.
|
||||
//
|
||||
// Ledger is the HOME org (BillingOrg: the validated `owner` claim, effective-org
|
||||
// fallback), NOT the effective X-Org-Id — so a platform SuperAdmin masquerading
|
||||
// into another org spends from the admin org's books, never the org being acted on.
|
||||
// Account is resolved by the ONE rule (account.Payer) from the signed
|
||||
// `billing_account` claim, falling back to Payer's legacy rule for a pre-claim
|
||||
// token. An org-less validated principal keeps the bare subject: no org names no
|
||||
// account, but a subject can still gate.
|
||||
// Ledger is the SELECTED org (BillingOrg) — the org the caller switched into, which
|
||||
// the trust boundary already proved they belong to; a masquerading SuperAdmin is the
|
||||
// one exception and spends from its own books. Account is resolved by the ONE rule
|
||||
// (account.Payer) from the signed `billing_account` claim, falling back to Payer's
|
||||
// legacy rule for a pre-claim token.
|
||||
//
|
||||
// AN UNRESOLVABLE ORG REFUSES. It used to discard BillingOrg's ok-bit and return a
|
||||
// wallet with an EMPTY ledger, ok=true — and an empty ledger is not "no ledger", it
|
||||
// is "whatever the next layer substitutes". The metering client substituted the
|
||||
// BRAND org, so a principal whose owner claim carried a zero-width rune was gated
|
||||
// against Hanzo's balance and, had the debit not errored on the empty org, would have
|
||||
// spent it. There is no substitute payer; the ok-bit is the answer and it propagates.
|
||||
func WalletOf(c *zip.Ctx) (Wallet, bool) {
|
||||
if !Validated(c) {
|
||||
ledger, ok := BillingOrg(c) // composes Validated; refuses an unresolvable org
|
||||
if !ok {
|
||||
return Wallet{}, false
|
||||
}
|
||||
ledger, _ := BillingOrg(c) // "" only for a validated principal with no usable org
|
||||
sub := strings.TrimSpace(c.User())
|
||||
acct := account.Payer(account.Credential{
|
||||
Owner: ledger,
|
||||
|
||||
@@ -33,7 +33,7 @@ const (
|
||||
// A fee of 0 (operator-configured free tier) is un-gated: Gate returns nil. The
|
||||
// caller renders a non-nil error via cloud.DenyResource.
|
||||
//
|
||||
// The gate keys on principal.HomeOrg(c) (the resolved CALLER org, hardened against a
|
||||
// The gate keys on principal.Ledger(c) (the resolved CALLER org, hardened against a
|
||||
// masquerading admin) and threads the caller's validated project sub-scope
|
||||
// (principal.ValidatedProject) so a forged X-Project-Id can neither hard-stop nor
|
||||
// evade a project-scoped hosting cap — the SAME anti-spoof gate the functions/s3
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
func gateHosting(s *cloud.Service[state], c *zip.Ctx) (fee int64, err error) {
|
||||
fee = cloud.ResourceFeeCents(deployFeeEnvPrefix, deployKind)
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
return fee, s.State.bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, deployKind, fee)
|
||||
return fee, s.State.bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, deployKind, fee)
|
||||
}
|
||||
|
||||
// meterDeploy debits the caller's org ledger ONCE for a successful deploy. It is
|
||||
@@ -50,5 +50,5 @@ func gateHosting(s *cloud.Service[state], c *zip.Ctx) (fee int64, err error) {
|
||||
// flipped the site live — never on a failed deploy. It attributes spend to the
|
||||
// caller's validated project sub-scope so a per-project cap sums correctly.
|
||||
func meterDeploy(s *cloud.Service[state], c *zip.Ctx, fee int64) {
|
||||
s.State.bill.Meter(principal.HomeOrg(c), principal.Project(c), deployKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.State.bill.Meter(principal.Ledger(c), principal.Project(c), deployKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func create(s *cloud.Service[state], kind string) zip.Handler {
|
||||
// this a no-op. Applies to BOTH strategies.
|
||||
fee := cloud.ResourceFeeCents(provisionFeeEnvPrefix, kind)
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
if err := s.Bill.Gate(ctx, principal.HomeOrg(c), project, projectValidated, kind, fee); err != nil {
|
||||
if err := s.Bill.Gate(ctx, principal.Ledger(c), project, projectValidated, kind, fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ func create(s *cloud.Service[state], kind string) zip.Handler {
|
||||
// blocks or corrupts this 201; a debit failure is logged for
|
||||
// reconciliation). Recurring storage footprint reuses s.Bill.Meter with a
|
||||
// GB-month amount once a live-size source exists.
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.Ledger(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
|
||||
// Return the PUBLIC endpoint, never the internal admin host. Remap the
|
||||
// connection string's host:port too so a copy-pasted DSN is routable.
|
||||
|
||||
@@ -250,7 +250,7 @@ func submitScan(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
|
||||
// One metered unit per scan (product=security). Nil/disabled meter → no-op.
|
||||
s.State.bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind, 0, c.RequestID(), clientIP(c))
|
||||
s.State.bill.Meter(principal.Ledger(c), principal.Project(c), meterKind, 0, c.RequestID(), clientIP(c))
|
||||
|
||||
// Audit: the scan happened, by whom, with what tally. The redacted findings
|
||||
// (never the secrets) are the evidence; the tally is the AU-3 outcome.
|
||||
|
||||
@@ -182,13 +182,13 @@ func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
|
||||
|
||||
fee := cloud.ResourceFeeCents(opFeeEnvPrefix, "op")
|
||||
project, projectValidated := principal.ValidatedProject(ctx)
|
||||
if err := s.State.bill.Gate(ctx.Context(), principal.HomeOrg(ctx), project, projectValidated, "op", fee); err != nil {
|
||||
if err := s.State.bill.Gate(ctx.Context(), principal.Ledger(ctx), project, projectValidated, "op", fee); err != nil {
|
||||
return cloud.DenyResource(ctx, err)
|
||||
}
|
||||
if err := h(ctx); err != nil {
|
||||
return err // handler failed — surface it; do not bill failed work.
|
||||
}
|
||||
s.State.bill.Meter(principal.HomeOrg(ctx), principal.Project(ctx), "op", fee, ctx.RequestID(), cloud.ClientIP(ctx))
|
||||
s.State.bill.Meter(principal.Ledger(ctx), principal.Project(ctx), "op", fee, ctx.RequestID(), cloud.ClientIP(ctx))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ func validToolName(name string) bool {
|
||||
}
|
||||
|
||||
func meterUnit(s *cloud.Service[state], c *zip.Ctx) {
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind,
|
||||
s.Bill.Meter(principal.Ledger(c), principal.Project(c), meterKind,
|
||||
cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ func createProject(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
kind := "project"
|
||||
fee := createFeeCents(kind)
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, kind, fee); err != nil {
|
||||
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, kind, fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func createProject(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.Ledger(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
return c.JSON(http.StatusCreated, toProjectView(p))
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ func createIssue(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
const billKind = "issue"
|
||||
fee := createFeeCents(billKind)
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, billKind, fee); err != nil {
|
||||
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, billKind, fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ func createIssue(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), billKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.Ledger(c), principal.Project(c), billKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
return c.JSON(http.StatusCreated, toIssueView(p.Key, created))
|
||||
}
|
||||
|
||||
|
||||
@@ -645,7 +645,7 @@ func discoverAndFold(s *cloud.Service[state], c *zip.Ctx, org string, cr cred, d
|
||||
// Bill NEW folds fail-closed (an existing fold refreshes free); the fee
|
||||
// keys on the HOME (paying) org, the fold on the operating org.
|
||||
if !prev[name] {
|
||||
if berr := s.Bill.Gate(c.Context(), principal.HomeOrg(c), principal.Project(c), projectValidated, foldClusterKind, fee); berr != nil {
|
||||
if berr := s.Bill.Gate(c.Context(), principal.Ledger(c), principal.Project(c), projectValidated, foldClusterKind, fee); berr != nil {
|
||||
res.Error = "billing gate denied"
|
||||
results = append(results, res)
|
||||
continue
|
||||
@@ -658,7 +658,7 @@ func discoverAndFold(s *cloud.Service[state], c *zip.Ctx, org string, cr cred, d
|
||||
continue
|
||||
}
|
||||
if !prev[name] {
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), foldClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.Ledger(c), principal.Project(c), foldClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
}
|
||||
res.Folded = true
|
||||
res.Nodes = rec.Nodes
|
||||
|
||||
@@ -59,14 +59,14 @@ func attachCluster(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// org, not the project sub-scope).
|
||||
fee := cloud.ResourceFeeCents("CLOUD_COMPUTE_FEE_CENTS", byoClusterKind)
|
||||
_, projectValidated := principal.ValidatedProject(c)
|
||||
if err := s.State.bill.Gate(c.Context(), principal.HomeOrg(c), principal.Project(c), projectValidated, byoClusterKind, fee); err != nil {
|
||||
if err := s.State.bill.Gate(c.Context(), principal.Ledger(c), 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)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusUnprocessableEntity, "%v", err)
|
||||
}
|
||||
s.State.bill.Meter(principal.HomeOrg(c), principal.Project(c), byoClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
s.State.bill.Meter(principal.Ledger(c), principal.Project(c), byoClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
return c.JSON(http.StatusCreated, byoToClusterView(rec))
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ func enforce(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
|
||||
// Ledger settlement debits an ORG ledger, so a validated payer is required.
|
||||
payer := principal.HomeOrg(c)
|
||||
payer := principal.Ledger(c)
|
||||
if payer == "" {
|
||||
return zip.ErrForbidden("sign in")
|
||||
}
|
||||
@@ -319,7 +319,7 @@ func settleLedger(s *cloud.Service[state], ctx context.Context, st *Settlement,
|
||||
// getSettlement is the receipt lookup: GET /v1/x402/settlements/:id, scoped to the
|
||||
// caller's payer org so one tenant can never read another's settlement.
|
||||
func getSettlement(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
payer := principal.HomeOrg(c)
|
||||
payer := principal.Ledger(c)
|
||||
if payer == "" {
|
||||
return zip.ErrForbidden("sign in")
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ require (
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.15.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.20.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sendgrid/sendgrid-go v3.16.1+incompatible // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
|
||||
@@ -758,7 +761,7 @@ require (
|
||||
github.com/hanzoai/base v1.5.7
|
||||
github.com/hanzoai/licensing v0.1.5
|
||||
github.com/hanzoai/metrics v1.110.2
|
||||
github.com/hanzoai/o11y v1.5.31-0.20260726155004-2b66f3201d03
|
||||
github.com/hanzoai/o11y v1.5.30
|
||||
github.com/hanzoai/thinking v0.1.1 // indirect
|
||||
github.com/hanzoai/vfs v0.6.6
|
||||
github.com/hanzoai/zen v1.4.2
|
||||
|
||||
@@ -1118,6 +1118,8 @@ github.com/hanzoai/money v0.2.1 h1:w2Fi2aP9bBYY+Zk1jyYfCOT3QhaswGzXQUmoZIjwIHM=
|
||||
github.com/hanzoai/money v0.2.1/go.mod h1:A8BJJ7CFalMMv+gyJm0gUMGfxg3zpygXgfb2WeRzStY=
|
||||
github.com/hanzoai/notify v1.6.18 h1:YLIKheJSMhGqRuo7NRsMicHjAWSVFV6j6ZGqm2H+IBM=
|
||||
github.com/hanzoai/notify v1.6.18/go.mod h1:O8OZj1cfUAIY39ROTPpiaVH8jv947VNfAGor2AZ/ebQ=
|
||||
github.com/hanzoai/o11y v1.5.30 h1:jh0BSAijR98eT5lvhq5/o3sRTU+YsVHtrGVym+a7cKo=
|
||||
github.com/hanzoai/o11y v1.5.30/go.mod h1:lI8yn6GRGJ4ZK2CEfuduZ9yGrr7QFo3AZr7FrFwg0M8=
|
||||
github.com/hanzoai/o11y v1.5.31-0.20260726155004-2b66f3201d03 h1:8Q7qETud4bQy/xORP/c+mnLdZ6OTPKLqm2vs7J0SsAo=
|
||||
github.com/hanzoai/o11y v1.5.31-0.20260726155004-2b66f3201d03/go.mod h1:npZ7y0k6+uP6CnKHGtiL+dgIuqI6e3WCV/+6IYNImw4=
|
||||
github.com/hanzoai/orm v0.6.8 h1:llCC1r2lO3PQJqh50vUn209EpXSvpNL6fK2ySEQ5Z3c=
|
||||
@@ -1851,6 +1853,12 @@ github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBi
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 h1:G3pzZlMvMX9VX9TBB8zr03CAkeyMtbyW2D59PdyaGkM=
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1/go.mod h1:JiJ4f0bngycE8LQqzY/4TB23witBbFnlUS6hPvHn6Zc=
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.15.1 h1:gvNK57rhjwIjAiGTSZH2+XO37mcLyYCsJC1qlNUnBjs=
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.15.1/go.mod h1:O41kV1OVBXIT0Tipo902iT8+rbqF0zL5v5paLxp5/7s=
|
||||
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
|
||||
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
|
||||
|
||||
@@ -320,9 +320,10 @@ func billingProbe(t *testing.T, headers map[string]string) (billingOrg, billingU
|
||||
// admin's spend was silently charged to the org being acted on.
|
||||
func TestIdentityFromCtx_AdminMasqueradeBillsHomeOrg(t *testing.T) {
|
||||
billOrg, billUser, dataOrg := billingProbe(t, map[string]string{
|
||||
"X-User-Id": "u_admin", // validated principal
|
||||
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
|
||||
"X-User-Owner": "admin", // HOME — the identity + billing anchor
|
||||
"X-User-Id": "u_admin", // validated principal
|
||||
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
|
||||
"X-User-Owner": "admin", // HOME — the identity + billing anchor
|
||||
"X-User-IsAdmin": "true", // platform sudo — what makes this a MASQUERADE
|
||||
})
|
||||
if billOrg != "admin" {
|
||||
t.Errorf("billing org (debit ledger) = %q, want %q (HOME org pays, not the acted-on org)", billOrg, "admin")
|
||||
@@ -432,6 +433,7 @@ func TestIdentityFromCtx_MasqueradeKeepsTheAdminsLedger(t *testing.T) {
|
||||
"X-User-Id": "u_admin",
|
||||
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
|
||||
"X-User-Owner": "admin", // HOME — who pays
|
||||
"X-User-IsAdmin": "true", // platform sudo — what makes this a MASQUERADE
|
||||
"X-Billing-Account-Id": "org:victim", // a claim naming the VICTIM's ledger
|
||||
})
|
||||
if billOrg == "victim" || billUser == "victim" {
|
||||
|
||||
+20
-2
@@ -250,9 +250,27 @@ func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
|
||||
}
|
||||
req.Header.Set("X-Org-Id", effOrg)
|
||||
case owner != "":
|
||||
// Any other principal: pinned to their own org, never SuperAdmin.
|
||||
// Any other principal acts in the org it SELECTED, provided the
|
||||
// validated token says it is a member of that org — the `orgs` claim
|
||||
// (IAM's signed membership set, home first). The org switcher is the
|
||||
// product: a person belongs to several orgs, picks one, and THAT org
|
||||
// is the payer of record (principal.BillingOrg reads this header). So
|
||||
// the selection has to survive the trust boundary, and membership is
|
||||
// the only thing that makes surviving safe.
|
||||
//
|
||||
// It is not a widening: the set is signed by IAM, so a caller can only
|
||||
// ever land on an org it already belongs to, and a claim-less token (a
|
||||
// legacy JWT, an hk-/sk- key, a client_credentials machine — IAM never
|
||||
// mints `orgs` for one) has an EMPTY set and stays pinned to home. A
|
||||
// selection outside the set is DISCARDED, not honored and not refused:
|
||||
// the request continues in the caller's own org, so a stale localStorage
|
||||
// selection after a membership is revoked reads the caller's own data
|
||||
// and bills the caller's own ledger — never someone else's.
|
||||
effOrg = owner
|
||||
req.Header.Set("X-Org-Id", owner)
|
||||
if isMember(claims.Orgs, cliOrg) {
|
||||
effOrg = cliOrg
|
||||
}
|
||||
req.Header.Set("X-Org-Id", effOrg)
|
||||
}
|
||||
// X-User-IsOrgAdmin marks a validated principal that is an admin OF ITS OWN
|
||||
// ORG — the IAM `isAdmin` bit (claims.IsAdmin). It is minted on the SAME
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package cloud
|
||||
|
||||
// THE SELECTED ORG PAYS — the whole thread, end to end, in one file.
|
||||
//
|
||||
// A person belongs to several orgs, picks one in the @hanzo/iam switcher, and the
|
||||
// surface sends it as X-Org-Id. Every assertion here drives a REAL RSA-signed IAM
|
||||
// token (with the `orgs` membership claim IAM actually mints) through the REAL
|
||||
// trust boundary (SanitizeIdentity) and reads the money address the debit uses
|
||||
// (identityFromCtx → principal.WalletOf → account.Payer). Nothing is stubbed
|
||||
// between the token and the ledger key, because the bug this closes lived exactly
|
||||
// in that gap: the selection was stripped at the boundary, and even if it had
|
||||
// survived, the payer was re-derived from the home org and ignored it.
|
||||
//
|
||||
// Each test states which half it pins:
|
||||
//
|
||||
// SelectedOrgIsThePayer — the switcher's org reaches the debit.
|
||||
// NonMemberSelectionIgnored — a selection outside the signed set never lands.
|
||||
// MasqueradeSpendsOwnBooks — platform sudo is not a selection.
|
||||
// UnresolvableOrgRefuses — no payer ⟹ no charge, and no substitute payer.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
model "github.com/hanzoai/iam/pkg/model"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// walletProbe signs claims into a real token, drives it through SanitizeIdentity
|
||||
// (adminOrg="admin") with the given client-selected X-Org-Id, and returns the money
|
||||
// address the request would spend from plus the DATA scope it would read.
|
||||
//
|
||||
// ok is principal.WalletOf's refusal bit: false means the request may not touch
|
||||
// money at all. billOrg/billUser are the ledger + wallet the gate checks and the
|
||||
// debit drains — read through identityFromCtx, the SAME function BillingGate calls.
|
||||
func walletProbe(t *testing.T, claims idClaims, selected string) (billOrg, billUser, dataOrg string, ok bool) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
jwks := jwksServer(t, &key.PublicKey)
|
||||
v := newIdentityValidator(testIssuer, jwks.URL, 0)
|
||||
tok := signWith(t, key, claims)
|
||||
|
||||
done := make(chan struct{})
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(v, "admin"))
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
in := identityFromCtx(c)
|
||||
billOrg, billUser = in.Org, in.User
|
||||
dataOrg, _ = principal.Org(c)
|
||||
_, ok = principal.WalletOf(c)
|
||||
close(done)
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
if selected != "" {
|
||||
req.Header.Set("X-Org-Id", selected)
|
||||
}
|
||||
if _, err := app.Fiber().Test(req); err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
<-done
|
||||
return billOrg, billUser, dataOrg, ok
|
||||
}
|
||||
|
||||
// memberClaims is a normal human token: home org `owner`, plus the signed `orgs`
|
||||
// membership set IAM mints (home first). aud matches the validator's allowlist via
|
||||
// tokenClaims.
|
||||
func memberClaims(owner string, orgs ...string) idClaims {
|
||||
c := tokenClaims("hanzo-cloud", owner, owner+"@example.test", false, time.Now().Add(time.Hour))
|
||||
c.Name = "alice"
|
||||
for _, o := range orgs {
|
||||
c.Orgs = append(c.Orgs, model.OrgRef{Org: o, Role: "member"})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestSelectedOrgIsThePayer is THE proof. alice's home org is `hanzo`; she is also a
|
||||
// member of `acme` and selects it. Every cent must come out of ACME's books.
|
||||
//
|
||||
// Before this change the boundary discarded the selection (X-Org-Id was re-minted
|
||||
// from `owner` unconditionally) and principal.BillingOrg keyed the debit on the home
|
||||
// org, so this request billed `hanzo` — the switcher moved the data and nothing else.
|
||||
func TestSelectedOrgIsThePayer(t *testing.T) {
|
||||
billOrg, billUser, dataOrg, ok := walletProbe(t, memberClaims("hanzo", "hanzo", "acme"), "acme")
|
||||
|
||||
if !ok {
|
||||
t.Fatal("a validated member selecting their own org must resolve a wallet")
|
||||
}
|
||||
if billOrg != "acme" {
|
||||
t.Errorf("ledger charged = %q, want %q — the SELECTED org is the payer of record", billOrg, "acme")
|
||||
}
|
||||
if billUser != "acme" {
|
||||
t.Errorf("wallet drained = %q, want %q — a real org pays from its own pool", billUser, "acme")
|
||||
}
|
||||
if dataOrg != "acme" {
|
||||
t.Errorf("data scope = %q, want %q — one selection, one org, data and money together", dataOrg, "acme")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectedOrgIsThePayer_HomeIsJustAnotherChoice: selecting the home org (or
|
||||
// selecting nothing) is not a special case — it resolves through the same rule and
|
||||
// lands on the same ledger. The switcher's default is a selection like any other.
|
||||
func TestSelectedOrgIsThePayer_HomeIsJustAnotherChoice(t *testing.T) {
|
||||
for _, selected := range []string{"", "hanzo"} {
|
||||
billOrg, _, dataOrg, ok := walletProbe(t, memberClaims("hanzo", "hanzo", "acme"), selected)
|
||||
if !ok || billOrg != "hanzo" || dataOrg != "hanzo" {
|
||||
t.Errorf("selected=%q: bill=%q data=%q ok=%v, want hanzo/hanzo/true", selected, billOrg, dataOrg, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonMemberSelectionIgnored: the signed set is the whole authorization. A
|
||||
// selection naming an org the token does not carry is DISCARDED — the caller keeps
|
||||
// acting, and paying, in their own org. It is not an error, because a stale
|
||||
// localStorage selection after a membership is revoked is ordinary, and it must
|
||||
// degrade to "your own org", never to "someone else's ledger".
|
||||
func TestNonMemberSelectionIgnored(t *testing.T) {
|
||||
billOrg, billUser, dataOrg, ok := walletProbe(t, memberClaims("hanzo", "hanzo", "acme"), "victim")
|
||||
|
||||
if !ok {
|
||||
t.Fatal("a validated caller with a stale selection must still resolve their own wallet")
|
||||
}
|
||||
if billOrg == "victim" || billUser == "victim" || dataOrg == "victim" {
|
||||
t.Fatalf("a non-member selection reached the request (bill=%q/%q data=%q) — cross-tenant", billOrg, billUser, dataOrg)
|
||||
}
|
||||
if billOrg != "hanzo" || dataOrg != "hanzo" {
|
||||
t.Errorf("bill=%q data=%q, want hanzo/hanzo (the caller's own org)", billOrg, dataOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyTokenCannotSwitch: a token minted before IAM shipped the `orgs` claim
|
||||
// carries an EMPTY membership set, so it can select nothing and stays pinned to
|
||||
// home. Same for an opaque key and a client_credentials machine, for which IAM never
|
||||
// mints the claim at all. The switch is strictly additive — no token gains reach.
|
||||
func TestLegacyTokenCannotSwitch(t *testing.T) {
|
||||
noClaim := memberClaims("hanzo") // no orgs at all
|
||||
billOrg, _, dataOrg, ok := walletProbe(t, noClaim, "acme")
|
||||
if !ok || billOrg != "hanzo" || dataOrg != "hanzo" {
|
||||
t.Fatalf("legacy token switched: bill=%q data=%q ok=%v, want hanzo/hanzo/true", billOrg, dataOrg, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMasqueradeSpendsOwnBooks: platform sudo is NOT a selection. A SuperAdmin may
|
||||
// act in any org, membership or not — that is what sudo means — so its effective org
|
||||
// says nothing about who should pay. It spends from the admin ledger; the org being
|
||||
// inspected is never charged for being looked at.
|
||||
func TestMasqueradeSpendsOwnBooks(t *testing.T) {
|
||||
admin := tokenClaims("hanzo-cloud", "admin", "z@hanzo.ai", false, time.Now().Add(time.Hour))
|
||||
admin.Name = "z"
|
||||
billOrg, billUser, dataOrg, ok := walletProbe(t, admin, "victim")
|
||||
|
||||
if !ok {
|
||||
t.Fatal("a SuperAdmin must resolve a wallet")
|
||||
}
|
||||
if billOrg == "victim" || billUser == "victim" {
|
||||
t.Fatalf("masquerade billed the inspected org (bill=%q/%q) — cross-tenant debit", billOrg, billUser)
|
||||
}
|
||||
if billOrg != "admin" {
|
||||
t.Errorf("ledger charged = %q, want admin (sudo spends its own books)", billOrg)
|
||||
}
|
||||
if dataOrg != "victim" {
|
||||
t.Errorf("data scope = %q, want victim (sudo still SEES the org it switched into)", dataOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvableOrgRefuses is the fail-closed half. A validated principal whose
|
||||
// `owner` claim carries a zero-width rune is org-less by design (OrgHasUnsafeRune
|
||||
// refuses to fold it), so no ledger can be named — and the request must be REFUSED,
|
||||
// not silently attached to a default.
|
||||
//
|
||||
// This is the shape of the F1 fail-open: WalletOf discarded BillingOrg's ok-bit and
|
||||
// returned ok=true with an empty ledger, and metering.orgFor turned that empty
|
||||
// ledger into the deployment's BRAND org. An unattributable request was therefore
|
||||
// gated against Hanzo's balance. Both halves are pinned below.
|
||||
func TestUnresolvableOrgRefuses(t *testing.T) {
|
||||
bad := memberClaims("hanzo", "hanzo") // zero-width space inside the owner
|
||||
billOrg, billUser, dataOrg, ok := walletProbe(t, bad, "")
|
||||
|
||||
if ok {
|
||||
t.Fatalf("an org-less principal resolved a wallet (%q/%q) — money with no payer", billOrg, billUser)
|
||||
}
|
||||
if billOrg != "" || billUser != "" {
|
||||
t.Errorf("refused request still named a payer: org=%q user=%q", billOrg, billUser)
|
||||
}
|
||||
if dataOrg != "" {
|
||||
t.Errorf("org-less principal got data scope %q, want none", dataOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvableOrgRefuses_NoBrandSubstitute is the second half of fail-closed,
|
||||
// at the layer that actually spent: the metering client must REFUSE an empty org
|
||||
// rather than fall back to its configured (brand) org. Without this, an org-less
|
||||
// AuthInput reads — and a Record debits — whatever the platform's own wallet holds.
|
||||
func TestUnresolvableOrgRefuses_NoBrandSubstitute(t *testing.T) {
|
||||
// A client configured with the brand org, exactly as build.go wires it.
|
||||
fc := &fakeCommerce{balanceBody: `{"available":100000}`}
|
||||
c, err := metering.New(metering.Config{BaseURL: fc.server(t).URL, Token: "svc", Org: "hanzo"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// The GATE: a funded brand org must not authorize an org-less principal.
|
||||
if err := c.Authorize(t.Context(), metering.AuthInput{User: "someone", AmountCents: 100}); err == nil {
|
||||
t.Error("Authorize with no org allowed the request — the brand's balance is not a substitute payer")
|
||||
}
|
||||
// The DEBIT: likewise refuses rather than posting under the brand header.
|
||||
if _, err := c.Record(t.Context(), metering.Usage{User: "someone", AmountCents: 100}); err == nil {
|
||||
t.Error("Record with no org posted a debit — it would land on the brand's ledger")
|
||||
}
|
||||
if n := fc.usages(); n != 0 {
|
||||
t.Errorf("commerce saw %d usage posts for an org-less debit, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func TestResourceMeter_GateAllowsFundedCallerOrg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// payerFor resolves principal.HomeOrg(c) — the HOME org that PAYS — from a request's
|
||||
// payerFor resolves principal.Ledger(c) — the HOME org that PAYS — from a request's
|
||||
// identity headers, exactly as a create-handler does before passing it to Gate.
|
||||
func payerFor(t *testing.T, headers map[string]string) string {
|
||||
t.Helper()
|
||||
@@ -117,7 +117,7 @@ func payerFor(t *testing.T, headers map[string]string) string {
|
||||
done := make(chan struct{})
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(func(c *zip.Ctx) error {
|
||||
payer = principal.HomeOrg(c)
|
||||
payer = principal.Ledger(c)
|
||||
close(done)
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
@@ -133,7 +133,7 @@ func payerFor(t *testing.T, headers map[string]string) string {
|
||||
}
|
||||
|
||||
// TestResourceMeter_GateKeysOnPayerForMasqueradingAdmin (LOW-1 fast-follow): the ml
|
||||
// + provisioning create-handlers pass principal.HomeOrg(c) (the HOME org) to the
|
||||
// + provisioning create-handlers pass principal.Ledger(c) (the HOME org) to the
|
||||
// pre-create balance Gate, matching the paired debit. So a masquerading SuperAdmin
|
||||
// (home=admin via X-User-Owner, acting in a victim org via X-Org-Id) is balance-gated
|
||||
// on the ADMIN's funds — never the victim's. Before the fix these two Gates keyed on
|
||||
@@ -143,9 +143,10 @@ func payerFor(t *testing.T, headers map[string]string) string {
|
||||
func TestResourceMeter_GateKeysOnPayerForMasqueradingAdmin(t *testing.T) {
|
||||
// A create-handler resolves Payer(c) from the request; for a masquerade it is home.
|
||||
payer := payerFor(t, map[string]string{
|
||||
"X-User-Id": "u_admin", // validated principal
|
||||
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
|
||||
"X-User-Owner": "admin", // HOME — the identity + billing anchor
|
||||
"X-User-Id": "u_admin", // validated principal
|
||||
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
|
||||
"X-User-Owner": "admin", // HOME — the identity + billing anchor
|
||||
"X-User-IsAdmin": "true", // platform sudo — what makes this a MASQUERADE
|
||||
})
|
||||
if payer != "admin" {
|
||||
t.Fatalf("principal.Payer for a masquerade = %q, want admin (HOME org)", payer)
|
||||
|
||||
Reference in New Issue
Block a user