Files
zeekayandHanzo Dev 4e18e1ec11 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers: strings.Cut and range over int. omitzero is not applied, since
it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 18:52:46 -07:00

358 lines
17 KiB
Go

// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
// Package account answers ONE question: who pays for a request.
//
// An Account is the thing that holds a balance and gets charged. It belongs to
// exactly one owner — a Person, an Org, or a Project — and (Org, Subject) IS the
// money's address: Org names the ledger that holds it, Subject the key within
// that ledger. A deposit credits that address, the gate reads it, a usage debit
// spends it. One address, one answer, every caller.
//
// This package is a pure leaf with no dependencies, because the rule must be
// IDENTICAL in every layer that touches money. The grant (commerce), the gate
// (ai), and the ledger call the same function or they disagree — and when they
// disagree, a customer tops up one account and spends from another.
//
// WHO PAYS IS A PROPERTY OF THE CREDENTIAL. Payer is the only function that
// answers it, and it reads nothing but the credential: no environment variable,
// no allowlist, no precedence rule. The two global lists this replaces
// (PERSONAL_BILLING_ORGS / ORG_BILLING_ORGS) encoded one boolean per org in two
// repos with a precedence between them, and one lied when unset — so the grant
// and the gate could consult "the same" rule and disagree. They did: the grant
// landed on the org account while the gate read the person's, and a funded org
// 402'd its own members.
package account
import "strings"
// Kind names what owns an Account. The constructor that made an Account fixes its
// kind; the constants stay unexported because no caller branches on kind today —
// they read Subject and Org, which is the whole contract with the ledger.
type Kind string
const (
person Kind = "person"
org Kind = "org"
project Kind = "project"
)
// SignupOrg is the org every self-serve signup lands in — IAM's
// DefaultOrganization (iam/object/session.go). It is NOT a tenant: it is the
// platform's own org, and it is the home of everyone who has not been placed in a
// real one, because IAM's social/OAuth signup path assigns Owner =
// application.Organization with no tenant logic at all (iam/controllers/auth.go).
// Its members are strangers to each other, so each pays from their OWN account —
// a shared org is not a shared wallet.
//
// This is a constant of the product, not a deployment knob: the same value is
// hardcoded in IAM, and nothing in the fleet configures it.
//
// It is the last fact about billing that does not come from the credential, and it
// survives for ONE reason: tokens minted before IAM shipped the `billing_account`
// claim name no payer, and Payer's fallback still has to answer for them. IAM
// states this same rule authoritatively for every token minted since
// (iam/object/billing_account.go), so this constant is a bridge, not a floor —
// when the last pre-claim token has expired, it deletes with the fallback.
const SignupOrg = "hanzo"
// Account is one account money is recorded against. Its fields are unexported so
// every Account is valid by construction — this is money; an Account whose owner
// disagrees with its key is not a value we allow to exist.
type Account struct {
kind Kind
org string
name string
}
// Org returns the account owned by an org: one pooled balance for the whole
// tenant, keyed by the org slug. This is the account an admin grant credits.
func Org(slug string) Account {
slug = fold(slug)
if slug == "" {
return Account{}
}
return Account{kind: org, org: slug}
}
// Person returns the account owned by one person within an org — a balance no
// other member of that org can spend.
func Person(slug, name string) Account {
slug, name = fold(slug), fold(name)
if slug == "" || name == "" {
return Account{}
}
return Account{kind: person, org: slug, name: name}
}
// Project returns the account owned by a project within an org.
//
// A project needs no org of its own to be billed: the project IS the owner, and
// the org is only the ledger its money lives in. Nothing in Payer special-cases
// it — it is one more owner kind, exactly like a Person.
//
// KNOWN LIMIT, stated rather than papered over: a Project and a Person are
// distinct VALUES here but share one ledger key space ("<org>/<name>"), so a
// project and a person with the same name in the same org would address the SAME
// wallet. Nothing constructs a Project account yet (no credential can name one —
// see Payer), so nothing collides today; giving projects their own key prefix is
// a ledger change and must land with the credential that names them, not before.
func Project(slug, name string) Account {
slug, name = fold(slug), fold(name)
if slug == "" || name == "" {
return Account{}
}
return Account{kind: project, org: slug, name: name}
}
// Kind reports what owns this account.
func (a Account) Kind() Kind { return a.kind }
// Org reports the ledger holding this account — the X-Org-Id namespace, and the
// per-org file the balance lives in.
func (a Account) Org() string { return a.org }
// Subject is the account's key within its ledger: what a deposit credits as
// DestinationId, the gate reads as ?user=, and a usage debit spends as SourceId.
// An org account is the bare slug; a person's or project's is "<org>/<name>".
//
// Always folded, because the read paths lowercase and the write paths store
// verbatim: an un-lowercased subject would record usage that never nets against
// the balance — a silent leak.
func (a Account) Subject() string {
if a.org == "" {
return ""
}
if a.name == "" {
return a.org
}
return a.org + "/" + a.name
}
// Zero reports an unresolved account — no owner could be named. A caller must not
// bill it: a request that cannot be attributed is refused, never billed free.
func (a Account) Zero() bool { return a.org == "" }
// String renders an Account as the `billing_account` claim IAM signs into every
// token: `<kind>:<subject>` — "org:acme", "person:hanzo/alice",
// "project:acme/website". A zero Account renders "" (unattributable names nobody).
//
// This is one half of a WIRE CONTRACT with iam/object/billing_account.go, which
// builds the same string from the grant context. IAM shares no code with this
// module, so the grammar is the only thing holding them together: kind is one of
// person|org|project, subject is the Account's own Subject(). Parse is the
// inverse — String ∘ Parse is the identity on every account IAM can mint.
func (a Account) String() string {
if a.Zero() {
return ""
}
return string(a.kind) + ":" + a.Subject()
}
// Parse reads a `billing_account` claim back into the Account it names. It is a
// PARSE, not a decision: it never invents an owner, and anything it cannot read —
// an empty claim, an unknown kind, a missing subject, a person or project with no
// name — returns the zero Account, which Payer treats as "the credential named
// nobody" and falls back rather than billing a guess.
//
// Every component funnels through the same constructors the rest of this file
// uses (Org/Person/Project), so a parsed Account is valid and folded by
// construction — it can never address a wallet a constructed one could not.
func Parse(claim string) Account {
kind, subject, ok := strings.Cut(strings.TrimSpace(claim), ":")
if !ok {
return Account{}
}
slug, name, hasName := strings.Cut(subject, "/")
switch Kind(fold(kind)) {
case org:
if hasName {
return Account{} // an org account is the bare slug — a subject with a name is not one
}
return Org(slug)
case person:
if !hasName {
return Account{}
}
return Person(slug, name)
case project:
if !hasName {
return Account{}
}
return Project(slug, name)
}
return Account{} // an unknown kind names nothing we are willing to bill
}
// Credential is what a request presents, reduced to the only facts about it that
// are both VALIDATED and relevant to money. It is the ONLY input to Payer.
//
// Owner and Name are the IAM `owner` and `name` claims — minted by the gateway
// from a verified JWT and stripped from client input, so they are trustworthy.
//
// Account is the IAM `billing_account` claim: the credential NAMING its own payer,
// signed at the identity boundary (iam/object/billing_account.go). It is the whole
// answer when present — Payer parses it and stops guessing. It reaches a caller two
// ways, both server-minted and neither forgeable: the gateway validates the claim,
// strips any client-supplied copy, and mints X-Billing-Account-Id from it
// (gateway/iamauth), and cloud's own identity boundary mints the same header from
// the same claim for the in-cluster path.
//
// THERE IS NO MACHINE FIELD, deliberately. One used to sit here, derived by each
// caller from User.Type, and it was the last input to this function that a ROW
// could assert. It decided nothing outside the signup org — machine and person
// both pool everywhere else — so its only effect was to hand a machine-typed row
// the signup org's account, which is the platform's own balance. That made "is a
// program", a profile fact, into "may spend the platform's money", an authority.
//
// Machine-ness still exists and is still asked (IsMachine), for the questions it
// actually answers: which column a usage record fills, whether a credential has a
// person behind it. It does not answer who pays. IAM resolves that at the identity
// boundary, where the grant shape is visible, and states it in Account.
type Credential struct {
Owner string
Name string
Account string
}
// IsMachine reports whether an IAM User.Type names a service credential rather than
// a person. It is the ONE place that predicate lives, so the day it becomes
// trustworthy it changes here and nowhere else.
//
// IAM SPELLS A MACHINE TWO WAYS, and money has to read both. "application" is the
// OIDC client shape (object.IsClientCredentialsClaim). "service-account" is the
// identity class IAM's own provisioning mints — internal/oidc/provision.go writes
// it for a tenant's pk-/sk- credential, /v1/iam/service-accounts creates rows of
// it, and SCIM declares userType readOnly precisely because it is that
// discriminator. Matching only the first meant every service account IAM has ever
// provisioned INTO THE SIGNUP ORG fell to the personal branch below and addressed
// a wallet no funding path can name: it reads $0 forever while the org's real
// balance sits beside it. That is the same defect IAM fixed for the token path
// (a machine credential names its payer); this is its API-key half.
//
// IT DOES NOT DECIDE WHO PAYS, and it is not an input to Payer. User.Type is a
// row column; IAM's own auth refuses to trust it alone and requires four
// correlated fields to call a credential a machine
// (object.IsClientCredentialsClaim: type=="application" AND name==app.Name AND
// provider=="" AND signinMethod==""). So this answers the questions a column can
// answer — which column a usage record fills, whether there is a person behind the
// credential — and the money question is answered at the identity boundary, where
// those four fields exist, and carried in the signed billing_account claim.
func IsMachine(userType string) bool {
switch strings.ToLower(strings.TrimSpace(userType)) {
case "application", "service-account":
return true
}
return false
}
// Payer returns the Account that pays for a credential. It is the ONE function
// that answers "who pays": the grant, the gate, the usage debit and the console
// read all call it, so they cannot drift. It reads no configuration.
//
// The rule:
//
// the account the credential NAMES → that account (IAM said so, over its signature)
// nothing named (a pre-claim token) → the legacy fallback below
//
// THE CREDENTIAL NAMES ITS PAYER. IAM mints `billing_account` at the identity
// boundary from the REAL grant context and signs it (iam/object/billing_account.go);
// the gateway validates it, strips any client copy, and mints X-Billing-Account-Id
// from it. So the pooled/personal distinction — real, and money depends on it — is
// now IN the credential, and this function reads it instead of inferring it. That
// is the whole point: an inference can be wrong, and this one was. It ran on
// User.Type=="application", which IAM's UpdateUser lets a user set, so a member of
// the shared signup org could name themselves a machine and spend the org pool.
// A signed claim cannot be forged by the caller it describes.
//
// THE FALLBACK IS FOR OLD TOKENS, NOT FOR DOUBT. A token minted before the claim
// shipped carries no account, and refusing it would 402 every live session. So an
// unnamed credential resolves the old way — and the old way is exactly what the
// claim now states for a new one, so the two agree for every principal that is not
// forging. When the last pre-claim token has expired, the fallback (and SignupOrg
// with it) deletes, and the rule is one line: the account the credential names.
//
// The fallback RESOLVES; it does not GRANT. It reads only the two fields the
// gateway mints from a verified token — the owner and the name — and never a class
// the credential asserts about itself. That distinction is the whole reason the
// machine flag is gone: it let the doorway for old tokens hand out the one account
// that is not a tenant's.
//
// The claim is only honored WITHIN the caller's own org — a claim naming another
// tenant's ledger is discarded, not billed. IAM never mints one (the claim and
// `owner` come from the same signed token), so this can only fire on a mis-wired
// caller pairing a foreign claim with a local owner; it costs one comparison to
// make that a fallback instead of a cross-tenant debit.
func Payer(c Credential) Account {
owner := fold(c.Owner)
if owner == "" {
return Account{} // unattributable — the caller refuses; it never bills free
}
if named := Parse(c.Account); !named.Zero() && named.Org() == owner {
return named
}
// Legacy fallback: a pre-claim token named nobody. Resolve who pays from the
// credential's shape, exactly as before the claim existed.
if owner == SignupOrg {
// The signup org is the ONE org where the NAME decides, and the account
// sitting beside its members is the platform's own balance. A credential
// that arrives here without a name has not asked for the pool — it has
// FAILED TO NAME A PERSON, and answering it with Org(owner) hands a
// stranger that balance. So it is refused: unattributable is not free, and
// it is certainly not "the platform pays".
//
// THE NAME DECIDES FOR EVERY CREDENTIAL HERE, program or person. A machine
// flag used to be read one step above this branch, so a machine-typed
// credential took the pool without ever reaching the rule that governs this
// org. That is backwards: the fallback exists to answer for credentials
// that carry no claim, and a credential carrying no claim carries no
// evidence of authority either. It must not GRANT what the claim would have
// had to state. Outside this org the flag decided nothing — every principal
// pools — so removing it narrows exactly one thing: an asserted class can no
// longer reach the platform's balance through the door meant for old tokens.
//
// The refusal is stated INSIDE this branch, not above it, because a blank
// name is also how a caller legitimately ADDRESSES an org account — a
// member-less admin grant crediting a tenant's pool, a bare key naming a
// ledger. In every other org those resolve identically to a named member
// (all of them pool), so the name is never read there and namelessness
// cannot be a defect. Refusing it estate-wide would break funding a
// customer's pool to close a hole that only exists where the name is
// load-bearing.
name := fold(c.Name)
if name == "" {
return Account{}
}
return Person(owner, name)
}
return Org(owner)
}
// PayerOf is Payer for callers holding the identity as an "<org>/<name>" key
// rather than two fields — usage records, ZAP params, searchAuth.UserID. If org
// is empty it is taken from the key's prefix. It is a parse, not a second rule:
// it funnels into Payer, so it can never answer differently.
func PayerOf(org, key string) Account {
if before, after, ok := strings.Cut(key, "/"); ok {
if strings.TrimSpace(org) == "" {
org = before
}
return Payer(Credential{Owner: org, Name: after})
}
if strings.TrimSpace(org) == "" {
// A bare key with no org to qualify it NAMES AN ORG — there is no person in
// that string to have gone missing, so it is an address, not a credential
// that failed to resolve, and Payer's refusal must not swallow it.
return Org(key)
}
// A bare key WITH an org is that org's member: the caller already supplied the
// ledger, so the key is the remaining half — the name. This used to drop the
// key on the floor and answer with the org account, which in the signup org is
// the platform pool: a legacy chat/TTS turn gated "hanzo/carol" (the spend
// check re-qualifies a bare name exactly this way) and then debited "hanzo".
// One request, two addresses, and the difference was billed to the platform.
return Payer(Credential{Owner: org, Name: key})
}
// fold normalizes an identity component to its canonical, comparable form.
func fold(s string) string { return strings.ToLower(strings.TrimSpace(s)) }