IAM API
Every route the iam.svc binary mounts, with its exact path, payloads and gate. Concepts live in the
IAM block pages: authentication flows,
tokens and keys, authorization,
the identity schema and operating the service.
The full error catalogue is on the IAM error page.
Conventions
Section titled “Conventions”Routes are mounted at the root. There is no version prefix — the path in this reference is the path on the wire.
Tenant headers
Section titled “Tenant headers”Every route except GET /ready and GET /health resolves a tenant from four headers. Examples below
abbreviate them as $H.
| Header | Example |
|---|---|
X-Customer | acme |
X-Product | shop |
X-Env | prod |
X-Tenant | eu1 |
H='-H "X-Customer: acme" -H "X-Product: shop" -H "X-Env: prod" -H "X-Tenant: eu1"'Headers that resolve to no configured tenant get 400 with the plain-text body invalid tenant
— not a JSON envelope.
Credentials
Section titled “Credentials”| Channel | Where it is read |
|---|---|
Authorization: Bearer <token> | Every authenticated route. PASETO v4.public; a legacy EdDSA JWT still verifies |
Authorization: ApiKey <id>.<secret> | POST /auth/token (required), plus GET /rest/:entity and PATCH /rest |
Cookie: jwt=<token> | /rest, /anon/rest, /admin/*, /schema/*, /cache, /superadmin/tenant/* — the shared library extractor accepts the token from this cookie as well as the header. The /auth/* routes read the header only |
Cookie: kis_session=<opaque> | Set by login on a managed tenant; exchanged for a token by the gateway, never verified as a credential by these routes |
A token whose tenant claim disagrees with the CPET headers is rejected with 401 tenant_mismatch.
There is no superadmin exception: a control-plane token presented under a tenant’s headers is rejected
like any other mismatch.
Error envelopes
Section titled “Error envelopes”Three shapes ship. Key on the pair (status, code) — several codes carry different statuses on different routes.
| Surface | Body |
|---|---|
/auth/*, /superadmin/registry/*, /superadmin/impersonate, /internal/session/* | {"error": "…", "code": "<slug>"} |
/rest, /anon/rest, /admin/user, /admin/role, /schema, /cache, /superadmin/tenant/* | {"error": "…", "code": "v2.<slug>", "details": {…}} |
/admin/data/* | {"error": "…"} — no code field at all |
Response headers
Section titled “Response headers”Set on every response, including pre-auth and pre-tenant failures:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: no-referrerCache-Control: no-storeContent-Security-Policy: default-src 'none'; frame-ancestors 'none'Strict-Transport-Security: max-age=63072000; includeSubDomainsCache-Control: no-store applies to GET /.well-known/jwks.json too, so a relying party must cache
the key set in its own process rather than relying on HTTP caching.
Body limits
Section titled “Body limits”1 MiB on every request; 8 KiB on POST /superadmin/impersonate. An over-limit body surfaces as a
400 JSON decode error, not a 413.
Rate limits
Section titled “Rate limits”Six profiles are declared. The bucket key is (profile, tenant, client IP); requests with no resolved
tenant are not bucketed. Client IP is taken from X-Forwarded-For (first entry), then X-Real-IP,
then the socket.
| Profile | Routes | Capacity | Refill |
|---|---|---|---|
auth_login | POST /auth/login | 30 | 0.5/s |
auth_mfa | POST /auth/mfa/verify | 30 | 0.5/s |
auth_reset | POST /auth/password/reset/request, /confirm | 15 | 0.25/s |
auth_magic | POST /auth/magic/request, /verify | 15 | 0.25/s |
auth_token | POST /auth/token | 60 | 1/s |
auth_apikey | POST /auth/apikey | 15 | 0.25/s |
A denial returns 429 with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining:
{ "error": "rate_limited", "profile": "auth_login", "retry_after_seconds": 2 }Every other route is unlimited. POST /superadmin/impersonate is wrapped with a profile name that is
not declared, and an undeclared profile passes through untouched — impersonation is not
rate-limited.
Authentication
Section titled “Authentication”| Method + path | Auth | Purpose |
|---|---|---|
POST /auth/login | none | Password login; also the operator login |
POST /auth/refresh | none (refresh token is the credential) | Rotate the token pair |
POST /auth/logout | none | Revoke a refresh token’s rotation family |
POST /auth/magic/request | none | Send a magic-link code |
POST /auth/magic/verify | none | Consume a code and log in |
POST /auth/webauthn/assert/begin | none | Begin a passkey login |
POST /auth/webauthn/assert/finish | none | Complete a passkey login |
GET /auth/oauth/:provider/start | none | Begin a delegated login |
GET /auth/oauth/:provider/callback | none | Provider redirect target |
Password login
Section titled “Password login”POST /auth/login
{ "password": "…", "identity_type": "mobile", "realm": "patient"}identity and password are required. identity_type names a declared identifier column; naming an
undeclared column returns 400 bad_identity_type with the declared set in the message. realm
selects the realm; omitted means the default realm. "realm": "superadmin" short-circuits to the
control-plane login — there is no /superadmin/login route.
Three success shapes, all 200:
{ "token": "v4.public.…", "refresh_token": "…", "expires_in": 900, "user": { "id": "U01K…", "email": "…", "displayname": "…" }}{ "mfa_required": true, "mfa_token": "…", "methods": ["totp"] }{ "session_id": "…", "expires_at": "2026-07-23T12:00:00Z", "user": { "id": "U01K…" } }The third is a managed tenant. It also sets an HttpOnly, Secure, SameSite=Lax cookie named
kis_session, and carries no access token and no refresh token.
| Status | Code |
|---|---|
| 400 | bad_request, missing_fields, no_tenant, unknown_realm, bad_identity_type |
| 401 | invalid_credentials — identical for unknown identity, no stored password, and wrong password |
| 403 | provider_not_allowed, hook_denied |
| 409 | session_limit |
| 429 | login_abuse (a scripted denial), rate_limited (the built-in limiter) |
| 500 | engine_unavailable, user_query_failed, mfa_lookup_failed, sign_failed, issue_failed |
| 503 | policy_unavailable, session_service_unavailable |
Refresh and logout
Section titled “Refresh and logout”POST /auth/refresh — {"refresh_token": "…"} → 200 {token, refresh_token, expires_in}.
The presented token dies on use. Replaying an already-rotated token returns 401 token_reuse and
revokes the whole rotation family descended from that login. On a managed tenant this route returns
403 managed_session — refresh tokens do not exist there.
POST /auth/logout — {"refresh_token": "…"} → 200 {"ok": true}. An absent or empty
refresh_token also returns 200 {"ok": true}.
Other statuses on both: 400 bad_request / no_tenant · 401 invalid_token · 403 hook_denied ·
500 engine_unavailable, lookup_failed, rotate_failed, issue_failed, sign_failed.
Magic link
Section titled “Magic link”| Route | Body | Response |
|---|---|---|
POST /auth/magic/request | {"email": "…"} | 200 {"ok": true} unconditionally — the same answer for an unknown address |
POST /auth/magic/verify | {"email": "…", "code": "…"} | the login response, or the mfa_required shape |
Codes are delivered through the configured notification backend. With no backend configured the code
is dropped and the request still returns 200.
Statuses: 400 bad_request / missing_fields / no_tenant · 401 invalid_magic (unknown, expired
or already consumed) / invalid_token · 500 engine_unavailable, code_failed, persist_failed,
delete_failed, mfa_lookup_failed, sign_failed, issue_failed.
Passkeys
Section titled “Passkeys”Four routes are mounted and every one of them answers 503 webauthn_disabled. The deployable binary
wires no WebAuthn provider and there is no configuration key that turns one on.
| Route | Body | Response when enabled |
|---|---|---|
POST /auth/webauthn/register/begin | {"name": "laptop"} | {session_id, options} — a CredentialCreation object |
POST /auth/webauthn/register/finish | {"session_id": "…", "response": {…}} | {"ok": true} |
POST /auth/webauthn/assert/begin | {"email": "…"} | {session_id, options} — a CredentialAssertion object |
POST /auth/webauthn/assert/finish | {"session_id": "…", "response": {…}} | the login response |
The register routes require an authenticated principal; the assert routes do not.
Delegated (OAuth) login
Section titled “Delegated (OAuth) login”Two routes are mounted and every one of them answers 404 unknown_provider. The deployable binary
never wires a provider. Declaring a provider in the block’s definitions wires no credentials — a
declaration is a name gate only, and nothing reads its type or template at request time.
| Route | Request | Response when a provider exists |
|---|---|---|
GET /auth/oauth/:provider/start | provider in the path | 302 Found with Location set to the provider’s authorization URL (PKCE S256) |
GET /auth/oauth/:provider/callback | query code, state, or error | the login response, or the mfa_required shape |
Statuses: 400 no_tenant / missing_callback · 401 provider_error, invalid_state,
exchange_failed, invalid_token · 403 hook_denied · 404 unknown_provider ·
500 engine_unavailable, state_failed, persist_failed, userinfo_failed, no_email,
user_provision_failed.
Multi-factor
Section titled “Multi-factor”TOTP is the only method. methods in the mfa_required response is always ["totp"].
| Method + path | Auth | Body | Response |
|---|---|---|---|
POST /auth/mfa/totp/setup | Bearer | none | 200 {"secret": "…", "otpauth_url": "otpauth://…"} |
POST /auth/mfa/totp/enable | Bearer | {"code": "…"} | 200 {"ok": true} |
POST /auth/mfa/totp/disable | Bearer | {"code": "…"} | 200 {"ok": true}, also when nothing was enrolled |
POST /auth/mfa/verify | none — mfa_token is the credential | {"mfa_token": "…", "code": "…"} | the login response |
Disable requires a currently valid code, so a hijacked session alone cannot strip MFA. A code is single-use inside its step window.
| Status | Code |
|---|---|
| 400 | missing_fields, no_tenant, no_pending_setup |
| 401 | auth_required, invalid_code, code_reused, invalid_mfa_token, mfa_not_enabled, unknown_realm |
| 409 | already_enabled |
| 500 | engine_unavailable, lookup_failed, persist_failed, user_lookup_failed, sign_failed, issue_failed |
An MFA-pending token is rejected on every protected route with 401 invalid_token.
Account and credentials
Section titled “Account and credentials”Password reset
Section titled “Password reset”| Route | Body | Response |
|---|---|---|
POST /auth/password/reset/request | {"email": "…"} | 200 {"ok": true} unconditionally |
POST /auth/password/reset/confirm | {"email": "…", "code": "…", "new_password": "…"} | 200 {"ok": true} |
Statuses: 400 bad_request / missing_fields / no_tenant · 401 invalid_reset_token ·
403 hook_denied · 500 engine_unavailable, code_failed, persist_failed, update_failed.
API keys
Section titled “API keys”POST /auth/apikey — Bearer or ApiKey; a resolved principal is required.
{ "service": "reporting", "capabilities": { "scope": ["acme:shop:prod:*"] }, "expires_in_days": 90 }service defaults to default; expires_in_days defaults to 365. Requested capabilities (scope
grants, roles, is_superadmin) are checked against the issuer’s own authority.
Response is 200 — not 201:
{ "id": "…", "key": "<id>.<base64url-secret>", "expires_at": "…", "capabilities": { "…": "…" } }key is returned once and stored hashed. Present it later as Authorization: ApiKey <id>.<secret>.
DELETE /auth/apikey/:id — Bearer or ApiKey → 200 {"ok": true}. The :id is the part before the
dot in the wire key.
Statuses: 400 bad_request / missing_id / no_tenant · 401 auth_required ·
403 scope_exceeds_authority / hook_denied · 500 engine_unavailable, generate_failed,
persist_failed.
Service token exchange
Section titled “Service token exchange”POST /auth/token with Authorization: ApiKey <key>. A Bearer is refused with 401 apikey_required.
No body; the optional query parameter ?audience=<svc> sets the token’s aud.
{ "token": "v4.public.…", "token_type": "Bearer", "expires_in": 900 }Only a key whose capabilities carry a scope list can be exchanged; an ordinary user key returns
403 not_a_service_key. 401 apikey_invalid is opaque — it never distinguishes an unknown key from a
bad secret from an expired one, and a missing or unparseable expiry is treated as expired.
GET /.well-known/jwks.json — no credential, but the CPET headers are required because key sets are
per-tenant. One tenant’s document never contains another’s keys, and it lists inactive keys alongside
active ones so tokens signed before a rotation keep verifying.
{ "keys": [ { "kid": "…", "kty": "OKP", "crv": "Ed25519", "x": "<base64url>", "alg": "EdDSA", "use": "sig" } ]}400 no_tenant without the headers; 500 jwks_failed on assembly error. There is no HTTP route that
rotates a signing key.
First-operator bootstrap
Section titled “First-operator bootstrap”POST /auth/bootstrap — no credential; gated by a shared secret from cluster config. Creates the
first operator in the control plane governing the request’s product and environment, not in the
request’s own tenant.
{ "token": "<shared secret>", "email": "…", "password": "…", "firstname": "…", "lastname": "…", "mobile": "…" }→ 200 {"ok": true, "id": "U01K…", "email": "…"}. The created operator carries the root role.
403 already_bootstrapped is one envelope for three causes — the secret is unset, the supplied token
is wrong, or an operator already exists. Other statuses: 400 bad_request / missing_fields /
no_tenant · 403 hook_denied · 500 persist_failed · 503 superadmin_not_deployed.
curl -s localhost:5030/auth/bootstrap -H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' -d '{"token":"…","email":"[email protected]","password":"…","firstname":"Admin","lastname":"User"}'Managed sessions
Section titled “Managed sessions”Four mesh-internal routes. id is the control-plane row id; the opaque session_id credential is
returned only at login and never by enumeration.
| Method + path | Body | Response |
|---|---|---|
POST /internal/session/exchange | {"session_id": "…", "audience": "…"} | 200 {token, format, expires_at, cpet} — format is v4.public |
POST /internal/session/revoke | {"id": "…", "reason": "admin_revoke"} | 204 No Content |
POST /internal/session/rotate | {"id": "…", "aal": 2} | 200 {session_id, absolute_expiry} — aal: 0 leaves the level unchanged |
GET /internal/session/list?principal=<id> | none | 200 {"sessions": [{id, principal_id, cpet, status, aal, auth_method, user_agent_label, created_at, last_seen_at}]} |
Statuses: 400 bad_request · 401 invalid_session (identical for revoked, expired and unknown) ·
404 unknown_session on rotate · 500 exchange_failed, revoke_failed, rotate_failed,
list_failed.
Entity CRUD
Section titled “Entity CRUD”Every entity in the tenant’s composed schema is served generically. Authorization is the entity’s own
access: rules plus row-level security — see authorization.
| Method + path | Auth | Purpose |
|---|---|---|
GET /rest/:entity | Bearer or ApiKey | List → {"data": […], "count": n} |
POST /rest/:entity | Bearer | Create → 201 |
POST /rest/:entity/search | Bearer | Structured filter in the body |
GET /rest/:entity/id/:id | Bearer | Read one |
PUT /rest/:entity | Bearer | Replace, id in the body |
PUT /rest/:entity/id/:id | Bearer | Replace, id in the path |
PATCH /rest/:entity | Bearer | Partial update, id in the body |
PATCH /rest/:entity/id/:id | Bearer | Partial update, id in the path |
DELETE /rest/:entity/id/:id | Bearer | Delete — soft or hard per the entity’s traits |
POST /rest/:entity/id/:id/restore | Bearer | Restore a soft-deleted row |
POST /rest | Bearer | Bulk envelope, default action create |
PUT /rest | Bearer | Bulk envelope, default action update |
PATCH /rest | Bearer or ApiKey | Bulk envelope, default action update |
DELETE /rest | Bearer | Bulk envelope, default action delete |
The single-row path is /rest/:entity/id/:id. /rest/:entity/:id is not registered.
A bulk body is an operations envelope. data is a single-key object keyed by entity name, whose value
is one row or an array of rows. An operation that omits action inherits the default for the HTTP
verb; the explicit values are create, update, delete, upsert and restore.
{ "operations": [ { "action": "update", "data": { "role": { "id": "01HVK…", "displayname": "Auditor" } } } ]}{ "operations": [ { "action": "create", "entity": "users", "data": [ { "id": "U01K…" } ], "affected_rows": 1 } ], "stats": { "operations_run": 2, "operations_ok": 2, "operations_fail": 0, "duration_ms": 14 }}The whole batch runs in one transaction — any single failure rolls all of it back. A failed operation
carries error and error_code. Envelope-level failures are 400 v2.bad_json,
400 v2.empty_envelope and 400 v2.body_read.
List responses carry data and count, plus total, stats, meta, states, allowed_actions
and page blocks when the corresponding query parameters ask for them. ?datastore=<name> selects a
datastore on any entity route; ?expression= applies a jq expression to the response and returns
400 v2.bad_expression if it fails to compile.
Statuses: 401 plain-text Unauthorized · 403 v2.no_tenant · 404 v2.entity_not_found ·
400/403/500 v2.create_failed, v2.update_failed, v2.delete_failed, v2.restore_failed,
v2.query_failed — an access-rule denial surfaces through this mapping ·
500 v2.engine_unavailable.
Anonymous mirror
Section titled “Anonymous mirror”Thirteen routes under /anon/rest mirror the authenticated surface with no credential. An entity is
reachable there only if its own access: rules admit an anonymous caller.
| Method + path |
|---|
POST · PUT · PATCH · DELETE /anon/rest |
GET /anon/rest/:entity |
GET /anon/rest/:entity/id/:id |
POST /anon/rest/:entity |
PUT /anon/rest/:entity · PUT /anon/rest/:entity/id/:id |
PATCH /anon/rest/:entity · PATCH /anon/rest/:entity/id/:id |
DELETE /anon/rest/:entity/id/:id |
POST /anon/rest/:entity/search |
Domain aliases
Section titled “Domain aliases”Exactly two alias mounts ship, seven routes each. They address a row as <base>/:id — not
<base>/id/:id like the generic surface — and they register no restore route.
| Method + path | Purpose |
|---|---|
GET /admin/user · GET /admin/role | List |
POST /admin/user · POST /admin/role | Create → 201 |
POST /admin/user/search · POST /admin/role/search | Structured filter in the body |
GET /admin/user/:id · GET /admin/role/:id | Read one |
PATCH /admin/user/:id · PATCH /admin/role/:id | Partial update |
PUT /admin/user/:id · PUT /admin/role/:id | Replace |
DELETE /admin/user/:id · DELETE /admin/role/:id | Delete |
/admin/user writes go through the password-hashing hook, and password is redacted on every read.
Role assignment happens here, on the user row’s properties.roles; /admin/role defines role
rows and assigns nothing.
curl -s localhost:5030/admin/user -H "Authorization: Bearer $TOKEN" $H -d '{"email":"[email protected]","firstname":"Ada","lastname":"Lovelace","password":"…","active":true,"properties":{"roles":["admin","auditor"]}}'Schema introspection and exports
Section titled “Schema introspection and exports”All Bearer-gated, no role gate.
| Route | Returns |
|---|---|
GET /schema | Composed schema: name, version, default datastore, and the name sets for datastores, entities, relations, types, enums, traits, embeddables, stateflows, views, functions |
GET /schema/entities | [{name, fields, relations}] |
GET /schema/entities/:entity | Detail for one entity |
GET /schema/graphql | GraphQL SDL as text/plain |
GET /schema/openapi.json | OpenAPI 3.1 document |
GET /schema/jsonschema.json | JSON Schema 2020-12, full bundle |
GET /schema/jsonschema/:entity | JSON Schema for one entity |
GET /schema/zod.ts | Zod TypeScript bundle |
GET /schema/zod/:entity | Zod TypeScript for one entity |
GET /schema/cue | CUE bundle |
GET /schema/cue/:entity | CUE for one entity |
DELETE /cache | Drop every cached resource for the calling tenant → {"cleared": true, "tenant": "<cpet>"} |
GET /schema/graphql returns the SDL only. There is no GraphQL execution endpoint.
Statuses: 403 v2.no_tenant · 500 v2.engine_unavailable, v2.export_render_failed,
v2.cache_clear_failed.
Data-admin plane
Section titled “Data-admin plane”Data-lifecycle operations against the caller’s own tenant, with separation of duties: a tenant admin
generates and reviews, an operator executes. RequireAdmin accepts admin or superadmin;
RequireSuperadmin accepts only superadmin. Role matching is case-insensitive. Every route accepts
?datastore=<name>.
| Method + path | Gate | Request → response |
|---|---|---|
POST /admin/data/plans | admin | {datastore, allow_drops} → {plan_id, source, max_risk, warnings[], refusals[], steps[]} |
GET /admin/data/plans/:id | admin | → {tenant, plan, steps[]}; 404 when unknown |
POST /admin/data/plans/:id/apply | superadmin | {datastore, confirm_max_risk, dry_run} → {plan_id, status, steps_applied, steps_failed, steps_skipped}; 207 on partial completion |
GET /admin/data/applied | admin | ?plan_id=<id> required → {tenant, plan_id, steps[]}; 400 without it |
GET /admin/data/seeds | admin | → {tenant, datastore, seeds[]} with name, entity, source, strategy, status, applied_at, applied_checksum, error |
POST /admin/data/seeds/runs | superadmin | {datastore, names[], environment, dry_run, force} → {plan_id, dry_run, applied_count, skipped_count, failed_count, outcomes[]}; 207 when any failed |
GET /admin/data/locks | admin | ?prefix, ?op_kind, ?include_expired → {locks: [{id, resource, holder_id, op_kind, acquired_at, expires_at, hostname, plan_id, notes, is_expired, seconds_to_expiry}]} |
DELETE /admin/data/locks/:resource | superadmin | → release confirmation |
GET /admin/data/retention/preview | admin | ?entity → preview payload |
GET /admin/data/retention/archives | admin | → {archives: [{name, type, datastore, suffix, settings}]} |
GET /admin/data/retention/entities | admin | → per-entity policy and PII fields |
GET /admin/data/retention/handoff/pending | admin | → pending ETL handoffs |
POST /admin/data/retention/runs | superadmin | {datastore, entities[], phases[], dry_run, batch_size, max_rows_per_pass, grace_hours} → run result |
POST /admin/data/retention/handoff/claim | superadmin | claim body → claim result |
POST /admin/data/retention/handoff/confirm | superadmin | confirm body → confirm result |
GET /admin/data/actions | admin | → action catalogue; SQL bodies are omitted |
GET /admin/data/materialize/pending | admin | → {tasks: [{id, target_entity, target_field, target_row_id, source_entity, source_op, enqueued_at, attempts}]} |
GET /admin/data/materialize/stats | admin | → queue statistics |
POST /admin/data/materialize/drain | superadmin | drain body → drain result |
GET /admin/data/references/:entity | admin | → reference map |
GET /admin/data/hashes | admin | → query-hash list |
confirm_max_risk is low, medium or high; omitted means low, and any other value is a 400.
Apply refuses a plan whose risk exceeds what was confirmed. A non-empty refusals list on generate is
the signal not to apply, even though the status is 200.
Seed runs execute synchronously inside the HTTP request. Retention and materialize operations are gated but unsupported on this service’s schema.
Statuses: 401 missing tenant identity (no code field on this surface) ·
403 v2.requires_admin, v2.requires_superadmin.
Superadmin control plane
Section titled “Superadmin control plane”The control plane is the reserved tenant <customer>:<product>:<env>:superadmin — one per product and
environment. Operators reach these routes with the plane’s own CPET headers and a token minted by
POST /auth/login with "realm": "superadmin".
Impersonation
Section titled “Impersonation”POST /superadmin/impersonate — the only sanctioned path from an operator to tenant data. Body capped
at 8 KiB.
{ "tenant": "acme:shop:prod:eu1", "user_id": "U01K…", "reason": "support ticket 4211", "read_write": false }{ "token": "v4.public.…", "session_id": "…", "expires_in": 900, "mode": "readonly", "acting_as": "U01K…", "tenant": "acme:shop:prod:eu1"}mode is the literal string readonly or readwrite. reason is mandatory. Omitting read_write
yields read-only and requires the exact role impersonate-readonly; "read_write": true requires the
exact role impersonate. Neither role implies the other, and root implies neither. The returned
token is the target user’s own, signed by the target tenant’s key, carrying an act claim naming the
operator. A managed session is force-created so the subject and their admin can list and revoke it.
| Status | Code |
|---|---|
| 400 | bad_request, reason_required, no_tenant |
| 401 | unauthorized |
| 403 | not_an_operator, role_required, not_granted, impersonation_not_allowed |
| 404 | no_such_user |
| 500 | lookup_failed, sign_failed, session_failed |
| 503 | tenant_unavailable |
not_granted is returned identically for a tenant the operator has no grant on and for a tenant that
does not exist, so operators cannot enumerate tenants by probing.
Tenant registry
Section titled “Tenant registry”| Method + path | Request → response |
|---|---|
POST /superadmin/registry/tenants | {"slug": "acme:iam:prod:eu1", "displayname": "Acme EU", "domain": "acme.io"} → 201 {id, slug} |
GET /superadmin/registry/tenants | → 200 {"data": [{id, slug, displayname, active}]}, hard limit 500 rows |
POST /superadmin/registry/tenants/provision | {"slug", "datastore", "admin_email", "admin_password", "admin_firstname", "admin_lastname"} → 200 {cpet, bootstrap_steps_applied, admin_id, admin_existed, activated} |
Registration records a row; provisioning creates the schema, tables and migration ledger, seeds the
tenant admin and activates. Provisioning is idempotent. The database itself must already exist — the
service creates the schema, never the database. slug may be a short name or a full CPET; the
reserved names superadmin and shared are refused in any segment.
Statuses: 400 bad_request, reserved_or_invalid_name · 403 v2.requires_superadmin ·
500 create_failed, list_failed, provision_failed · 503 superadmin_not_deployed.
There is no tenant-deletion route on this family. Removing a registry row is
DELETE /rest/tenant/id/:id under the plane’s headers, requires the root role, and deletes the
registry record only — no schema, tables or data are dropped. Deactivating is a PATCH setting
active to false.
Control-plane entity administration
Section titled “Control-plane entity administration”The operator roster and operator grants have no dedicated routes. They are administered through the
generic entity surface under the plane’s headers, which works because those entities are split out of
every tenant’s schema — the same paths under a tenant’s headers return 404 v2.entity_not_found.
| Route | Purpose |
|---|---|
GET · POST /rest/superadmin | List and create operators |
GET · POST /rest/superadmin_grant | List and create operator→tenant grants |
PATCH /rest/superadmin_grant/id/:id | Revoke by setting active to false — grants are re-read per request, so revocation is immediate |
GET /rest/tenant | Registry rows with full entity query options |
DELETE /rest/tenant/id/:id | Delete a registry row (root only) |
{ "superadmin_id": "U01K…", "tenant_id": "01HVK…", "reason": "EU on-call, ticket OPS-233", "active": true }tenant_id is the registry row id, not a CPET. Both ids are final on a grant — revoke and
re-grant rather than editing.
Cross-tenant operations
Section titled “Cross-tenant operations”Twenty-three routes mirror the data-admin plane at another tenant. All require superadmin.
| Method + path | Purpose |
|---|---|
GET /superadmin/tenant | {"tenants": ["acme:shop:prod:eu1", …], "count": n} — tenants this process has built resources for |
DELETE /superadmin/tenant/:tenant/cache | → {"invalidated": "<full cpet>"} |
POST /superadmin/tenant/:tenant/plans | Generate a plan |
GET /superadmin/tenant/:tenant/plans/:id | Read a plan |
POST /superadmin/tenant/:tenant/plans/:id/apply | Execute a plan |
GET /superadmin/tenant/:tenant/applied | Ledger for one plan (?plan_id required) |
POST /superadmin/tenant/:tenant/seeds/runs | Run seeds |
GET /superadmin/tenant/:tenant/seeds | List seeds |
POST /superadmin/tenant/:tenant/retention/runs | Run retention |
GET /superadmin/tenant/:tenant/retention/preview | Preview retention |
POST /superadmin/tenant/:tenant/retention/handoff/claim | Claim an ETL batch |
POST /superadmin/tenant/:tenant/retention/handoff/confirm | Confirm an ETL batch |
GET /superadmin/tenant/:tenant/retention/handoff/pending | Pending ETL batches |
GET /superadmin/tenant/:tenant/retention/archives | Archive destinations |
GET /superadmin/tenant/:tenant/retention/entities | Retention policy per entity |
GET /superadmin/tenant/:tenant/locks | List locks |
DELETE /superadmin/tenant/:tenant/locks/:resource | Force-release a lock |
GET /superadmin/tenant/:tenant/actions | Action catalogue |
GET /superadmin/tenant/:tenant/materialize/pending | Pending refresh tasks |
GET /superadmin/tenant/:tenant/materialize/stats | Refresh queue statistics |
POST /superadmin/tenant/:tenant/materialize/drain | Force-drain the refresh queue |
GET /superadmin/tenant/:tenant/references/:entity | Reference map |
GET /superadmin/tenant/:tenant/hashes | Query hashes |
Request and response bodies are identical to the /admin/data/* route each one mirrors.
GET /superadmin/tenant is process state, not the registry: a tenant that is registered and
configured but has served no request is absent. For the authoritative list use
GET /superadmin/registry/tenants.
Health and readiness
Section titled “Health and readiness”| Method + path | Auth | Purpose |
|---|---|---|
GET /ready | none | Readiness probe |
GET /health | none | Liveness probe |
Both are mounted by the service bootstrap before any other route, and the tenant middleware short-circuits both paths — they need no CPET headers and no credential. Both report healthy and ready from server start.
Routes that are not mounted
Section titled “Routes that are not mounted”Earlier documentation, the checked-in functional test suite and the Go client each name routes the
service does not register. Every one of these returns 404.
| Path | Use instead |
|---|---|
/admin/realm | Realms are configuration, not rows — see authentication |
/admin/tenant | /superadmin/registry/tenants and /rest/tenant under the plane’s headers |
/superadmin/login | POST /auth/login with "realm": "superadmin" |
/auth/login/password, /auth/validate, /auth/session/refresh, /auth/jwt/refresh, /auth/updatepassword | POST /auth/login, /auth/refresh, /auth/password/reset/confirm |
/auth/realms, /auth/providers | No introspection route exists for either |
| any OTP route | No OTP login provider ships — not over SMS, email or chat, and no route accepts a one-time code except MFA, magic link and password reset |
/admin/users, /tenant/new, /authorize, /user/signup | /admin/user, /superadmin/registry/tenants/provision |
/rest/:entity/:id | /rest/:entity/id/:id |
/rest/:entity/action/:action | Declared actions are catalogued at /admin/data/actions; there is no execution route |
/schema/:entity | /schema/entities/:entity |
/metrics | Metrics are pushed through the telemetry provider; no HTTP route is registered |
The login body is {identity, password}. {email, password} is not accepted.