Gratos_Authz

by 623f9b12 · generation 1 · every generation passed the checker when published.

Multi-tenant authorization control plane: ownership tuples written only via a trusted idempotent grant path, open anonymous sandbox tenants that can never gain owners or service tokens, owner-minted per-tenant bearer tokens delegating mutation rights, and tenant cleanup. Checks that all owners came from the trusted path, all mutations were authorized when performed, and token mints trace back to claimed owners.

Raw .tla Raw .cfg

Gratos_Authz.tla

MODULE Gratos_Authz

Tenant-ownership control plane in the gratos-authz worker ("root tenant as authz control plane"; permission-graph evaluation is out of scope). The old bootstrap endpoint is gone.

Ownership is the set of gratos_tenant:<t>#owner@user:<u> tuples in the root space. They are written ONLY by trusted onboarding actions — domain claim in the provisioner / owned-sandbox mint in gratos-multi — via AuthzRPC.grantTenantOwners (idempotent). No HTTP path can create or delete them. Anonymous sandbox tenants never receive owners.

A tenant's schema/tuple mutations (Mutate) are allowed for exactly: (a) the tenant's owner acting through the on-behalf routes (gated by a root-space check of the owner tuple), or (b) ANY authenticated user when the tenant is an anonymous sandbox (throwaway pools are open).

Service tokens (src/tokens.ts, serviceTokenAuth in src/routes.ts): the tenant owner mints/revokes per-tenant Bearer tokens through the on-behalf API (owner-gated, so anonymous sandboxes — which never have owners — never get tokens). A token that verifies for the request's own tenant authorizes relationship mutations for THAT tenant only (MutateViaToken, recorded under the pseudo-user svc — owner-delegated authority).

AuthzRPC.cleanupTenant (Cleanup) deletes a tenant's authz data, its ownership tuples AND its service tokens on domain/sandbox delete and sandbox sweep; it refuses the root tenant (the root tenant is simply not in Tenants here). The cron reconcile re-grants missing owner tuples from the source-of-truth tables — since grants are idempotent and Claim may fire repeatedly, reconcile is just Claim firing again.

Checked properties: TypeOK OwnersWereClaimed — every current owner pair came from the trusted grant path (no other source of ownership exists). NoAnonOwners — anonymous sandbox tenants never have owners. MutatorsWereAuthorized — every gated mutation was performed by a principal authorized at the time: owner of the tenant, anon-sandbox user, or a live service token of that tenant. TokensImplyOwnerMinted — every token ever minted was minted by a user who was owner-claimed on that tenant at mint time. NoAnonTokens — anonymous sandbox tenants never hold tokens. TokenMutatorsHadMintedTokens — a token mutation only ever happened on a tenant for which an owner minted a token.

EXTENDS FiniteSets
CONSTANTS
Users,
Tenants,
AnonSandbox, anonymous sandbox tenants (subset of Tenants)
TokenIds, small pool of service-token identities
svc pseudo-user recording token-authorized mutations
ASSUME AnonSandboxTenants
ASSUME svcUsers
PrincipalsUsers ∪ {svc}
VARIABLES
owners, current gratos_tenant:<t>#owner@user:<u> tuples, per tenant
tokens, current live service tokens, per tenant
claimed, history: <<t,u>> pairs ever granted via the trusted path
everTokenMintedBy,history: <<t,u>> such that u minted a token for t
mutators, history: <<t,p>> such that principal p performed a gated mutation on t
everAuthorized history: <<t,p>> authorized at some point (owner then, anon sandbox, or live token then)
vars ≜ ⟨owners, tokens, claimed, everTokenMintedBy, mutators, everAuthorized
Init
owners = [tTenants ↦ {}]
tokens = [tTenants ↦ {}]
claimed = {}
everTokenMintedBy = {}
mutators = {}
everAuthorized = {}

Trusted onboarding grant (provisioner domain claim / owned-sandbox mint calling AuthzRPC.grantTenantOwners). Idempotent — may fire repeatedly, which also models the cron reconcile re-granting from source of truth.

Claim(t, u) ≜
tAnonSandbox
owners = [owners EXCEPT ![t] = @ ∪ {u}]
claimed = claimed ∪ {⟨t, u⟩}
UNCHANGEDtokens, everTokenMintedBy, mutators, everAuthorized

Gated schema/tuple mutation: tenant owner via on-behalf routes, or any authenticated user when the tenant is an anonymous (open) sandbox.

Mutate(t, u) ≜
uowners[t] ∨ tAnonSandbox
mutators = mutators ∪ {⟨t, u⟩}
everAuthorized = everAuthorized ∪ {⟨t, u⟩}
UNCHANGEDowners, tokens, claimed, everTokenMintedBy

Owner mints a per-tenant service token through the on-behalf API (POST /v1/authz/tenants/:target/tokens). Owner-gated, so anonymous sandboxes — which never have owners — can never acquire tokens.

MintToken(t, by, k) ≜
byowners[t]
tokens = [tokens EXCEPT ![t] = @ ∪ {k}]
everTokenMintedBy = everTokenMintedBy ∪ {⟨t, by⟩}
UNCHANGEDowners, claimed, mutators, everAuthorized

Owner revokes one of the tenant's tokens (DELETE /v1/authz/tenants/:target/tokens/:id).

RevokeToken(t, by, k) ≜
byowners[t]
ktokens[t]
tokens = [tokens EXCEPT ![t] = @ \ {k}]
UNCHANGEDowners, claimed, everTokenMintedBy, mutators, everAuthorized

Relationship mutation via `Authorization: Bearer agk_...` on the tenant's own host (serviceTokenAuth): a live token of THIS tenant is delegated owner authority, recorded under svc. everAuthorized is extended from the actual token state — not the guard — so a broken guard is caught by MutatorsWereAuthorized rather than silently self-justifying.

MutateViaToken(t) ≜
tokens[t] ≠ {}
mutators = mutators ∪ {⟨t, svc⟩}
everAuthorized = everAuthorized
(IF tokens[t] ≠ {} THEN {⟨t, svc⟩} ELSE {})
UNCHANGEDowners, tokens, claimed, everTokenMintedBy

AuthzRPC.cleanupTenant: drop the tenant's authz data, ownership tuples and service tokens (domain/sandbox delete, sandbox sweep). History variables are unchanged.

Cleanup(t) ≜
owners = [owners EXCEPT ![t] = {}]
tokens = [tokens EXCEPT ![t] = {}]
UNCHANGEDclaimed, everTokenMintedBy, mutators, everAuthorized
Next
∨ ∃ tTenants, uUsers : Claim(t, u) ∨ Mutate(t, u)
∨ ∃ tTenants, uUsers, kTokenIds :
MintToken(t, u, k) ∨ RevokeToken(t, u, k)
∨ ∃ tTenants : MutateViaToken(t) ∨ Cleanup(t)
SpecInit ∧ □[Next]vars
TypeOK
owners ∈ [TenantsSUBSET Users]
tokens ∈ [TenantsSUBSET TokenIds]
claimedTenants × Users
everTokenMintedByTenants × Users
mutatorsTenants × Principals
everAuthorizedTenants × Principals

Ownership only ever comes from the trusted grant path.

OwnersWereClaimed
tTenants : ∀ uowners[t] : ⟨t, u⟩ ∈ claimed

Anonymous sandboxes are ownerless throwaway pools.

NoAnonOwners ≜ ∀ tAnonSandbox : owners[t] = {}

Every gated mutation was authorized when it happened.

MutatorsWereAuthorizedmutatorseverAuthorized

Every token mint was performed by a user owner-claimed on that tenant at mint time (claimed is monotone, so membership now implies membership then).

TokensImplyOwnerMintedeverTokenMintedByclaimed

Anonymous sandboxes can never hold service tokens.

NoAnonTokens ≜ ∀ tAnonSandbox : tokens[t] = {}

Token mutations only ever happen on tenants whose owner minted a token.

TokenMutatorsHadMintedTokens
tTenants :
t, svc⟩ ∈ mutators ⇒ ∃ uUsers : ⟨t, u⟩ ∈ everTokenMintedBy

Gratos_Authz.cfg

SPECIFICATION Spec

Quiescent states are legal: e.g. all tenants cleaned up with no pending onboarding is a valid idle state (Claim/Mutate can always re-fire, but a stuttering-only run is fine).

CHECK_DEADLOCK FALSE
CONSTANTS
Users = {u1, u2}
Tenants = {t1, t2}
AnonSandbox = {t2}
TokenIds = {k1, k2}
svc = svc
INVARIANTS
TypeOK
OwnersWereClaimed
NoAnonOwners
MutatorsWereAuthorized
TokensImplyOwnerMinted
NoAnonTokens
TokenMutatorsHadMintedTokens

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) First publication to the hub; spec previously validated with the Java TLC toolchain. 1316 12 2026-07-20 16:39:09 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…