Skip to content
Talk to our solutions team

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.

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.

A request is decided in this order. Every step is in-process.

#StageWhat it does
1Tenancy middlewareFixes the request’s customer:product:env:tenant position from the X-Customer / X-Product / X-Env / X-Tenant headers
2Auth middlewareValidates the Bearer token or ApiKey credential and builds the principal
3Principal projectionCopies user id, tenant, roles and the impersonation actor onto the request context for the data layer
4Read-only actor floorDenies any mutating action when the credential is a read-only impersonation — checked before every other gate
5Service allowlistEnforces access.services when the entity declares one
6Action gateEvaluates access.actions.<verb> for read / create / update / delete
7Extended action hookEvaluates non-CRUD actions, notably unmask
8Row filterEvaluates 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.

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.

KeyValue
user.idthe authenticated subject’s id
user.user_idthe same value, under the alternate name
user.tenantthe token’s customer:product:env:tenant
user.tenant_idthe same value
user.rolesthe roles claim, verbatim
user.servicethe calling service identifier, when the request is service-to-service
user.actnil, or {sub, realm, mode, readonly} when the request is an impersonation
user.grantsthe 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.

An entity’s access: block has four tiers.

TierShapeEffect
serviceslist of service identifiersAllowlist. 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.
fieldsfield-tier rulesColumn 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.

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.

An RLS rule returns a value, never SQL. The engine builds the predicate so the rule never handles quoting.

ReturnPredicate
""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.

Role strings are conventions, not a registry. These are the ones the shipped rules and route gates name.

RoleWhere it comes fromWhat it admits
admina tenant identity’s properties.rolestenant-side CRUD on identity entities; passes /v2/admin/* gates
superadminderived from the reserved control-plane realm at mint — never from row datareach to the control plane’s routes; passes /v2/superadmin/* gates. Authorizes no data by itself
iam-serviceattached internally by the service’s own handlersthe trusted internal identity named by every credential entity’s rule, including unmask
rootan operator row’s properties.roleseverything in its own control plane, including destructive operations; an implicit grant over every tenant in that plane
superadmin-adminan operator rowmanage the operator roster and grants; never unmask another operator’s hash
tenant-provisionan operator rowcreate and update tenants
tenant-readan operator rowread-only auditor on the tenant registry
impersonatean operator rowread-write impersonation
impersonate-readonlyan operator rowread-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.

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”
GateMatchingOn denial
RequireAdmin on /v2/admin/*case-insensitive, whitespace-trimmed; superadmin also passes403 v2.requires_admin
RequireSuperadmin on /v2/superadmin/*case-insensitive; strictly superadmin403 v2.requires_superadmin
Entity access: expressionsexact string matchengine denial

The only grant object that ships is superadmin_grant, and it is deliberately flat.

FieldNotes
idG + ULID
superadmin_idthe operator; immutable after create
tenant_idthe tenant registry id, not a customer:product:env:tenant string, so it survives renames; immutable after create
reasonoptional, 500 characters
activerevocation 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.read returns "" for root and the internal identity, and user.grants for 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. root short-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.

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 all

See Impersonation for the full guard inventory.

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.

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.

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.

Access rules compose across layers, and the layer decides what is allowed to contribute.

LayerMay author access rules
Service (the block’s own entity definitions)yes — and may seal
Productyes, per tier, unless the tier is sealed
Tenantno — 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: true seals 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.

These entities ship, have access rules, and appear in the schema. Nothing reads them for an authorization decision.

EntityShapeStatus
usergroupname, visibility (Private / Public / Hidden), parentid (the hierarchy)No code resolves group membership into roles or into any decision.
groupmembershipuserid, groupid, membershiptype (Owner / Moderator / Member), starttime, endtimeSame — rows can be written and read; they authorize nothing.
iamdelegationsuserid, touserid, notbefore, notafter, active, attributes, claimsNo code creates, reads or honours a delegation. Impersonation is the only delegation-shaped path that works.
iamagents, iambotsidentity rows for non-human principalsRemoved 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.

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.