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.
What has to be true
Section titled “What has to be true”The handler checks these in order and stops at the first failure.
| # | Check | Failure |
|---|---|---|
| 1 | Body parses (8 KiB cap) and carries tenant + user_id | 400 bad_request |
| 2 | Body carries a non-empty reason | 400 reason_required |
| 3 | A verified principal with a non-empty subject is on the request | 401 unauthorized |
| 4 | The request carries tenancy context | 400 no_tenant |
| 5 | The token’s tenant claim equals this plane’s CPET (<customer>:<product>:<env>:superadmin) | 403 not_an_operator |
| 6 | The operator holds the exact role for the requested mode | 403 role_required |
| 7 | tenant parses as a full customer:product:env:tenant key | 400 bad_tenant |
| 8 | The target’s customer, product and environment equal the caller’s | 403 not_granted |
| 9 | The target’s tenant slot is not a reserved scope name | 403 not_granted |
| 10 | The operator holds an active grant on the target tenant (root exempt) | 403 not_granted |
| 11 | The target tenant’s datastore resolves | 503 tenant_unavailable |
| 12 | An active user with that id exists in the target tenant | 404 no_such_user (500 lookup_failed on a query error) |
| 13 | That user’s allow_impersonation is true | 403 impersonation_not_allowed |
| 14 | The token mints | 500 sign_failed |
| 15 | The force-managed session row is created | 500 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.
The two roles
Section titled “The two roles”| Role | Mints | Implied by |
|---|---|---|
impersonate-readonly | mode: readonly | nothing |
impersonate | mode: 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.
The grant check
Section titled “The grant check”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.
The consent gate
Section titled “The consent gate”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.
Request
Section titled “Request”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"}'| Field | Type | Required | Meaning |
|---|---|---|---|
tenant | string | yes | Full CPET to act in. Must share the caller’s customer, product and environment, and be granted (or the caller holds root) |
user_id | string | yes | The target user’s id in that tenant |
reason | string | yes | Why. The only part of the record that says why, and it cannot be reconstructed afterwards |
read_write | boolean | no | Absent 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.
Response
Section titled “Response”{ "token": "v4.public.…", "session_id": "01J…", "expires_in": 900, "mode": "readonly", "acting_as": "U01ALICE", "tenant": "acme:shop:prod:main"}| Field | Meaning |
|---|---|
token | The target user’s PASETO v4.public token, signed by the target tenant’s keyring |
session_id | The opaque managed-session credential. Omitted when no session service is wired |
expires_in | Seconds, from the target tenant’s composed token policy (15 minutes by default) — impersonation applies no TTL override |
mode | readonly or readwrite |
acting_as | The target user’s id |
tenant | The 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": "…"}.
How the token differs from a normal one
Section titled “How the token differs from a normal one”It differs in exactly one claim.
| Claim | Impersonation token | Ordinary login token |
|---|---|---|
iss | the service issuer | same |
sub | the target user’s id | the logged-in user’s id |
tenant | the target tenant’s CPET | the caller’s CPET |
realm | the target user’s realm | the caller’s realm |
roles | the target user’s properties.roles | the caller’s roles |
type | user | user |
iat / nbf / exp | from the target tenant’s token policy | same |
act | present — see below | absent |
{ "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.
Every input that means read-only
Section titled “Every input that means read-only”The verifier projects act into an actor and fails closed on anything it cannot read.
| Token contains | Actor | Effective mode |
|---|---|---|
{"act":{"sub":"U01OP","realm":"superadmin","mode":"readwrite"}} | present | read-write |
{"act":{"sub":"U01OP","realm":"superadmin","mode":"readonly"}} | present | read-only |
{"act":{"sub":"U01OP","realm":"superadmin"}} — mode absent | present | read-only |
{"act":{"sub":"U01OP","mode":"READWRITE"}} — wrong case | present | read-only |
{"act":"U01OP"} — not an object | present, fields empty | read-only |
no act key | nil | not 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.
What a read-only impersonation cannot do
Section titled “What a read-only impersonation cannot do”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”.
| Action | Refused under a read-only actor |
|---|---|
create | yes |
update | yes |
delete | yes |
purge | yes |
restore | yes |
read | no |
unmask | no |
decrypt | no |
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.
Writing rules against the actor
Section titled “Writing rules against the actor”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' }| Binding | Value |
|---|---|
user.act.sub | the operator’s control-plane id |
user.act.realm | superadmin |
user.act.mode | the raw claim value |
user.act.readonly | precomputed 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.
What is audited
Section titled “What is audited”| Record | Where it lands | Content |
|---|---|---|
| Per-action audit rows | the tenant’s schema, <entity>_audit | changed_by = the operator; the entity row’s own updatedby keeps the target user |
| Managed-session row | the tenant’s schema, session | impersonator_id, impersonator_reason, impersonator_mode, auth_method: impersonation, realm |
| Auth event | the auth event bus | impersonator, plane, mode, reason, session_id |
| Process log line | service log | operator, 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.
Terminating a session
Section titled “Terminating a session”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.
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'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.
| Route | Purpose |
|---|---|
GET /internal/session/list?principal=<id> | Enumerate a principal’s live sessions |
POST /internal/session/revoke | {id, reason} → 204 |
POST /internal/session/exchange | Trade the opaque id for a short token, re-asserting realm, roles and act |
POST /internal/session/rotate | Re-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.
Errors
Section titled “Errors”Every code the endpoint itself returns, plus the token errors raised before it.
| Code | HTTP | Cause |
|---|---|---|
bad_request | 400 | Malformed or oversized body, or tenant / user_id missing |
reason_required | 400 | reason omitted or blank |
no_tenant | 400 | No tenancy context on the request |
bad_tenant | 400 | tenant is not a full customer:product:env:tenant key |
unauthorized | 401 | No verified identity, or an empty subject |
tenant_mismatch | 401 | Raised by the middleware: the token’s tenant is not the request’s tenant |
invalid_token | 401 | Signature or verification failure |
token_expired | 401 | Outside the validity window, or no exp claim at all |
not_an_operator | 403 | The token’s tenant claim is not this plane’s CPET |
role_required | 403 | The exact role for the requested mode is missing |
not_granted | 403 | No active grant, target outside the caller’s plane, reserved scope named, or tenant absent from the registry |
impersonation_not_allowed | 403 | The target user’s allow_impersonation is false |
no_such_user | 404 | No active user with that id in the target tenant |
lookup_failed | 500 | The target-user query failed |
sign_failed | 500 | The token could not be minted |
session_failed | 500 | The force-managed session could not be opened; the impersonation fails |
tenant_unavailable | 503 | The 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/.
Restrictions that are not enforced
Section titled “Restrictions that are not enforced”State these plainly when you build an operator runbook on top of this endpoint.
| Restriction | Status |
|---|---|
Rate limiting on /superadmin/impersonate | None 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 row | Possible. 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 impersonating | Not implemented |
| Notification to the impersonated user or their administrator | Not 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 login | Not implemented |
Continue with
Section titled “Continue with”- 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
usersentity andallow_impersonation - Operating — event-hook wiring, session presets, and runbooks
- IAM API reference — the endpoint contract