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
Section titled “The plane”The control plane is the reserved CPET whose tenant slot is the literal name superadmin:
<customer>:<product>:<env>:superadminThe 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 |
superadmin and shared are reserved scope names. Both 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: superadmin and final: true, so they sit in the reserved schema and are sealed against product and tenant schema layering. See Identity entities for the tenant-side model.
Getting an operator token
Section titled “Getting an operator token”Bootstrap the first operator
Section titled “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.
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":"[email protected]","password":"…","firstname":"Root","lastname":"Operator"}'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.
Operator login
Section titled “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.
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' \{ "token": "v4.public.…", "refresh_token": "…", "expires_in": 900,}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.
Membership is not authority
Section titled “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
Section titled “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.
Grants: which tenants
Section titled “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
Section titled “An ungranted tenant is invisible”The tenant entity carries a row-level-security rule bound to user.grants:
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.
A plane token is not a master key
Section titled “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-SuperAdminrequest 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.
The operator surfaces
Section titled “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 v2.requires_superadmin and 403 v2.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
Section titled “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 |
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"}'{"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.
The one door into tenant data
Section titled “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.
Guard inventory
Section titled “Guard inventory”Enforced
Section titled “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 changed_by is the operator, 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 |
Absent or weak
Section titled “Absent or weak”Each of these is a real gap in the shipping binary, not a hardening idea.
| Gap | Effect |
|---|---|
/superadmin/registry/* runs its engine calls as iam-service | Operator roles and the grant RLS are both bypassed. The only surviving gate is the membership marker, so an operator with no roles and no grants can list every tenant in the plane, register new ones, and provision an existing one — seeding a tenant admin with a password of their choosing |
| No customer:product:env constraint on a registry slug or a provision target | Slug validation rejects only reserved names; it never compares the slug’s segments to the caller’s. An operator of one product can register another customer’s CPET in their own registry and provision it, if that CPET resolves in the deployment’s tenant hive |
root impersonation is not bounded to its own plane | The grant check returns immediately for root, before any test that the requested CPET belongs to this plane. A root operator can name any CPET the deployment resolves |
| The grant check compares only the tenant segment | Customer/product/env are discarded when resolving the registry row. With bare slugs, a grant on main also satisfies a request for another product’s main; with full-CPET slugs the lookup misses and every non-root impersonation returns not_granted |
A tenant user whose row declares superadmin passes the platform gate | The role union has no denylist, and the gate matches the role string case-insensitively without consulting the realm. A tenant admin who can update users rows can write properties.roles: ["superadmin"] onto a tenant user, and that token satisfies every RequireSuperadmin route. Never assign superadmin as a tenant role |
/internal/session/exchange, /revoke, /rotate, /list have no application-layer auth | No token wrapper, no role gate, and no check tying a session id or the principal query parameter to the caller. Mesh mTLS is a deployment property, not a code guard — never expose the port beyond the mesh |
The auth_impersonate rate-limit profile is not defined | An unknown profile is a silent pass-through, so the impersonation endpoint is unlimited. The limiter also fails open on store errors and skips bucketing when the request carries no tenant key |
/auth/bootstrap carries no rate limit | The route is registered without a profile |
| Impersonation proceeds with no session when no session service is wired | The force-managed session row is created only if the session service was injected at boot. Without it the impersonation still succeeds, producing exactly the unlistable, unrevocable session the rule exists to prevent |
| The auth event bus has no default sink | The shipped pointcut file registers no hooks, and the tenant_eventlog / user_eventlog entities are never written. Without a wired hook the only control-plane record of an impersonation start is a process log line. Registry and provision operations emit no auth event at all |
| Session enumeration omits the impersonator fields | The row carries impersonator_id / _reason / _mode, but the listing projects only id, principal, CPET, status, AAL, auth method, user-agent label and timestamps |
| No MFA, no lockout, no step-up | Operator login reads neither MFA nor the locked field. Impersonation demands a role and a reason, with no re-authentication, and notifies neither the impersonated user nor their tenant administrator |
root may widen its own authority | The roster update rule admits root, and roles live in the row |
| A managed tenant’s impersonation still returns a bearer token | Ordinary login on a managed preset returns only the opaque session reference. The impersonation response returns the session id and the full token, whatever the preset |
Not implemented
Section titled “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 |
Realm-aware RequireSuperadmin | The gate reads the roles claim only; the realm check is deferred |
Grant enforcement on /superadmin/registry/* | Those handlers run as iam-service |
Application-layer auth on /internal/session/* | The registrations add no wrapper |
| Impersonation start notification | No notification call exists in the handler |
| Step-up before impersonation | The handler requires a role and a reason only |
| MFA and lockout for operator login | The plane carries no MFA tables; locked is never read |
| The revised principal model | No shipped code reads it. The live model is realm marker + row roles + grant rows |
Errors
Section titled “Errors”Control-plane codes. Impersonation’s own set is on Impersonation.
| 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 |
v2.requires_superadmin | 403 | The roles claim does not contain superadmin (case-insensitive; admin does not qualify) |
v2.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.
Continue with
Section titled “Continue with”- Impersonation — the one door into tenant data, end to end
- Tokens — claims, reserved claims, PASETO minting, managed sessions
- Identity entities —
users,session, and the tenant-side model - Operating — bootstrap, tenant hive config, migrations, event-hook wiring
- Endpoint reference — every route with its wire shape