being a program is not a grant, so it stops deciding who pays

Credential carried a Machine flag, derived by each caller from User.Type, and
Payer read it one step above the rule that governs the signup org. So a
machine-typed row took Org(SignupOrg) — the platform's own balance — without
ever reaching that rule.

Outside the signup org the flag decided nothing: machine and person both pool.
Its only effect was to turn "is a program", a profile column, into "may spend
the platform's money", an authority. 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 resolve, not grant.

So the field is gone rather than merely unread: the defect is now unexpressible
at the type level, and every caller that asserted a class is a compile error
rather than a silent wrong answer. Machine-ness still answers the questions a
column can answer (IsMachine — which column a usage record fills, whether there
is a person behind the credential); who pays is stated by IAM at the identity
boundary and carried in the signed billing_account claim.

First-party service credentials are untouched: both doors state that claim, and
the API-key door does not transmit User.Type at all.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-20 10:15:11 -07:00
co-authored by Hanzo Dev
parent 8a345611a2
commit c2639c71f9
3 changed files with 116 additions and 55 deletions
+34 -20
View File
@@ -197,20 +197,21 @@ func Parse(claim string) Account {
// (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 (see IsMachine for the two spellings IAM uses) — 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.
// 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
Machine bool
}
// IsMachine reports whether an IAM User.Type names a service credential rather than
@@ -228,14 +229,14 @@ type Credential struct {
// 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 is NOT trustworthy today — see Credential.Machine. IAM's own auth refuses to
// trust this field alone and requires four correlated fields
// 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==""). Writing Type needs org-admin, SuperAdmin, or
// an app holding CapUserAdmin, so neither spelling is reachable by a plain member;
// adding the second widens nothing the first did not already admit. 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.
// 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":
@@ -270,6 +271,12 @@ func IsMachine(userType string) bool {
// 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
@@ -285,9 +292,6 @@ func Payer(c Credential) Account {
}
// Legacy fallback: a pre-claim token named nobody. Resolve who pays from the
// credential's shape, exactly as before the claim existed.
if c.Machine {
return Org(owner)
}
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
@@ -296,6 +300,16 @@ func Payer(c Credential) Account {
// 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
+80 -33
View File
@@ -36,11 +36,21 @@ func TestPayer_GrantAndGateAgree(t *testing.T) {
},
{
// A service credential IS the org — it never bills a per-app wallet that
// no one funds (the "hanzo/hanzo-cloud" 402 the carve-out closes).
name: "machine credential bills to its org",
cred: Credential{Owner: "hanzo", Name: "hanzo-cloud", Machine: true},
// no one funds (the "hanzo/hanzo-cloud" 402 the carve-out closes). It
// says so in the SIGNED claim: in the signup org an org account is the
// platform's balance, so naming it is a statement IAM makes at the
// identity boundary, never one inferred from a row's class here.
name: "machine credential bills to its org, over the claim",
cred: Credential{Owner: "hanzo", Name: "hanzo-cloud", Account: "org:hanzo"},
want: "hanzo",
},
{
// …and in a real tenant it needs no claim, because every principal
// there pools regardless of class.
name: "machine credential in a tenant org pools without a claim",
cred: Credential{Owner: "acme", Name: "acme-bot"},
want: "acme",
},
{
// A member of a real org, even named identically to a signup person,
// still pools — the org, not the name, decides.
@@ -84,10 +94,11 @@ func TestPayer_ClaimWins(t *testing.T) {
if got := Payer(Credential{Owner: "hanzo", Name: "alice", Account: "org:hanzo"}).Subject(); got != "hanzo" {
t.Fatalf("claimed org subject = %q, want %q (claim must beat the personal inference)", got, "hanzo")
}
// The claim also disarms the FORGEABLE machine signal: Machine says "pool me",
// the signed claim says "you are a person". The signature wins.
if got := Payer(Credential{Owner: "hanzo", Name: "alice", Account: "person:hanzo/alice", Machine: true}).Subject(); got != "hanzo/alice" {
t.Fatalf("claimed subject with forged Machine = %q, want %q (signed claim must beat User.Type)", got, "hanzo/alice")
// A signed claim naming the PERSON holds in the signup org too — there is no
// longer any second signal that could override it, because the class a row
// asserts is no longer an input at all.
if got := Payer(Credential{Owner: "hanzo", Name: "alice", Account: "person:hanzo/alice"}).Subject(); got != "hanzo/alice" {
t.Fatalf("claimed person subject = %q, want %q", got, "hanzo/alice")
}
}
@@ -205,7 +216,7 @@ func TestPayer_FailsClosed(t *testing.T) {
for _, c := range []Credential{
{Owner: "", Name: "alice"},
{Owner: " ", Name: "alice"},
{Owner: "", Name: "", Machine: true},
{Owner: "", Name: ""},
{Owner: "", Account: "org:acme"}, // a claim cannot supply a missing owner
} {
a := Payer(c)
@@ -289,26 +300,61 @@ func TestIsMachine_MatchesApplicationType(t *testing.T) {
}
}
// TestPayer_ServiceAccountInSignupOrgSpendsThePool is the live defect, in one
// assertion: hanzo/guest is the anonymous free tier, IAM typed it "service-account",
// and it read $0 on a pool holding ~$149k because Payer took it for a person.
// TestPayer_AnAssertedClassNeverReachesThePool is the regression for the last
// input to this function that a ROW could assert.
//
// The signup org is the only place this bites — its members are strangers, so a
// person there pays personally — which is exactly where every first-party service
// account lands. The wallet a service account was handed there ("hanzo/guest") is
// one no funding path can name: a grant credits the pool, a deposit names a real
// member. So the wrong answer is not a smaller balance, it is an unreachable one.
func TestPayer_ServiceAccountInSignupOrgSpendsThePool(t *testing.T) {
guest := Credential{Owner: SignupOrg, Name: "guest", Machine: IsMachine("service-account")}
if got := Payer(guest).Subject(); got != SignupOrg {
t.Fatalf("service account in the signup org pays %q, want the org pool %q", got, SignupOrg)
// A machine flag used to be read one step ABOVE the signup-org rule, so a
// machine-typed credential took Org(SignupOrg) — the platform's own balance —
// without ever reaching the rule that governs that org. The flag was derived by
// each caller from User.Type, a profile column: "is a program" was a fact about a
// row, and it was being spent as "may spend the platform's money".
//
// The fallback answers for credentials that carry NO claim, and a credential
// carrying no claim carries no evidence of authority either. So here the NAME
// decides, for a program exactly as for a person. Being a machine is not a grant.
func TestPayer_AnAssertedClassNeverReachesThePool(t *testing.T) {
// The shape the defect needs: the signup org, a machine class, nothing signed.
for _, name := range []string{"guest", "svc", "bot"} {
c := Credential{Owner: SignupOrg, Name: name}
if got := Payer(c).Subject(); got != SignupOrg+"/"+name {
t.Fatalf("%q in the signup org pays %q, want personal %q — the pool is not reachable by shape",
name, got, SignupOrg+"/"+name)
}
if Payer(c) == Org(SignupOrg) {
t.Fatalf("%q reached the platform pool through the fallback", name)
}
}
// A PERSON in the same org still pays personally — the pool is not opened to
// strangers, which is the whole reason the signup org is special.
human := Credential{Owner: SignupOrg, Name: "alice", Machine: IsMachine("normal-user")}
if got := Payer(human).Subject(); got != SignupOrg+"/alice" {
// A PERSON in the same org is unchanged — the free-rider hole stays shut.
if got := Payer(Credential{Owner: SignupOrg, Name: "alice"}).Subject(); got != SignupOrg+"/alice" {
t.Fatalf("person in the signup org pays %q, want personal %q", got, SignupOrg+"/alice")
}
// A REAL TENANT is unchanged: every principal there pools, which is why the
// removed flag decided nothing outside the signup org.
if got := Payer(Credential{Owner: "acme", Name: "bot"}).Subject(); got != "acme" {
t.Fatalf("a machine in a tenant org pays %q, want the tenant pool %q", got, "acme")
}
// And the SIGNED claim still decides, above the shape — this narrows the
// fallback only. IAM states the payer at the identity boundary; that is the
// one path by which a first-party service credential names an org account.
claimed := Credential{Owner: SignupOrg, Name: "guest", Account: Org(SignupOrg).String()}
if got := Payer(claimed); got != Org(SignupOrg) {
t.Fatalf("a signed org claim was dropped: %q", got.Subject())
}
}
// A CREDENTIAL FROM ANOTHER TENANT NEVER NAMES THE POOL, however it is shaped.
// The claim is honored only within the caller's own org, so a foreign claim on a
// signup-org credential falls back to the shape rule rather than crossing tenants.
func TestPayer_ForeignClaimNeverCrossesIntoThePool(t *testing.T) {
// An acme credential claiming the platform's balance is refused the claim and
// resolves to its own tenant.
if got := Payer(Credential{Owner: "acme", Name: "bob", Account: Org(SignupOrg).String()}); got != Org("acme") {
t.Fatalf("a foreign claim was honored: %q", got.Subject())
}
// …and a signup-org member claiming a tenant's pool is refused likewise.
if got := Payer(Credential{Owner: SignupOrg, Name: "mallory", Account: Org("acme").String()}); got != Person(SignupOrg, "mallory") {
t.Fatalf("a foreign claim was honored: %q", got.Subject())
}
}
// TestSignupOrg_MatchesIAM guards the one named constant against silent drift
@@ -344,7 +390,7 @@ func TestSignupOrg_MatchesIAM(t *testing.T) {
func TestPayer_NamelessCredentialNeverReachesThePool(t *testing.T) {
// Exactly what iam/internal/oidc.userClaims emits for an unresolvable row,
// carried into ai: owner from the signed `owner` claim, everything else absent.
nameless := Credential{Owner: SignupOrg, Name: "", Account: "", Machine: false}
nameless := Credential{Owner: SignupOrg, Name: "", Account: ""}
got := Payer(nameless)
if !got.Zero() {
@@ -376,15 +422,16 @@ func TestPayer_NamelessCredentialNeverReachesThePool(t *testing.T) {
// first-party service, which is a worse outage than the hole it closes — so each
// is asserted explicitly rather than assumed.
func TestPayer_RefusalSparesEveryLegitimateCaller(t *testing.T) {
// 1. A MACHINE. Every client_credentials token IAM has ever minted carries
// `name: app.Name` AND a signed billing_account, so it is doubly clear of the
// refusal — the claim answers before the fallback is reached, and the name is
// non-empty if it ever is. Both spellings of the legacy type still pool.
// 1. A MACHINE, as it is actually minted. Every client_credentials token IAM
// issues carries a signed billing_account, and the API-key door states the same
// claim (iam compat keyUser.BillingAccount) — so the claim answers before the
// fallback is ever reached, and a first-party service credential keeps its org
// pool. That door does not transmit User.Type at all, which is why removing the
// asserted class from Credential cannot touch this population.
for _, m := range []Credential{
{Owner: SignupOrg, Name: "hanzo-cloud", Account: "org:hanzo"}, // as minted today
{Owner: SignupOrg, Name: "hanzo-cloud", Machine: true}, // pre-claim token
{Owner: SignupOrg, Name: "", Machine: true}, // pre-claim, no name
{Owner: SignupOrg, Name: "hanzo-insights", Machine: IsMachine("service-account")},
{Owner: SignupOrg, Name: "hanzo-cloud", Account: "org:hanzo"},
{Owner: SignupOrg, Name: "hanzo-insights", Account: "org:hanzo"},
{Owner: SignupOrg, Name: "", Account: "org:hanzo"}, // nameless, but signed
} {
if got := Payer(m).Subject(); got != SignupOrg {
t.Fatalf("machine %+v resolved to %q, want the org pool %q", m, got, SignupOrg)
+2 -2
View File
@@ -442,7 +442,7 @@ func TestNoSwitchIsANoOp(t *testing.T) {
creds := []Credential{
{Owner: "hanzo", Name: "alice"}, // person in the signup org
{Owner: "acme", Name: "bob"}, // person in a real org
{Owner: "hanzo", Name: "hanzo-cloud", Machine: true}, // machine credential
{Owner: "hanzo", Name: "hanzo-cloud", Account: "org:hanzo"}, // machine credential
{Owner: "globex", Name: "carol", Account: "org:globex"}, // claim names the org
{Owner: "hanzo", Name: "dave", Account: "person:hanzo/dave"}, // claim names the person
{Owner: "acme", Name: "eve", Account: "project:acme/website"}, // claim names a project
@@ -475,7 +475,7 @@ func TestNoSwitchIsANoOp(t *testing.T) {
// The credential as the new pipeline would build it, versus the
// credential the old code built straight from the owner claim.
now := Payer(Credential{Owner: ledger, Name: cred.Name, Account: cred.Account, Machine: cred.Machine})
now := Payer(Credential{Owner: ledger, Name: cred.Name, Account: cred.Account})
before := Payer(cred)
if now.Subject() != before.Subject() {