Compare commits

...
Author SHA1 Message Date
antje 47eba96cbc build: pin hanzoai/o11y to the released v1.5.30, not a revision that never shipped
main was unbuildable. go.mod pinned o11y at the pseudo-version
v1.5.31-0.20260726155004-2b66f3201d03, and that revision exists in exactly one
place: somebody's module cache. It is not on origin and not reachable from any
tag, so `go mod download` in the on-cluster build resolved "unknown revision"
and every image build of cloud failed before it compiled a line.

v1.5.30 is the last released patch and its tree is byte-identical to the cached
pseudo-version (diff -rq over both module directories is empty), so this is the
same code under a name the builder can actually fetch — not a version bump, and
not a jump forward past anything.
2026-07-26 17:15:28 -07:00
antje 5359686cfc money: the SELECTED org is the payer of record
The @hanzo/iam switcher moved data and nothing else. A person picks an org,
every surface stamps X-Org-Id from that choice, and the money still came out
of their HOME org: the boundary re-minted X-Org-Id from the `owner` claim
unconditionally, and principal.BillingOrg keyed the debit on home anyway. Both
halves are fixed here, and they are one fix — one org, for data and money alike.

  - SanitizeIdentity honors a selection the token's signed `orgs` claim says the
    caller belongs to (isMember). IAM already mints that set, home first;
    nothing else can widen it. A selection OUTSIDE the set is discarded, not
    refused — a stale localStorage choice after a revoked membership reads and
    bills the caller's OWN org, never someone else's. A legacy token, an opaque
    key and a machine principal carry no set, so they cannot switch at all.
  - principal.BillingOrg returns that effective org. Platform sudo is the one
    exception and is not a selection: a masquerading SuperAdmin still spends
    from the admin ledger, keyed on the header the boundary already mints.
  - HomeOrg -> Ledger. A name that states the wrong fact is how a gate ends up
    keying one wallet while the debit spends another.

And an unresolvable org now REFUSES instead of charging someone else:

  - WalletOf propagates ok=false. It used to discard BillingOrg's ok-bit and
    return an EMPTY ledger, which is not "no ledger" but "whatever the next
    layer substitutes".
  - metering.AuthorizeVerdict and Record refuse an empty org. orgFor fell back
    to the deployment's BRAND org, so an org-less principal was gated against
    Hanzo's balance and its debit posted under Hanzo's header. orgFor stays for
    CONFIG reads (spend rules, caps, plan tier), where the platform's own org IS
    the right default, and is no longer reachable from the money path.

Tests fail without the change: TestSelectedOrgIsThePayer bills "hanzo" instead
of the selected "acme"; TestUnresolvableOrgRefuses resolves a wallet with no
payer; TestUnresolvableOrgRefuses_NoBrandSubstitute posts a real debit to the
brand's ledger.
2026-07-26 17:14:54 -07:00
32 changed files with 440 additions and 111 deletions
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 ""
+1 -1
View File
@@ -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())
}
+5 -4
View File
@@ -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
+4 -4
View File
@@ -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")
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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,
+30 -2
View File
@@ -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
+13 -12
View File
@@ -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&currency=usd
if !strings.HasPrefix(gotURL, "/v1/billing/balance?") {
+2 -2
View File
@@ -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))
}
}
+2 -2
View File
@@ -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())
+46 -31
View File
@@ -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
View File
@@ -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,
+3 -3
View File
@@ -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))
}
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
}
}
+1 -1
View File
@@ -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))
}
+4 -4
View File
@@ -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))
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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))
}
+2 -2
View File
@@ -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")
}
+4 -1
View File
@@ -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
+8
View File
@@ -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=
+5 -3
View File
@@ -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
View File
@@ -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
+224
View File
@@ -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)
}
}
+7 -6
View File
@@ -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)