mirror of
https://github.com/hanzoai/account.git
synced 2026-09-30 20:38:12 +00:00
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]>
358 lines
17 KiB
Go
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)) }
|