Authorization model
Authorization in iam.svc is role-string RBAC evaluated by the data layer. The token carries a flat roles array, the auth middleware projects it onto the request, and every entity’s declarative access: block is a boolean expression over that array. There is no policy service, no authorization API call, and no permission catalogue.
There is no permission catalogue
Section titled “There is no permission catalogue”Nothing in the deployable service models a permission. Roles are the entire authorization currency, and they are matched by exact string membership inside hand-written expressions:
create: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles' }There is no permission entity, no role-to-permission table, no permission claim, and no wildcard permission syntax on this path. A role is not resolved, expanded, or inherited before it reaches a rule — it is compared as a literal string.
The decision path
Section titled “The decision path”A request is decided in this order. Every step is in-process.
| # | Stage | What it does |
|---|---|---|
| 1 | Tenancy middleware | Fixes the request’s customer:product:env:tenant position from the X-Customer / X-Product / X-Env / X-Tenant headers |
| 2 | Auth middleware | Validates the Bearer token or ApiKey credential and builds the principal |
| 3 | Principal projection | Copies user id, tenant, roles and the impersonation actor onto the request context for the data layer |
| 4 | Read-only actor floor | Denies any mutating action when the credential is a read-only impersonation — checked before every other gate |
| 5 | Service allowlist | Enforces access.services when the entity declares one |
| 6 | Action gate | Evaluates access.actions.<verb> for read / create / update / delete |
| 7 | Extended action hook | Evaluates non-CRUD actions, notably unmask |
| 8 | Row filter | Evaluates access.rls.<verb> and ANDs the resulting predicate into the WHERE clause |
Steps 5 through 8 are the same enforcement the Data API uses; IAM’s own entities are governed by it exactly like any entity you author.
What a rule can see
Section titled “What a rule can see”Both evaluators inject a fixed user map. This is the complete binding surface — a rule cannot reach email, realm, arbitrary identity attributes, or the row being written.
| Key | Value |
|---|---|
user.id | the authenticated subject’s id |
user.user_id | the same value, under the alternate name |
user.tenant | the token’s customer:product:env:tenant |
user.tenant_id | the same value |
user.roles | the roles claim, verbatim |
user.service | the calling service identifier, when the request is service-to-service |
user.act | nil, or {sub, realm, mode, readonly} when the request is an impersonation |
user.grants | the tenant ids this operator holds a grant for — control-plane entities only |
language defaults to expr when omitted, and every rule shipped with the block is expr. Rules run in a sandbox with no filesystem and no network.
Action rules
Section titled “Action rules”An entity’s access: block has four tiers.
| Tier | Shape | Effect |
|---|---|---|
services | list of service identifiers | Allowlist. Empty or absent allows every service; non-empty plus an empty caller identity denies. |
actions | <verb>: { language, expression } | Boolean gate on the operation. |
rls | <verb>: { language, expression } | Row filter ANDed into the query. |
fields | field-tier rules | Column projection. |
The users entity is the canonical shape:
entities: - name: users access-lock: [actions, rls] access: actions: read: { language: expr, expression: 'user.id != ""' } create: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles' } update: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles' } delete: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles' } unmask: { language: expr, expression: '"iam-service" in user.roles' } rls: read: { language: expr, expression: '("admin" in user.roles || "iam-service" in user.roles) ? "" : user.id' }Read passes for any authenticated caller at the action gate and is then narrowed to the caller’s own row by the RLS tier. unmask gates the ?unmask=true extended action, so the at-rest password hash never leaves over the entity surface — not for an admin, not for an operator. See Identity entities for the full per-entity rule inventory.
Enforcement strictness
Section titled “Enforcement strictness”The gate runs in standard mode. In standard mode an action that declares a rule but reaches a host with no rule evaluator wired is allowed, not denied. iam.svc always wires an evaluator on the boot path, which is what makes standard mode safe here.
Row scoping
Section titled “Row scoping”An RLS rule returns a value, never SQL. The engine builds the predicate so the rule never handles quoting.
| Return | Predicate |
|---|---|
"" | none — unrestricted |
"U01ABC…" | id = "U01ABC…" |
["T1","T2"] | id IN ("T1","T2") |
[] | id IN ("") — matches no row |
anything else (bool, nil, map, list containing a non-string) | hard error; the query is refused |
The refusal on an uninterpretable return is deliberate. Coercing an unexpected value to "" would read as unrestricted and serve every row.
Well-known roles
Section titled “Well-known roles”Role strings are conventions, not a registry. These are the ones the shipped rules and route gates name.
| Role | Where it comes from | What it admits |
|---|---|---|
admin | a tenant identity’s properties.roles | tenant-side CRUD on identity entities; passes /v2/admin/* gates |
superadmin | derived from the reserved control-plane realm at mint — never from row data | reach to the control plane’s routes; passes /v2/superadmin/* gates. Authorizes no data by itself |
iam-service | attached internally by the service’s own handlers | the trusted internal identity named by every credential entity’s rule, including unmask |
root | an operator row’s properties.roles | everything in its own control plane, including destructive operations; an implicit grant over every tenant in that plane |
superadmin-admin | an operator row | manage the operator roster and grants; never unmask another operator’s hash |
tenant-provision | an operator row | create and update tenants |
tenant-read | an operator row | read-only auditor on the tenant registry |
impersonate | an operator row | read-write impersonation |
impersonate-readonly | an operator row | read-only impersonation |
There is no internal bypass. iam-service is a role like any other, expressed as a rule on each entity, which is why the login path’s credential read is visible in the same YAML a reviewer reads.
The marker and the privilege are separate
Section titled “The marker and the privilege are separate”When a login authenticates against the reserved control-plane realm, the mint forces roles: ["superadmin"] structurally — a tenant cannot assert it. The operator’s own row then contributes granular roles, and the two are unioned, not overwritten:
mergeRoles(["superadmin"], ["tenant-provision"]) -> ["superadmin", "tenant-provision"]Both are required. The marker alone passes the route gate and then fails every entity rule; a granular role alone never gets past the route gate. An operator with neither authenticates successfully and can do nothing.
Route gates match differently from entity rules
Section titled “Route gates match differently from entity rules”| Gate | Matching | On denial |
|---|---|---|
RequireAdmin on /v2/admin/* | case-insensitive, whitespace-trimmed; superadmin also passes | 403 v2.requires_admin |
RequireSuperadmin on /v2/superadmin/* | case-insensitive; strictly superadmin | 403 v2.requires_superadmin |
Entity access: expressions | exact string match | engine denial |
What a grant carries
Section titled “What a grant carries”The only grant object that ships is superadmin_grant, and it is deliberately flat.
| Field | Notes |
|---|---|
id | G + ULID |
superadmin_id | the operator; immutable after create |
tenant_id | the tenant registry id, not a customer:product:env:tenant string, so it survives renames; immutable after create |
reason | optional, 500 characters |
active | revocation flag |
There is no role column and no permission list. A grant answers which tenants only; what the operator may do comes from their plane-wide roles. Per-tenant roles (“provisioner on one, read-only on another”) are not expressible.
Both fields are immutable, so moving a grant is revoke-and-re-grant. A unique index on (superadmin_id, tenant_id) makes re-granting idempotent. Grants live in the control plane, so they are per product and environment.
Grants are enforced twice:
- Visibility. The tenant registry’s
rls.readreturns""forrootand the internal identity, anduser.grantsfor everyone else. Grants are loaded per request, so a revoke takes effect immediately rather than at token expiry. A failure to load the grant table is an error, never an empty set. - Action. The impersonation handler queries for an active grant on the target tenant before minting anything.
rootshort-circuits both.
root needs no grant rows at all. That is bounded only by a plane being one customer, product and environment. Full detail on the plane, the operator roster and the grant surface is on Superadmin.
The actor axis
Section titled “The actor axis”An impersonated token is the target user’s own token plus an act claim naming the operator:
{ "sub": "U01…", "roles": ["admin"], "act": { "sub": "U09…", "realm": "superadmin", "mode": "readonly" } }act can only narrow. mode: readonly denies every mutating action in the data layer, and that check runs before the gate consults the entity at all — so it holds when the entity declares no access block, when the action has no rule, and when the rule engine is disabled. Any act that is present but unrecognised or malformed reads as read-only. act is a reserved claim, so a tenant can never forge attribution.
Rules see the same thing as user.act, which lets an entity state a policy the engine-level floor cannot:
read: { language: expr, expression: 'user.act == nil' } # nobody may impersonate into this entity at allSee Impersonation for the full guard inventory.
The realm axis
Section titled “The realm axis”realm names which identity population authenticated, and resolves to a schema entity through the tenant’s composed configuration. The default realm is users. Roles come from whatever entity the realm resolves to, read from that row’s properties.roles.
realm is a reserved claim: a tenant’s custom claims can never inject or override it. See Realms.
Principal type is not implemented
Section titled “Principal type is not implemented”There is no principal-type axis. No claim distinguishes a human from a service, a delegate, an agent or a bot, and no code emits, reads, stores or validates one. The distinctions that exist today are the realm a token authenticated against, and the type claim marking a service token.
Service credentials
Section titled “Service credentials”A service token carries type: "service" and a scope claim of position grants (customer:product:env:tenant, * per segment). A user token’s scope claim is never treated as authority — it is honoured only when the token type is service. That is enforced at both ends: scope is reserved at the issuer so a tenant cannot inject it, and type-gated at the receiver.
An API key’s capabilities object may confer scope, roles, and is_superadmin, and all three are projected straight onto the bearer’s principal. Each is bounded at issuance by the issuer’s own authority; a request exceeding it is refused with 403 scope_exceeds_authority. This is the one place the service uses positional (customer:product:env:tenant) reasoning rather than role strings. See API keys and Tokens.
Where a rule may be authored
Section titled “Where a rule may be authored”Access rules compose across layers, and the layer decides what is allowed to contribute.
| Layer | May author access rules |
|---|---|
| Service (the block’s own entity definitions) | yes — and may seal |
| Product | yes, per tier, unless the tier is sealed |
| Tenant | no — a tenant-layer access: block is dropped outright |
Two seals exist:
access-lock: [actions, rls]on an entity seals the named tiers. A product override of a sealed tier is dropped with a diagnostic.final: trueseals the whole entity. Any layer’s contribution to it is dropped.
In the shipped schema: users is access-locked on [actions, rls]; role, tenant, superadmin, superadmin_grant and api_key are final; usergroup and groupmembership are left unsealed so a product may own their read rule.
Parts of the model that are tables only
Section titled “Parts of the model that are tables only”These entities ship, have access rules, and appear in the schema. Nothing reads them for an authorization decision.
| Entity | Shape | Status |
|---|---|---|
usergroup | name, visibility (Private / Public / Hidden), parentid (the hierarchy) | No code resolves group membership into roles or into any decision. |
groupmembership | userid, groupid, membershiptype (Owner / Moderator / Member), starttime, endtime | Same — rows can be written and read; they authorize nothing. |
iamdelegations | userid, touserid, notbefore, notafter, active, attributes, claims | No code creates, reads or honours a delegation. Impersonation is the only delegation-shaped path that works. |
iamagents, iambots | identity rows for non-human principals | Removed from the composed schema entirely when their feature gates are off; no behaviour behind them either way. |
Role hierarchy and inheritance are not implemented anywhere on this path. A role never implies another role, in either direction, and superadmin passing an admin route gate is a hardcoded special case in that gate, not inheritance.
Named authorization: predicates declared in a product’s or tenant’s access configuration parse and merge across layers, but nothing in the request path evaluates them. Do not use them as a policy hook.
Deny-by-default, stated exactly
Section titled “Deny-by-default, stated exactly”Where it holds:
- A request with no validated credential reaches the data layer with an empty principal and fails every rule that names a role.
- A fresh operator holds no roles and no grants. They authenticate, and can do nothing to anyone.
- An operator with zero grants sees zero tenants, not all of them.
- An RLS rule the engine cannot interpret refuses to serve rows.
- A malformed impersonation actor reads as read-only.
Where it does not:
- An entity with no
access:block is open to any authenticated caller of that tenant. - The action gate in standard mode allows a ruled action when no rule evaluator is wired.
Continue with
Section titled “Continue with”- Superadmin — the control plane, operator roster, and the grant surface
- Impersonation — the only operator-to-tenant-data path, gate by gate
- Identity entities — every shipped entity and its rules
- Tokens — claims, reserved names, lifetimes
- Access rules — the same rule shape applied to entities you author
- Row-level security and field protection
- Endpoint reference — request and response shapes
- Error codes