# Authorization model

> How iam.svc decides a request — role strings on the token, entity access rules evaluated in the data layer, row scoping, grants, and the parts of the model that ship as tables with no behaviour behind them.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authz/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

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:

```yaml
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.

:::caution
A `role` entity exists in the schema (`slug`, `displayname`, `active`, `properties`) and is administrable through the entity surface, but nothing in the authorization path reads it. The `roles` claim comes from the identity row's `properties.roles`, not from a join against `role`. Creating a `role` row grants nobody anything.
:::

## 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](/blocks/baas/data-api/security/access-rules/) uses; IAM's own entities are governed by it exactly like any entity you author.

## 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

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:

```yaml
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](/blocks/baas/iam/identity/entities/) for the full per-entity rule inventory.

### 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.

:::caution
An entity that declares **no** `access:` block at all is open to any authenticated caller of the tenant. Deny-by-default comes from the rules, not from their absence. A product or tenant extension entity must declare its own rules explicitly.
:::

## 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.

:::note
`[]` and `""` are opposite answers. An empty list means "you may see nothing"; an empty string means "you may see everything". A rule that returns the wrong one of those is the most expensive mistake available in this file.
:::

## 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

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:

```text
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

| Gate | Matching | On denial |
|---|---|---|
| `RequireAdmin` on `/v2/admin/*` | case-insensitive, whitespace-trimmed; `superadmin` also passes | `403 requires_admin` |
| `RequireSuperadmin` on `/v2/superadmin/*` | case-insensitive; strictly `superadmin` | `403 requires_superadmin` |
| Entity `access:` expressions | exact string match | engine denial |

:::caution
The same role string can pass a route gate and fail an entity rule. `Admin` clears `RequireAdmin` and then fails `"admin" in user.roles`. Store role slugs lower-case.
:::

## 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.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](/blocks/baas/iam/authz/superadmin/).

## The actor axis

An impersonated token is the target user's own token plus an `act` claim naming the operator:

```json
{ "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:

```yaml
read: { language: expr, expression: 'user.act == nil' }   # nobody may impersonate into this entity at all
```

See [Impersonation](/blocks/baas/iam/authz/impersonation/) for the full guard inventory.

## 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](/blocks/baas/iam/authn/realms/).

### 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

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](/blocks/baas/iam/authn/api-keys/) and [Tokens](/blocks/baas/iam/tokens/).

## 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: 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.

## 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

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

- [Superadmin](/blocks/baas/iam/authz/superadmin/) — the control plane, operator roster, and the grant surface
- [Impersonation](/blocks/baas/iam/authz/impersonation/) — the only operator-to-tenant-data path, gate by gate
- [Identity entities](/blocks/baas/iam/identity/entities/) — every shipped entity and its rules
- [Tokens](/blocks/baas/iam/tokens/) — claims, reserved names, lifetimes
- [Access rules](/blocks/baas/data-api/security/access-rules/) — the same rule shape applied to entities you author
- [Row-level security](/blocks/baas/data-api/security/row-level-security/) and [field protection](/blocks/baas/data-api/security/field-protection/)
- [Endpoint reference](/blocks/baas/iam/api/) — request and response shapes
- [Error codes](/blocks/baas/iam/error/)