Documentation
¶
Overview ¶
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.
Index ¶
Constants ¶
const SignupOrg = "hanzo"
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.
Variables ¶
This section is empty.
Functions ¶
func EffectiveOrg ¶ added in v0.2.1
EffectiveOrg resolves the org a request ACTS IN: the org the client asked to act in if the signed claim set says they belong to it, otherwise their home org. It is the one org-switch predicate, and it is pure — the answer is a function of the token alone, with no lookup, so every layer that runs it on the same token gets the same answer.
MEMBERSHIP COMES FROM THE SIGNED CLAIM, NEVER FROM THE REQUEST. `orgs` is decoded from a validated JWT; `requested` is the client's ask and carries no authority of its own. Honoring a raw client org would be a cross-tenant read — anyone could name any tenant and be believed. So the ask is only ever a SELECTION FROM a set IAM already granted, and the value returned on a match is the one from that set, so nothing a client typed can flow onward as an org.
REFUSAL IS SILENT AND IS THE HOME ORG. A request outside the membership set, an empty set (a legacy pre-claim token, an opaque key, a machine principal), an empty ask, an unattributable subject — all resolve to home, which is the exact behavior of every one of these layers before a switch existed. There is no error return because there is no error: not switching is a valid outcome, and it is the outcome for every user who never touches the switcher. That makes adopting this function a provable no-op for them.
THE COMPARISON IS VERBATIM: no trim, no case-fold. "acme" and "ACME" are DISTINCT orgs in IAM, so folding would let a member of one select the other, and trimming would let " acme" pass for a third. Byte-equality can only ever refuse a switch a looser rule would allow, and a refused switch is home — so the strict rule fails closed by construction. (Addressing folds; authorizing does not. Account.Subject lowercases so a write and a read net against the same balance. That is a different job: it canonicalizes a key AFTER this function has decided the caller may use it, and it must not be conflated with deciding.)
Role is deliberately not read. Membership answers "may I act in this org at all"; what a role permits inside it is a separate authority, and braiding them here would put two decisions behind one call.
func IsMachine ¶ added in v0.2.0
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.
It is NOT trustworthy today — see Credential.Machine. IAM's own auth refuses to trust this field alone and requires four correlated fields (object.IsClientCredentialsClaim: type=="application" AND name==app.Name AND provider=="" AND signinMethod==""), because "type" rides IAM's non-admin UpdateUser column list and a user can set their own. Billing should resolve this at the auth boundary, where those four fields exist, and pass the answer in; this function is the seam that makes that a one-line change.
func LedgerOrg ¶ added in v0.2.1
LedgerOrg resolves the org that PAYS. It takes the org the request acts in (EffectiveOrg), the subject's home org, and whether the subject holds platform sudo — the reserved authority that lets a support admin act inside a customer's org without being a member of it.
Two branches, deliberately not one clever expression, because they are two different policies that merely share a shape:
sudo → home an admin acting on a customer must never spend the
customer's money. Their org's data, our org's bill. A
support session that drains the account it was opened to
help is indistinguishable from theft, and the customer has
no way to see it happened.
anyone → effective the switcher IS the payer selection. A member who picks a
team org spends the team's balance; that is the entire
feature, and the reason the two functions are separate:
acting somewhere and paying for it are the same answer for
everyone except sudo.
Note that sudo AT HOME is not a special case — there effective already equals home, so the branch returns the value the other branch would have.
Types ¶
type Account ¶ added in v0.2.0
type Account struct {
// contains filtered or unexported fields
}
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.
func Org ¶ added in v0.2.0
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 Parse ¶ added in v0.2.0
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 Payer ¶ added in v0.2.0
func Payer(c Credential) Account
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 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 PayerOf ¶ added in v0.2.0
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 Person ¶ added in v0.2.0
Person returns the account owned by one person within an org — a balance no other member of that org can spend.
func Project ¶ added in v0.2.0
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 (Account) Org ¶ added in v0.2.0
Org reports the ledger holding this account — the X-Org-Id namespace, and the per-org file the balance lives in.
func (Account) String ¶ added in v0.2.0
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 (Account) Subject ¶ added in v0.2.0
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.
type Credential ¶ added in v0.2.0
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.
Machine is the LEGACY fallback signal, and it is not trustworthy: callers derive it from User.Type == "application", a field IAM's UpdateUser carries in its NON-admin column list — IAM's own code says "User.Type=='application' ALONE is forgeable" (iam/object/client_credentials.go), which is why IAM's auth requires four correlated fields (IsClientCredentialsClaim). A signup-org member who set their own Type could be billed as a machine and reach the org pool. That is exactly what the Account claim removes: IAM now resolves machine-ness from the client_credentials GRANT SHAPE and states the answer in the claim, so Machine only ever decides a token minted BEFORE the claim shipped.
type Kind ¶ added in v0.2.0
type Kind string
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 OrgRef ¶ added in v0.2.1
OrgRef is one entry of the signed `orgs` claim: an org the subject may act in and their coarse role there (owner | admin | member). IAM mints the set from the subject's real memberships, home org first, and omits the claim entirely for a machine principal (iam/internal/store.MemberOrgRefs).
The JSON tags are the WIRE CONTRACT with IAM and are byte-identical to the three decoders that already exist independently in cloud, gateway and ai. It is declared here so those three can alias this type and decode one shape, rather than keep three hand-copies that can drift apart one field at a time.