Skip to content

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 refused start attempt (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.