mirror of
https://github.com/hanzoai/account.git
synced 2026-09-30 20:38:12 +00:00
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:
+34
-20
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user