# Impersonation

> The iam.svc impersonation endpoint end to end — who may call it, the token it mints and its act claim, the read-only floor, what is audited, and how a session is terminated.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authz/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

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.

:::note
A valid **tenant** user's token aimed at this endpoint never reaches the handler. It dies at the middleware's tenant pin — `401 tenant_mismatch` — before check 5 runs. `not_an_operator` is what a token from a *different plane surface* produces, not what a tenant user sees.
:::

### 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](/blocks/baas/iam/authz/superadmin/) for the operator role vocabulary and the grant table.

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

:::caution
`not_granted` is returned **identically** for four distinct causes: no active grant, a target outside the caller's plane, a reserved scope name in the tenant slot, and a tenant absent from the registry. That sameness is the control — otherwise an operator enumerates the customer list by probing. Do not expect a distinct "tenant not found" response.
:::

:::note
The registry lookup matches the **tenant segment** against the registry slug. Checks 8 and 9 have already pinned customer, product and environment, so the segment is unambiguous within a plane — but the provisioning path resolves the same registry by **full CPET**. Register slugs consistently. If slugs are stored as full CPETs, this lookup misses and every non-`root` impersonation returns `not_granted`.
:::

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

:::note
The cost is accepted, not overlooked: if a tenant breaks badly enough that no administrator can log in to set the flag, no operator can help them through the product. The break-glass is a human with database credentials, outside the product.
:::

## Request

```bash
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

```json
{
  "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

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 |

```json
{ "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](/blocks/baas/iam/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.

:::caution
The ceiling is the target user's own authority, so a **read-write** impersonation of a tenant administrator carries administrator authority — including `users` update, which is the rule that guards `allow_impersonation` on every other user in that tenant. Pick the least-privileged user who can reproduce the problem.
:::

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

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:

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

:::caution
`unmask` and `decrypt` are reads, so the floor does not touch them. Whether a read-only impersonator may reveal a redacted or encrypted field is decided entirely by that entity's own rules — on the `users` entity, for instance, `unmask` is `iam-service`-only, so an impersonation never reveals a password hash.

The floor's reach is exactly "every caller that populates the actor". That covers the generic `/rest` and `/admin` surfaces. It does **not** cover the service's own `/auth/*` handlers, which run their engine operations under the internal `iam-service` principal and therefore carry no actor.
:::

### 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:

```yaml
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](/blocks/baas/data-api/security/access-rules/).

## What is audited

| Record | Where it lands | Content |
|---|---|---|
| Per-action audit rows | the **tenant's** schema, `<entity>_audit` | `actor` = the **operator**; `changed_by` and the entity row's own `updatedby` keep 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.

:::caution
Two gaps in the operator-side trail:

The auth event is emitted as the **`after_login_success`** event with `tenant` set to the target tenant and `subject` set to the target user. There is no dedicated impersonation event name — an alerting rule must key off the payload keys (`impersonator`, `plane`, `mode`), not the event name.

The event bus fans out only to registered hooks, and the shipped pointcut file registers none. With no hook wired, the only control-plane record that an impersonation started is a process log line. Treat event-hook wiring as required, not optional. See [Operating](/blocks/baas/iam/operating/configuration/).
:::

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

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.

```bash
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'
```

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

:::caution
**Revoking the session does not invalidate the token already issued.** The impersonation response hands back a self-contained PASETO, and the bearer path verifies signature, tenant pin and expiry — it never consults the session registry. Effective revocation latency is therefore immediate for tenants on a managed preset (every request re-exchanges the opaque id) and **one token TTL** for tenants on a stateless preset, where the session row is disclosure and audit only. See [Sessions](/blocks/baas/iam/tokens/sessions/).

Related: for a managed-preset tenant, ordinary login deliberately returns only the opaque session reference and no self-contained token. The impersonation response returns **both**, whatever the tenant's preset.
:::

:::caution
The `/internal/session/*` routes carry **no application-layer authentication**. They are registered with bare handlers — no token wrapper, no role gate, and no check tying a session id or the `principal` query parameter to the caller. Anyone who can reach the port with the right tenancy headers can enumerate a principal's sessions, revoke any session by id, or exchange a stolen opaque id for a live token. They must be unreachable from outside the service mesh.
:::

:::note
The session listing does not project the impersonator fields. It returns `id`, `principal_id`, `cpet`, `status`, `aal`, `auth_method`, `user_agent_label`, `created_at` and `last_seen_at` — an impersonation is identifiable in the listing by `auth_method: impersonation` alone. The impersonator's id, reason and mode are on the row, readable through the entity surface, not through this API.
:::

## 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](/blocks/baas/iam/error/); the endpoint-level contract is at [`/blocks/baas/iam/api/`](/blocks/baas/iam/api/).

## What this endpoint does not do

Worth stating plainly when you build an operator runbook on top of it, because each is something
your process has to supply rather than something the endpoint supplies.

| Not provided | Build it as |
|---|---|
| Step-up or re-authentication before impersonating | An approval step in front of the call |
| Notification to the impersonated user or their administrator | An out-of-band notification from your process |
| Per-tenant operator roles | Grants are flat; scope operators by which tenants they are granted |
| MFA and lockout on operator login | Your identity provider, in front of the operator plane |

Impersonation is audited, and the audit trail is the control that does exist — see the fields
above. Treat it as the record you review, not as a preventative gate.

## Continue with

- [Superadmin](/blocks/baas/iam/authz/superadmin/) — the control plane, operator roles, and the grant table
- [Tokens](/blocks/baas/iam/tokens/) — claim set, reserved claims, signing keys, and token policy
- [Sessions](/blocks/baas/iam/tokens/sessions/) — session presets, exchange, and revocation
- [Identity](/blocks/baas/iam/identity/entities/) — the `users` entity and `allow_impersonation`
- [Operating](/blocks/baas/iam/operating/configuration/) — event-hook wiring, session presets, and runbooks
- [IAM API reference](/blocks/baas/iam/api/) — the endpoint contract