Admin impersonation ("View as User")
An administrator can temporarily view the app as one of their users to reproduce what that user sees for support — their navigation, feature gates, role, plan, and settings. Impersonation is an administrative, cookie-plane capability: the operator acts within the admin API as the target, every action is attributed to both the real administrator and the target, and the session ends the moment the operator stops — or sooner if their right to impersonate lapses.
⚠️ This is act-as, not read-only — but the user's personal data is hidden. While impersonating, the operator's admin-API requests carry the target's identity and scope, but the target's private content is refused (
403), not shown: chats, memories, uploads and knowledge-file downloads, personal keywords, slash commands, the personal data export, wallet / billing, approvals, scheduled tasks, devices, MCP connectors, and project conversations. See Personal data is refused. Creating durable credentials is likewise refused — see Durable credentials are blocked. Every permitted mutation is audited against both identities (below).
Session model
Starting impersonation issues a second, short-lived cookie — aig_imp — alongside the
operator's own aig_admin session (the operator's session is never destroyed, so stopping
restores it with no re-login). aig_imp is a signed JWT (HttpOnly; Secure; SameSite=Strict,
Path=/admin, 30-minute lifetime) that carries the target id, the real administrator id, and an
impersonation-session id.
The token is never trusted for scope. On every request the server re-reads the target's live account row (role, tenant) and re-checks that the operator is still allowed to impersonate that target — so the impersonated scope is always exactly the target's current scope, re-derived server-side, never asserted by the client.
Impersonation ends — reverting the operator to their own session on the next request — when
any of these becomes true: the operator stops it, the 30-minute token expires, the impersonation
record is ended, the operator loses the right to impersonate (their account is disabled, or
they are demoted below administrator/tenant-administrator), the operator's own admin session was
revoked (its iat is at or before the operator's sessions_valid_after floor — a disabled and
later re-enabled operator's old cookie can neither impersonate nor mint a fresh impersonation),
or the target's sessions were ended (the impersonation token predates the target's
sessions_valid_after floor).
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/admin/auth/impersonation/start |
aig_admin session (admin / tenant-admin) |
Begin viewing as a user. Body: { "user_id": "<id>" }. On success sets the aig_imp cookie and returns { "ok": true, "impersonating": { "actor": {...}, "target": {...}, "expires_at": <unix-seconds> } }. |
POST |
/admin/auth/impersonation/stop |
Public (idempotent) | End impersonation. Always clears the aig_imp cookie and returns { "ok": true }; ends the server-side record when the real administrator session and the impersonation id can be identified. Safe to call when not impersonating. |
While impersonating, GET /admin/auth/me returns the target's account payload plus an
impersonating block ({ actor, target, expires_at }) that the UI uses to render a persistent
on-screen banner. That block is advisory (display only) — the server re-derives and re-checks
impersonation on every request and never trusts the client's copy.
Accepted and rejected input
user_id is validated at the trust boundary and every disallowed target is refused
fail-closed (the client is never the authorization boundary). Each denial is recorded as an
impersonation.denied security-audit event.
| Condition | Result |
|---|---|
| Valid, permitted target | 200 + aig_imp cookie |
user_id absent / not a string / empty |
400 |
Not signed in (aig_admin missing/invalid) |
401 |
| Caller is not an administrator or tenant-administrator | 403 |
| Target is a platform administrator and the caller is not | 403 (rank guard — no privilege escalation) |
| Target is in a different tenant than a tenant-administrator caller | 403 (tenant scope) |
| Target is the shared demo user, or the caller is | 403 (the demo ghost is never an actor or a target) |
| Target is the caller themselves | 403 (no self-impersonation) |
| Target does not exist | 404 |
| Already impersonating (no nesting) | 409 — stop first |
Backing-store fault, or the durable impersonation.start audit write fails |
503 / 500 — no cookie is issued (fail-closed) |
A tampered, forged, expired, or replayed-after-stop aig_imp cookie is silently ignored: the
request is served as the operator's own session and GET /me reports no impersonating
block — a forged cookie can never fabricate an impersonated identity.
While impersonating: the admin-auth surface
To avoid editing the wrong account, PATCH /admin/auth/me (self-profile update) returns
409 while impersonating — the operator must stop first (the SPA tolerates this on its
background one-shot-flag writes). POST /admin/auth/logout (and SAML single-logout) always clear
both the aig_admin and aig_imp cookies, so logging out never leaves an orphaned
impersonation cookie.
Personal data is refused
Impersonation exists for support, not for reading a user's private content. While impersonating,
every personal-data admin route is refused with 403
{ "error": "impersonation_forbidden", "code": "impersonation_forbidden" } — the refusal is the
first thing each handler does, before any lookup, so it holds for reads and act-as writes.
(GDPR: an administrator must not be able to read or export an employee's private data by "viewing as"
them.)
Refused while impersonating (representative — the guard covers every route in these areas):
| Area | Examples |
|---|---|
| Conversations | list / search / single / comments / live session; messages, attachments, images, knowledge files including download |
| Memories | list / create / reorder / feedback |
| Personal PII keywords & export | GET/POST /me/pii-keywords, GET /me/export |
| Personal API tokens & devices | GET/DELETE /me/tokens, /me/device-token, device families |
| Slash commands | personal /chat-commands and /me/shared-commands |
| Feedback | per-conversation feedback / summaries, GET /my-feedback |
| Billing & wallet | /billing/*, /billing/wallet/* |
| Approvals | /me/approvals* |
| Scheduled tasks | /scheduled-tasks* |
| Tenant knowledge | /tenant-knowledge*, /me/tenant-knowledge* |
| MCP connectors | /mcp* including GET /mcp/{id}/credential and /mcp/{id}/call |
| Projects | project conversations / feed / knowledge (incl. download), and project / membership / knowledge mutations |
| Self-destruct | DELETE /me, DELETE /me/erase |
Still available for support (role / plan / access context, never private content):
GET /me/roles, GET /me/groups, GET /projects (membership list) and
GET /projects/{id}/members/candidates, GET /mcp/catalog, and the FEEDBACK_TRIAGE-gated
/feedback triage routes.
GET /admin/auth/me masks the target's personal fields
While impersonating, GET /admin/auth/me returns the target's account for support (role,
permissions, plan, tenant & branding config, feature gates) but masks or omits the target's
personal fields: the email is masked (a***@domain, keeping the domain); name,
preferred_name, work_category, model_instructions, default_model and the account-creation
time are omitted; and favorite_models, failing_runs and expiring_tokens are returned empty.
The target email shown in the impersonating banner block is masked the same way.
A CI lint (scripts/lint_impersonation_personal_guard.sh) fails the build if a new personal-data
route is added to a guarded file without the guard (or an explicit, justified exemption), so the
boundary cannot silently drift. Note: these impersonation_forbidden refusals are expected in
the error-triage queue (they are the control working), not regressions.
Durable credentials are blocked
Creating any durable credential or persistent out-of-band effect is refused with 403
impersonation_forbidden while impersonating, because it would live on a plane the aig_imp
cookie does not gate — it would survive Stop (defeating stop-revocation) and be usable
out-of-band with no per-use audit. Refusing every such site keeps every act-as action on the
cookie plane, which is stop-revocable and audited with both identities. The blocked sites are:
| Method | Path | What it would mint |
|---|---|---|
POST |
/admin/v1/playground/token |
short-lived inference bearer (chat / agents / playground) |
POST |
/admin/v1/me/tokens |
the caller's persistent inference API token |
POST |
/admin/v1/users/{id}/tokens |
a user's persistent inference API token |
POST |
/admin/v1/gateways/{id}/tokens |
a gateway inference token |
POST |
/admin/v1/tenants/{id}/scim-credential |
the tenant's SCIM provisioning bearer |
POST |
/admin/v1/conversations/{id}/share |
a public /shared/{token} conversation link |
POST |
/admin/v1/gateways/{gw}/agents/{id}/webhook-triggers |
a public /hooks/{token} agent trigger + secret |
POST |
/admin/v1/gateways/{gw}/workflows/{id}/webhook |
a public /hooks/{token} workflow trigger + secret |
POST |
/admin/v1/gateways/{gw}/workflows/{id}/form (and the PATCH token-regenerate) |
a public /forms/{token} trigger URL |
A CI lint (scripts/lint_impersonation_mint_guard.sh) fails the build if a new
secret-minting admin route is added without this guard, so the boundary cannot silently drift.
Consequently the chat composer is read-only while viewing as a user. Opening a chat trips the
refused token mint and shows a banner; the chat app ships the localised text Chat is
unavailable while you are viewing the app as this user. Use "Stop viewing as user" to chat
again. — the refusal carries the typed code next to the legacy error string
({ "error": "impersonation_forbidden", "code": "impersonation_forbidden" }), and every
other refused mint (personal tokens, SCIM credentials, share links) carries the same pair — see
Error codes. (Full act-as inference is out of scope for this
capability.)
Audit trail (both identities)
Every impersonated action records both the real administrator (actor) and the target:
impersonation.start— written before the cookie is issued; if this durable write fails, no token is granted.impersonation.action— written ahead of every impersonated mutation (actor_id= the real administrator,entity_id= the target, plus method/path). If this write fails, the mutation is refused (500) — no impersonated mutation is ever performed unaudited.impersonation.denied— a refusedstartattempt (the lateral-movement signal).impersonation.stop— impersonation ended.
A permission denial (403) that occurs during impersonation is attributed to the real
administrator, with the target as the entity. See Audit log.