Skip to content
Talk to our solutions team

Impersonation

POST /superadmin/impersonate is the only sanctioned path from an operator identity to a customer’s data. An operator’s own control-plane token is refused on every tenant surface, and no tenant entity grants operator roles, so an operator holding root plus a grant on every tenant in their plane still reads zero rows of user data. They come through this endpoint or not at all.

What the endpoint returns is the target user’s own token, minted with the target tenant’s keyring, carrying the target user’s sub, realm and roles — plus one extra claim, act, naming the operator and the mode. act can only narrow: it never adds authority, and in read-only mode it removes the ability to mutate anything.

The handler checks these in order and stops at the first failure.

#CheckFailure
1Body parses (8 KiB cap) and carries tenant + user_id400 bad_request
2Body carries a non-empty reason400 reason_required
3A verified principal with a non-empty subject is on the request401 unauthorized
4The request carries tenancy context400 no_tenant
5The token’s tenant claim equals this plane’s CPET (<customer>:<product>:<env>:superadmin)403 not_an_operator
6The operator holds the exact role for the requested mode403 role_required
7tenant parses as a full customer:product:env:tenant key400 bad_tenant
8The target’s customer, product and environment equal the caller’s403 not_granted
9The target’s tenant slot is not a reserved scope name403 not_granted
10The operator holds an active grant on the target tenant (root exempt)403 not_granted
11The target tenant’s datastore resolves503 tenant_unavailable
12An active user with that id exists in the target tenant404 no_such_user (500 lookup_failed on a query error)
13That user’s allow_impersonation is true403 impersonation_not_allowed
14The token mints500 sign_failed
15The force-managed session row is created500 session_failed

Check 5 pins the caller to this plane; checks 8 and 9 pin the target. Both halves are needed and both are enforced: the plane a request can reach is a pure function of the CPET it arrived with, and a plane is exactly one customer + product + environment, so an operator can only ever impersonate inside the product and environment they authenticated against. Check 9 refuses a reserved scope name in the tenant slot, so impersonation cannot be aimed at another control plane — or at the operator’s own, which holds no impersonable users.

RoleMintsImplied by
impersonate-readonlymode: readonlynothing
impersonatemode: readwrite ("read_write": true)nothing

Neither is implied by root, by superadmin-admin, or by the other. Role matching in this handler is byte-for-byte and case-sensitive: impersonate-readonly can never satisfy a check for impersonate, and Impersonate satisfies neither. The same exactness applies to the root grant exemption — Root does not qualify. Publish and grant these names lowercase-exact. See Superadmin for the operator role vocabulary and the grant table.

A grant names a tenant by registry row id, so the handler resolves the requested CPET’s tenant segment to a registry row first, then looks for an active superadmin_grant row for (operator, tenant). Grants are read live, so flipping active to false takes effect on the next impersonation attempt rather than at token expiry.

users.allow_impersonation is a boolean on the target tenant’s users entity, default false. No user is impersonable until their tenant opts them in, one user at a time.

An operator cannot set it. users create and update require "admin" or "iam-service" in the caller’s roles, and an operator’s plane token carries neither. The consent is the customer’s to give.

Terminal window
curl -s localhost:5030/superadmin/impersonate \
-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 '{"tenant":"acme:shop:prod:main","user_id":"U01ALICE","reason":"ticket-1234: cannot save invoices"}'
FieldTypeRequiredMeaning
tenantstringyesFull CPET to act in. Must share the caller’s customer, product and environment, and be granted (or the caller holds root)
user_idstringyesThe target user’s id in that tenant
reasonstringyesWhy. The only part of the record that says why, and it cannot be reconstructed afterwards
read_writebooleannoAbsent or false → read-only. true requires the impersonate role

The request goes under the plane’s tenancy headers with the operator’s Bearer token. The tenant to act in comes from the body, never from the headers. All three strings are trimmed, so a whitespace-only value counts as absent.

{
"token": "v4.public.…",
"session_id": "01J…",
"expires_in": 900,
"mode": "readonly",
"acting_as": "U01ALICE",
"tenant": "acme:shop:prod:main"
}
FieldMeaning
tokenThe target user’s PASETO v4.public token, signed by the target tenant’s keyring
session_idThe opaque managed-session credential. Omitted when no session service is wired
expires_inSeconds, from the target tenant’s composed token policy (15 minutes by default) — impersonation applies no TTL override
modereadonly or readwrite
acting_asThe target user’s id
tenantThe target tenant’s CPET

Use the returned token with the target tenant’s headers (X-Tenant: main above), not the plane’s. It is that tenant’s token and verifies against that tenant’s keyring.

Errors use the uniform envelope {"error": "…", "code": "…"}.

It differs in exactly one claim.

ClaimImpersonation tokenOrdinary login token
issthe service issuersame
subthe target user’s idthe logged-in user’s id
tenantthe target tenant’s CPETthe caller’s CPET
realmthe target user’s realmthe caller’s realm
rolesthe target user’s properties.rolesthe caller’s roles
typeuseruser
iat / nbf / expfrom the target tenant’s token policysame
actpresent — see belowabsent
{ "act": { "sub": "U01OPERATOR", "realm": "superadmin", "mode": "readonly" } }

act is a reserved claim. A product or tenant may augment the claim set through token.claims / token.claims_from, but reserved names are refused at parse and skipped at mint, so a tenant can never inject or shadow an act. That matters less for authority — the actor claim only ever narrows — than for attribution: a forged act would frame a named operator in the audit trail. See Tokens.

The operator’s own roles are not carried. The impersonation token grants exactly the target user’s authority and no more, which is why every existing access rule keeps working without knowing impersonation exists.

The verifier projects act into an actor and fails closed on anything it cannot read.

Token containsActorEffective mode
{"act":{"sub":"U01OP","realm":"superadmin","mode":"readwrite"}}presentread-write
{"act":{"sub":"U01OP","realm":"superadmin","mode":"readonly"}}presentread-only
{"act":{"sub":"U01OP","realm":"superadmin"}} — mode absentpresentread-only
{"act":{"sub":"U01OP","mode":"READWRITE"}} — wrong casepresentread-only
{"act":"U01OP"} — not an objectpresent, fields emptyread-only
no act keynilnot an impersonation; no floor

Only the exact string readwrite yields read-write. A truncated claim or a version skew between minter and verifier costs an operator a write they must re-request; the reverse mistake would silently grant writes to a customer’s data under that customer’s own user id.

The read-only floor lives in the data layer’s access gate, checked first in AllowAction — above the “access engine is off” return, above “the entity declares no access block”, above “this action has no rule”.

ActionRefused under a read-only actor
createyes
updateyes
deleteyes
purgeyes
restoreyes
readno
unmaskno
decryptno

Matching is case-insensitive. The refusal reads:

accessgate: "update" on entity "invoice" is a mutation, but this request is a READ-ONLY impersonation (operator "U01OPERATOR" acting as user "U01ALICE")

This is a property of the credential, not an entity rule. No entity can opt out of it, a tenant-authored rule cannot forget it, and turning the rule engine off does not disable it — that switch rolls back the rule engine, not the meaning of the token.

Access rules and row-level security both see the actor as user.act, nil when the request is not an impersonation:

access:
actions:
read: { language: expr, expression: 'user.act == nil || !user.act.readonly' }
BindingValue
user.act.subthe operator’s control-plane id
user.act.realmsuperadmin
user.act.modethe raw claim value
user.act.readonlyprecomputed from the same predicate the engine floor uses

readonly is precomputed deliberately, so a rule and the floor can never disagree about what an unrecognised mode means. Rule syntax and the shared binding vocabulary are documented once on the Data API access-rules page.

RecordWhere it landsContent
Per-action audit rowsthe tenant’s schema, <entity>_auditchanged_by = the operator; the entity row’s own updatedby keeps the target user
Managed-session rowthe tenant’s schema, sessionimpersonator_id, impersonator_reason, impersonator_mode, auth_method: impersonation, realm
Auth eventthe auth event busimpersonator, plane, mode, reason, session_id
Process log lineservice logoperator, tenant, user, mode, session, reason

The audit pair reads “row updated under user X, by operator Y”, and it lands in the customer’s own schema where the customer can query it. An operator id in a tenant’s audit table is unambiguous: an operator can never write tenant data directly, so their id there can only mean an impersonation.

Not implemented: no notification is sent to the impersonated user or their tenant administrator when a session starts, and no step-up or re-authentication is demanded of the operator — the handler requires a role and a reason.

The impersonation session is force-managed: the row is created whatever the tenant’s session preset, because under a stateless preset it would not exist as a row at all, and a session nobody can list is a session nobody can revoke. It lives in the tenant’s schema, so the impersonated user and their administrator can see and kill it.

Terminal window
curl -s 'localhost:5030/internal/session/list?principal=U01ALICE' \
-H 'X-Customer: acme' -H 'X-Product: shop' \
-H 'X-Env: prod' -H 'X-Tenant: main'
Terminal window
curl -s localhost:5030/internal/session/revoke \
-H 'Content-Type: application/json' \
-H 'X-Customer: acme' -H 'X-Product: shop' \
-H 'X-Env: prod' -H 'X-Tenant: main' \
-d '{"id":"01J…","reason":"admin_revoke"}'

Revoke takes the control-plane id from the listing, never the opaque session_id credential; it answers 204.

RoutePurpose
GET /internal/session/list?principal=<id>Enumerate a principal’s live sessions
POST /internal/session/revoke{id, reason}204
POST /internal/session/exchangeTrade the opaque id for a short token, re-asserting realm, roles and act
POST /internal/session/rotateRe-issue the opaque id on privilege change

The exchange path re-reads the identity row and the stored impersonator_id / impersonator_mode, then re-asserts the actor through the same claim builder login uses, with an unknown or empty stored mode resolving to read-only. Exchanged tokens are fixed at 10 minutes regardless of the tenant’s token policy. That persistence is load-bearing: without it, a read-only impersonation became read-write at the first exchange, roughly fifteen minutes in, under the target user’s own identity. With no canonical mint wired, the registry refuses to exchange rather than hand-roll a claim set.

Every code the endpoint itself returns, plus the token errors raised before it.

CodeHTTPCause
bad_request400Malformed or oversized body, or tenant / user_id missing
reason_required400reason omitted or blank
no_tenant400No tenancy context on the request
bad_tenant400tenant is not a full customer:product:env:tenant key
unauthorized401No verified identity, or an empty subject
tenant_mismatch401Raised by the middleware: the token’s tenant is not the request’s tenant
invalid_token401Signature or verification failure
token_expired401Outside the validity window, or no exp claim at all
not_an_operator403The token’s tenant claim is not this plane’s CPET
role_required403The exact role for the requested mode is missing
not_granted403No active grant, target outside the caller’s plane, reserved scope named, or tenant absent from the registry
impersonation_not_allowed403The target user’s allow_impersonation is false
no_such_user404No active user with that id in the target tenant
lookup_failed500The target-user query failed
sign_failed500The token could not be minted
session_failed500The force-managed session could not be opened; the impersonation fails
tenant_unavailable503The target tenant’s datastore could not be resolved

The full external table is on the error reference; the endpoint-level contract is at /api/iam/.

State these plainly when you build an operator runbook on top of this endpoint.

RestrictionStatus
Rate limiting on /superadmin/impersonateNone applied. The route is wrapped with a profile that the default limiter does not define, and an unknown profile is a silent pass-through. The limiter also fails open on store errors and skips bucketing when the request carries no tenant key
Impersonation without a session rowPossible. The force-managed session is created only when a session service is wired; with none injected at boot the impersonation still succeeds and returns an empty session_id — exactly the unlistable, unrevocable session the force-managed rule exists to prevent
Step-up / re-authentication before impersonatingNot implemented
Notification to the impersonated user or their administratorNot implemented
Per-tenant operator roles (“provisioner on t1, read-only on t2”)Not implemented — the grant table is flat, with no role column
MFA and lockout on operator loginNot implemented
  • Superadmin — the control plane, operator roles, and the grant table
  • Tokens — claim set, reserved claims, signing keys, and token policy
  • Sessions — session presets, exchange, and revocation
  • Identity — the users entity and allow_impersonation
  • Operating — event-hook wiring, session presets, and runbooks
  • IAM API reference — the endpoint contract