Skip to content
Talk to our solutions team

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 control plane is the reserved CPET whose tenant slot is the literal name superadmin:

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

PropertyValue
Address<customer>:<product>:<env>:superadmin
Scaleone plane per customer + product + environment
PostgreSQL schemasuperadmin (hardcoded)
Signing keysthe plane’s own keyring — the plane’s JWKS carries no tenant’s keys
Entitiessuperadmin (operator roster), superadmin_grant, tenant (registry), tenant_eventlog
Deploymenta 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.

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.

Terminal window
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"}'
{"ok": true, "id": "U01…", "email": "[email protected]"}

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.

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.

Terminal window
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":"[email protected]","password":"…","realm":"superadmin"}'
{
"token": "v4.public.…",
"refresh_token": "",
"expires_in": 900,
"user": {"id": "U01…", "email": "[email protected]", "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.

FieldValue
identityAn operator identifier: email (the default identity_type) or mobile. Both unique, both nolog
identity_typeOptional. Anything not declared on the superadmin entity → 400 bad_identity_type
realmsuperadmin, 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.

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.

RoleAuthority
superadminPlane membership marker. Route reach only; no data access
rootEverything on every plane entity, including deletes; an implicit grant over tenants
superadmin-adminRoster read/create/update (not delete); full grant CRUD
tenant-provisionRegistry read/create/update; grant read
tenant-readRegistry read; grant read — a read-only auditor
impersonate-readonlyPOST /superadmin/impersonate with read_write false
impersonatePOST /superadmin/impersonate with read_write true
iam-serviceThe 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.

Entityreadcreateupdatedeleteunmask
superadminiam-service, root, superadmin-adminsamesameiam-service, rootiam-service only
superadmin_grantiam-service, root, superadmin-admin, tenant-read, tenant-provisioniam-service, root, superadmin-adminsame as createsame as create
tenantiam-service, root, tenant-read, tenant-provisioniam-service, root, tenant-provisionsame as createiam-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.

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.

FieldTypeNotes
superadmin_idstring, requiredThe operator’s superadmin row id, in this plane. final
tenant_idstring, requiredA tenant registry row id, not a CPET — ids survive a rename. final
reasonstring, max 500Why this operator holds this grant
activeboolean, default trueSoft 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.

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 returnsEffect
""Unrestricted — iam-service and root only
A list of idsid IN (…) over the granted tenants
An empty listid IN ("") — matches no row. “Granted nothing” means “sees nothing”
Anything not a string or listDenied. It does not fall through to unrestricted
A grant-load failureAn 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.

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.

Everything an operator token reaches, and what gates it.

SurfaceGate
POST /auth/login (realm: superadmin), /auth/refresh, /auth/logoutNone — this is the auth surface. auth_login rate limit on login
POST /auth/bootstrapbootstrap.token, constant-time; self-closing
POST /superadmin/impersonateVerified operator principal, exact impersonation role, grant, per-user consent — all in the handler, no route-level role gate
POST/GET /superadmin/registry/tenants, POST …/provisionPlatform token wrapper + RequireSuperadmin (the membership marker) only
GET /superadmin/tenantPlatform token wrapper + RequireSuperadmin
/superadmin/tenant/:tenant/* — cache bust, seeds, applied migrations, DDL plans and apply, locks and force-release, actions, references, query hashesPlatform 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.

MethodPathBehaviour
POST/superadmin/registry/tenantsRegister a tenant. Sets active: true, markedforseed: true. 201
GET/superadmin/registry/tenantsList, capped at 500, projecting id, slug, displayname, active
POST/superadmin/registry/tenants/provisionCreate schema and tables, seed the tenant admin, activate. Idempotent
Terminal window
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.

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.

RoleMintsImplied by
impersonate-readonlymode: readonlynothing
impersonatemode: readwritenothing

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.

GuardCovers
Reserved-name refusalsuperadmin / shared rejected in any CPET segment and any tenant short name, deployed or not
Plane derivationThe plane is a pure function of the request CPET; another customer’s or product’s plane is unaddressable
Keyring isolationThe plane signs with its own keys; its JWKS carries no tenant’s keys
Realm-derived markerPlane membership comes from the realm, never from row data
Role unionRow roles are unioned onto the marker, so granular privileges never cost an operator their membership
Deny-by-default plane entitiesA fresh operator holds the marker only, and every plane entity refuses them
Grant RLS on the registryPer-request grant load; empty grants match no row; non-string/non-list returns deny; a load failure is an error
Tenant pinToken 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 checkByte-for-byte; read-write does not imply read-only, and root implies neither
Grant check on impersonationNon-root operators need an active grant on the target tenant
Per-user consentusers.allow_impersonation, default false, structurally unwritable by an operator
Reserved act claimRefused at parse and skipped at mint; a tenant cannot inject or shadow an actor
Read-only floorChecked above every early return in the access gate; an unreadable mode falls closed to read-only
Operator attributionAudit changed_by is the operator, written into the tenant’s own schema
Credential separationunmask on the roster is iam-service-only; password is hashed and redacted
No enumeration oraclenot_granted is identical for “no grant” and “no such tenant”; already_bootstrapped covers three distinct causes; invalid_credentials covers three
Constant-time bootstrap compareThe bootstrap secret is compared without a timing signal, and an empty value disables the route

Each of these is a real gap in the shipping binary, not a hardening idea.

GapEffect
/superadmin/registry/* runs its engine calls as iam-serviceOperator 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 targetSlug 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 planeThe 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 segmentCustomer/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 gateThe 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 authNo 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 definedAn 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 limitThe route is registered without a profile
Impersonation proceeds with no session when no session service is wiredThe 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 sinkThe 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 fieldsThe 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-upOperator 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 authorityThe roster update rule admits root, and roles live in the row
A managed tenant’s impersonation still returns a bearer tokenOrdinary 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

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

FeatureState
Per-tenant operator rolesThe grant table is deliberately flat — no role column
Realm-aware RequireSuperadminThe 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 notificationNo notification call exists in the handler
Step-up before impersonationThe handler requires a role and a reason only
MFA and lockout for operator loginThe plane carries no MFA tables; locked is never read
The revised principal modelNo shipped code reads it. The live model is realm marker + row roles + grant rows

Control-plane codes. Impersonation’s own set is on Impersonation.

CodeHTTPMeaning
bad_request400Malformed JSON body
no_tenant400No tenancy on the request, so the plane cannot be resolved (401 from the token middleware)
bad_identity_type400identity_type is not a declared operator identifier
missing_fields400Bootstrap body missing token, email, password, firstname or lastname
reserved_or_invalid_name400Registry slug used a reserved scope name, or is not a valid CPET
invalid_credentials401Operator not found, no stored hash, or argon2 verify failed — one envelope for all three
tenant_mismatch401Token tenant claim does not equal the request tenant. This is the guard that refuses a plane token on a tenant surface
invalid_token401Verification failure; also covers the tenant mismatch in the shared middleware
token_expired401Outside the validity window. A token with no exp is invalid — every mint sets one
already_bootstrapped403Token unset, token mismatch, or an operator already exists
v2.requires_superadmin403The roles claim does not contain superadmin (case-insensitive; admin does not qualify)
v2.requires_admin403The /admin gate. superadmin passes it too
user_query_failed500Operator identity lookup failed against the plane engine
sign_failed500Could not mint the operator’s access token
issue_failed500Could not issue the operator refresh token
persist_failed500Bootstrap could not create the first operator
create_failed / list_failed500Registry create or list failed at the engine
provision_failed500Provisioning failed. Operator-fixable causes remap to 400 bad_request, an unresolvable plane to 503 superadmin_not_deployed
superadmin_not_deployed503The plane’s engine could not be resolved for this product and environment

The full external table is on Errors.

  • Impersonation — the one door into tenant data, end to end
  • Tokens — claims, reserved claims, PASETO minting, managed sessions
  • Identity entitiesusers, session, and the tenant-side model
  • Operating — bootstrap, tenant hive config, migrations, event-hook wiring
  • Endpoint reference — every route with its wire shape