# Superadmin

> The iam.svc control plane — the reserved superadmin tenant, operator login, the role and grant model, the tenant pin that refuses a plane token on a tenant surface, and the complete guard inventory.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authz/superadmin/ -->

A superadmin is an **operator identity** that lives in a reserved control plane, not in any tenant. It administers the tenant registry and the operator roster for one product in one environment. It is not a tenant user with extra roles, and its token is not a master key: a control-plane token presented on a tenant's surface is rejected.

## The plane

The control plane is the reserved CPET whose **tenant** slot is the literal name `superadmin`:

```text
<customer>:<product>:<env>:superadmin
```

The plane a request can reach is a pure function of the CPET the request arrived with — the tenant slot is replaced, customer/product/env are kept. `acme:shop:prod:main` resolves to `acme:shop:prod:superadmin` and to nothing else. **There is no deployment-global plane.** Two products, or two customers, sharing one database get different planes, in different schemas, signing with different keys. An operator of `acme:shop:prod` is not an operator of `globex:crm:prod` and cannot address that plane at all.

| Property | Value |
|---|---|
| Address | `<customer>:<product>:<env>:superadmin` |
| Scale | one plane per customer + product + environment |
| PostgreSQL schema | `superadmin` (hardcoded) |
| Signing keys | the plane's own keyring — the plane's JWKS carries no tenant's keys |
| Entities | `superadmin` (operator roster), `superadmin_grant`, `tenant` (registry), `tenant_eventlog` |
| Deployment | a reserved hive binding; unresolvable = `503 superadmin_not_deployed` on every plane route |

`customer` and `product`, and their prior spellings `superadmin` and `shared`, are reserved scope names. All are refused — case-insensitively, trimmed — in **any** CPET segment and in any tenant short name, in every deployment, whether or not the scope is deployed.

Plane entities declare `scope: customer` (the prior `superadmin` is folded) and `final: true`, so they sit in the reserved schema and are sealed against product and tenant schema layering. See [Identity entities](/blocks/baas/iam/identity/entities/) for the tenant-side model.

:::note
`503 superadmin_not_deployed` covers every failure to resolve the plane's engine, not only an absent hive binding: an unregistered product, a tenant config the running service cannot fetch, and a pool that will not build all answer the same way. The response is deliberately vague — an unauthenticated caller learns nothing about which planes exist — and the real cause is written to the service log.
:::

## Getting an operator token

### Bootstrap the first operator

`POST /auth/bootstrap` creates exactly one operator, seeded with `properties.roles: ["root"]`, in the plane governing the request's product and environment. It is gated by the `bootstrap.token` config key, compared in constant time.

```bash
curl -s localhost:5030/auth/bootstrap \
  -H 'Content-Type: application/json' \
  -H 'X-Customer: acme' -H 'X-Product: shop' \
  -H 'X-Env: prod'     -H 'X-Tenant: superadmin' \
  -d '{"token":"<bootstrap.token>","email":"root@ops.io","password":"…","firstname":"Root","lastname":"Operator"}'
```

```json
{"ok": true, "id": "U01…", "email": "root@ops.io"}
```

An **empty `bootstrap.token` disables the endpoint entirely**. Token unset, token mismatch, and "an operator already exists" all return the same `403 already_bootstrapped`, so a stranger probing the endpoint learns nothing about which of the three it hit. Rotate or remove the secret once the first operator exists. The route carries no rate-limit profile; the constant-time compare and the single envelope are its only protection.

The CLI equivalent seeds through the same core — see [Operating](/blocks/baas/iam/operating/configuration/).

### Operator login

`POST /auth/login` with `"realm":"superadmin"` (matched case-insensitively) under the plane's tenancy headers. The handler resolves the plane from the request CPET, reads the `superadmin` entity, verifies argon2id, mints with the plane's own keyring, and issues a refresh token in the plane's own table.

```bash
curl -s localhost:5030/auth/login \
  -H 'Content-Type: application/json' \
  -H 'X-Customer: acme' -H 'X-Product: shop' \
  -H 'X-Env: prod'     -H 'X-Tenant: superadmin' \
  -d '{"identity":"root@ops.io","password":"…","realm":"superadmin"}'
```

```json
{
  "token": "v4.public.…",
  "refresh_token": "…",
  "expires_in": 900,
  "user": {"id": "U01…", "email": "root@ops.io", "displayname": "Root"}
}
```

The claims carry `tenant: acme:shop:prod:superadmin`, `realm: superadmin`, `type: user`, and the `roles` array described below. `/auth/refresh` and `/auth/logout` work unchanged for the plane when the client sends the plane's headers.

| Field | Value |
|---|---|
| `identity` | An operator identifier: `email` (the default `identity_type`) or `mobile`. Both unique, both `nolog` |
| `identity_type` | Optional. Anything not declared on the `superadmin` entity → `400 bad_identity_type` |
| `realm` | `superadmin`, case-insensitive. Any other value goes to the ordinary tenant realm machinery |

The lookup filters on `active: true`. Identity not found, no stored hash, and a failed argon2 verify all return the same `401 invalid_credentials`.

:::caution
Operator login has **no MFA and no lockout**. The plane carries no MFA tables, and although the `superadmin` entity has a `locked` field, nothing in the login path reads it. The only brake is the `auth_login` rate-limit profile — token bucket, capacity 30, refill 0.5/s, keyed on tenant plus caller IP — and its default store is in-memory and per instance, so the cap is per instance rather than fleet-wide. Supply a distributed-store limiter for a fleet-wide limit.
:::

## Membership is not authority

At mint, `realm == "superadmin"` forces the single role `superadmin`. That marker is derived from the **realm**, never from row data, and it is what route gates read. The operator row's `properties.roles` are then **unioned** on top — union, not assignment, so a row declaring roles of its own does not erase the marker.

The marker grants **reach and nothing else**: it admits an operator to the plane's routes, not to the plane's data. Every plane entity gates on a granular role, so a fresh operator with no roles and no grants authenticates successfully and can do nothing to anyone. That is the authority floor — deny by default, with the marker at zero.

| Role | Authority |
|---|---|
| `superadmin` | Plane membership marker. Route reach only; no data access |
| `root` | Everything on every plane entity, including deletes; an implicit grant over tenants |
| `superadmin-admin` | Roster read/create/update (**not** delete); full grant CRUD |
| `tenant-provision` | Registry read/create/update; grant read |
| `tenant-read` | Registry read; grant read — a read-only auditor |
| `impersonate-readonly` | `POST /superadmin/impersonate` with `read_write` false |
| `impersonate` | `POST /superadmin/impersonate` with `read_write` true |
| `iam-service` | The service's own trusted handlers. Not assignable to a person |

Roles are plain strings in `properties.roles`. **There is no role catalog entity for operator roles** — a role exists because a rule names it. Grant them lowercase-exact: the impersonation handler compares role strings byte-for-byte, so `Root` does not satisfy a check for `root`, while the platform route gate lowercases and trims.

### Exactly which rule admits which role

| Entity | read | create | update | delete | unmask |
|---|---|---|---|---|---|
| `superadmin` | `iam-service`, `root`, `superadmin-admin` | same | same | `iam-service`, `root` | `iam-service` only |
| `superadmin_grant` | `iam-service`, `root`, `superadmin-admin`, `tenant-read`, `tenant-provision` | `iam-service`, `root`, `superadmin-admin` | same as create | same as create | — |
| `tenant` | `iam-service`, `root`, `tenant-read`, `tenant-provision` | `iam-service`, `root`, `tenant-provision` | same as create | `iam-service`, `root` | — |

Grant `read` is deliberately open to anyone who may read the registry: an operator can see what they have been given. It exposes the mapping, never tenant data.

`unmask` on the roster is `iam-service`-only, even for `root`. Managing an operator account and holding its credential are different powers; the `password` field also carries `redact`, so the at-rest argon2id hash never leaves over REST or the admin surface.

:::caution
`root` may update the roster, and roles live in the row, so **root can add `impersonate` or `impersonate-readonly` to its own row**. The explicit-grant requirement still converts a silent capability into a recorded act, but root is not prevented from self-granting.
:::

## Grants: which tenants

Roles say what kind of operation an operator may perform. A `superadmin_grant` row says which tenants they may perform it on. Both are deny-by-default.

| Field | Type | Notes |
|---|---|---|
| `superadmin_id` | string, required | The operator's `superadmin` row id, in this plane. `final` |
| `tenant_id` | string, required | A `tenant` **registry row id**, not a CPET — ids survive a rename. `final` |
| `reason` | string, max 500 | Why this operator holds this grant |
| `active` | boolean, default `true` | Soft revoke |

Unique index `superadmin_grant_uq` on (`superadmin_id`, `tenant_id`) makes re-granting idempotent instead of leaking duplicate rows a revoke would then have to chase; `superadmin_grant_operator_idx` covers lookups by operator. Both id columns are `final`: a grant is revoked and re-granted, never moved.

The table is **flat — there is no per-tenant role column**. "Provisioner on t1, read-only on t2" is not expressible.

`root` needs no rows: the role carries an implicit grant over the tenants in its own plane.

### An ungranted tenant is invisible

The `tenant` entity carries a row-level-security rule bound to `user.grants`:

```yaml
rls:
  read:
    language: expr
    expression: '("iam-service" in user.roles || "root" in user.roles) ? "" : user.grants'
```

On a control-plane engine only, the RLS evaluator loads the operator's granted tenant ids **per request** from `superadmin_grant WHERE superadmin_id = $1 AND active = true`. Flipping `active` to false takes effect on the very next request rather than at token expiry. Tenant engines get no loader and pay nothing for the binding.

The semantics are fail-closed:

| Rule returns | Effect |
|---|---|
| `""` | Unrestricted — `iam-service` and `root` only |
| A list of ids | `id IN (…)` over the granted tenants |
| An empty list | `id IN ("")` — matches no row. "Granted nothing" means "sees nothing" |
| Anything not a string or list | Denied. It does not fall through to unrestricted |
| A grant-load failure | An error, never an empty set |

An ungranted tenant is not merely unactionable: the operator cannot learn its existence, name, or count. Absence is the right failure mode for a control plane, because the alternative is handing out a customer list. The general mechanism is documented on the [Data API access-rules page](/blocks/baas/data-api/security/access-rules/).

:::caution
This enforcement lives on the **engine**, so it covers the generic entity surface (`/rest/tenant`, `/admin/*`). It does **not** cover the purpose-built `/superadmin/registry/*` routes, which issue their engine calls as the internal `iam-service` principal. See [Guard inventory](#guard-inventory).
:::

## A plane token is not a master key

Every token is verified against the keyring of the tenant it targets and pinned to it: the token's `tenant` claim must equal the request's CPET. **There is no superadmin exception**, in the service's own middleware or in the shared platform PASETO/JWT verifier. Presenting an operator's plane token with a tenant's headers returns `401 tenant_mismatch`.

Two bypasses previously existed and are **deleted**:

- A client-supplied `X-SuperAdmin` request header, trusted with no corroboration. Holding a valid token for your own tenant, aiming it at another tenant's domain and adding the header served you as the victim. The gateway set the header inside a gated branch but never stripped an inbound one, so it was reachable across customers.
- A verified plane-token predicate that admitted a plane token onto tenant surfaces within the request's own product space.

Neither is a supported pattern, and neither header nor predicate exists to re-enable. Any document, client, or runbook that presents a plane token on a tenant CPET is describing removed behaviour.

Operator-gated routes are reached with the **plane's own headers**. On the shared operator surface (`/superadmin/tenant/:tenant/*`) the `:tenant` path slot takes a **bare tenant name**, composed onto the caller's own customer:product:env; a full CPET in that slot is rejected. A cross-product target is unrepresentable there rather than merely refused.

:::note
This service mounts every surface with an empty prefix. The real paths are `/superadmin/…`, `/admin/…`, `/rest/…`, `/auth/…`, `/internal/session/…` — there is no `/v2/` prefix, whatever the shared library's own comments say. Wire shapes for all of them are in the [endpoint reference](/blocks/baas/iam/api/).
:::

## The operator surfaces

Everything an operator token reaches, and what gates it.

| Surface | Gate |
|---|---|
| `POST /auth/login` (`realm: superadmin`), `/auth/refresh`, `/auth/logout` | None — this is the auth surface. `auth_login` rate limit on login |
| `POST /auth/bootstrap` | `bootstrap.token`, constant-time; self-closing |
| `POST /superadmin/impersonate` | Verified operator principal, exact impersonation role, grant, per-user consent — all in the handler, no route-level role gate |
| `POST`/`GET /superadmin/registry/tenants`, `POST …/provision` | Platform token wrapper + `RequireSuperadmin` (the membership marker) only |
| `GET /superadmin/tenant` | Platform token wrapper + `RequireSuperadmin` |
| `/superadmin/tenant/:tenant/*` — cache bust, seeds, applied migrations, DDL plans and apply, locks and force-release, actions, references, query hashes | Platform token wrapper + `RequireSuperadmin`; `:tenant` composed onto the caller's own c:p:e |
| `/superadmin/tenant/:tenant/retention/*`, `/materialize/*` | Registered through the shared surface but unsupported on this service |
| `/rest/tenant`, `/rest/superadmin*`, `/admin/*` | The real operator principal — granular roles **and** the grant RLS both bite here |

`RequireSuperadmin` reads the `roles` claim and asks only whether `superadmin` is present, matched case-insensitively; `admin` does not qualify, and `admin` routes accept `superadmin` as well. Denials are `403 requires_superadmin` and `403 requires_admin`.

DDL apply on this service touches signing-key, credential and identity tables, which is why it is operator-gated rather than tenant self-service.

### Tenant registry

| Method | Path | Behaviour |
|---|---|---|
| `POST` | `/superadmin/registry/tenants` | Register a tenant. Sets `active: true`, `markedforseed: true`. `201` |
| `GET` | `/superadmin/registry/tenants` | List, capped at 500, projecting `id`, `slug`, `displayname`, `active` |
| `POST` | `/superadmin/registry/tenants/provision` | Create schema and tables, seed the tenant admin, activate. Idempotent |

```bash
curl -s localhost:5030/superadmin/registry/tenants \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'X-Customer: acme' -H 'X-Product: shop' \
  -H 'X-Env: prod'     -H 'X-Tenant: superadmin' \
  -d '{"slug":"acme:shop:prod:eu1","displayname":"Acme EU"}'
```

```json
{"id": "01J…", "slug": "acme:shop:prod:eu1"}
```

A slug is accepted either as a bare short name or as a full `customer:product:env:tenant` key; reserved scope names are refused in either spelling (`400 reserved_or_invalid_name`). Registration records a tenant; provisioning brings it live. Provisioning requires the tenant's datastore to already exist in the tenant hive and the **database** to exist — the service creates the schema, never the database. See [Operating](/blocks/baas/iam/operating/configuration/).

:::caution
The registry slug is the one place the two halves of the system disagree about what a slug is. Provisioning looks the registry up by **full CPET**; the impersonation grant check looks it up by the **tenant segment alone**. Store slugs one way and one of the two paths misses. See [Guard inventory](#guard-inventory).
:::

:::caution
All three registry routes are gated by the membership marker alone. Read the guard inventory before treating operator roles or grants as a control over them.
:::

## The one door into tenant data

An operator's plane token is refused on every tenant surface, and no tenant entity grants operator roles. An operator holding `root` plus a grant on every tenant in their plane still reads zero rows of customer data directly. The single sanctioned path is `POST /superadmin/impersonate`, which mints the **target user's own tenant-signed token** carrying an `act` claim that names the operator and a `readonly` or `readwrite` mode.

| Role | Mints | Implied by |
|---|---|---|
| `impersonate-readonly` | `mode: readonly` | nothing |
| `impersonate` | `mode: readwrite` | nothing |

Neither is implied by `root`, by `superadmin-admin`, or by the other, and matching is case-sensitive. The read-only mode is enforced by a floor in the data layer's access gate that refuses `create`, `update`, `delete`, `purge` and `restore` before any entity rule runs — a property of the credential, not a rule an entity can opt out of. Anything the verifier cannot read as exactly `readwrite` falls closed to read-only.

The full flow — the twelve ordered checks, the consent gate, the claim shape, audit attribution, and how a session is terminated — is on [Impersonation](/blocks/baas/iam/authz/impersonation/).

## Guard inventory

### Enforced

| Guard | Covers |
|---|---|
| Reserved-name refusal | `superadmin` / `shared` rejected in any CPET segment and any tenant short name, deployed or not |
| Plane derivation | The plane is a pure function of the request CPET; another customer's or product's plane is unaddressable |
| Keyring isolation | The plane signs with its own keys; its JWKS carries no tenant's keys |
| Realm-derived marker | Plane membership comes from the realm, never from row data |
| Role union | Row roles are unioned onto the marker, so granular privileges never cost an operator their membership |
| Deny-by-default plane entities | A fresh operator holds the marker only, and every plane entity refuses them |
| Grant RLS on the registry | Per-request grant load; empty grants match no row; non-string/non-list returns deny; a load failure is an error |
| Tenant pin | Token `tenant` claim must equal the request CPET, with no superadmin exception, in both the service and the shared middleware |
| Bare tenant slot | `/superadmin/tenant/:tenant/*` composes the slot onto the caller's own c:p:e; a full CPET is rejected |
| Exact-role impersonation check | Byte-for-byte; read-write does not imply read-only, and `root` implies neither |
| Grant check on impersonation | Non-`root` operators need an active grant on the target tenant |
| Per-user consent | `users.allow_impersonation`, default false, structurally unwritable by an operator |
| Reserved `act` claim | Refused at parse and skipped at mint; a tenant cannot inject or shadow an actor |
| Read-only floor | Checked above every early return in the access gate; an unreadable mode falls closed to read-only |
| Operator attribution | Audit `actor` is the operator (`changed_by` stays the target user), written into the tenant's own schema |
| Credential separation | `unmask` on the roster is `iam-service`-only; `password` is hashed and redacted |
| No enumeration oracle | `not_granted` is identical for "no grant" and "no such tenant"; `already_bootstrapped` covers three distinct causes; `invalid_credentials` covers three |
| Constant-time bootstrap compare | The bootstrap secret is compared without a timing signal, and an empty value disables the route |

## Not implemented

Specified, with no shipping code path. **Do not design against them.**

| Feature | State |
|---|---|
| Per-tenant operator roles | The grant table is deliberately flat — no role column |
| Impersonation start notification | No notification is sent when an impersonation begins |
| Step-up re-authentication before impersonation | The handler requires a role and a reason |
| MFA and lockout for operator login | The operator plane carries no MFA tables |

If your process needs any of these, build it around the endpoint rather than expecting it from
the endpoint — an operator runbook, an out-of-band notification, or an approval step in front.

## Errors

Control-plane codes. Impersonation's own set is on [Impersonation](/blocks/baas/iam/authz/impersonation/#errors).

| Code | HTTP | Meaning |
|---|---|---|
| `bad_request` | 400 | Malformed JSON body |
| `no_tenant` | 400 | No tenancy on the request, so the plane cannot be resolved (401 from the token middleware) |
| `bad_identity_type` | 400 | `identity_type` is not a declared operator identifier |
| `missing_fields` | 400 | Bootstrap body missing token, email, password, firstname or lastname |
| `reserved_or_invalid_name` | 400 | Registry slug used a reserved scope name, or is not a valid CPET |
| `invalid_credentials` | 401 | Operator not found, no stored hash, or argon2 verify failed — one envelope for all three |
| `tenant_mismatch` | 401 | Token `tenant` claim does not equal the request tenant. This is the guard that refuses a plane token on a tenant surface |
| `invalid_token` | 401 | Verification failure; also covers the tenant mismatch in the shared middleware |
| `token_expired` | 401 | Outside the validity window. A token with no `exp` is invalid — every mint sets one |
| `already_bootstrapped` | 403 | Token unset, token mismatch, or an operator already exists |
| `requires_superadmin` | 403 | The roles claim does not contain `superadmin` (case-insensitive; `admin` does not qualify) |
| `requires_admin` | 403 | The `/admin` gate. `superadmin` passes it too |
| `user_query_failed` | 500 | Operator identity lookup failed against the plane engine |
| `sign_failed` | 500 | Could not mint the operator's access token |
| `issue_failed` | 500 | Could not issue the operator refresh token |
| `persist_failed` | 500 | Bootstrap could not create the first operator |
| `create_failed` / `list_failed` | 500 | Registry create or list failed at the engine |
| `provision_failed` | 500 | Provisioning failed. Operator-fixable causes remap to `400 bad_request`, an unresolvable plane to `503 superadmin_not_deployed` |
| `superadmin_not_deployed` | 503 | The plane's engine could not be resolved for this product and environment |

The full external table is on [Errors](/blocks/baas/iam/error/).

## Continue with

- [Impersonation](/blocks/baas/iam/authz/impersonation/) — the one door into tenant data, end to end
- [Tokens](/blocks/baas/iam/tokens/) — claims, reserved claims, PASETO minting, managed sessions
- [Identity entities](/blocks/baas/iam/identity/entities/) — `users`, `session`, and the tenant-side model
- [Operating](/blocks/baas/iam/operating/configuration/) — bootstrap, tenant hive config, migrations, event-hook wiring
- [Endpoint reference](/blocks/baas/iam/api/) — every route with its wire shape