Skip to content

Epic 24 — Admin Impersonation

A tool for admins to debug user-reported issues and QA role-specific flows by logging in as another user — without disturbing their own admin session. The impersonation must (a) happen in a brand-new browser tab that behaves like a completely separate session from the admin's, (b) never leak into the admin's main tab, and (c) leave a complete, immutable audit trail of every action performed under the impersonated identity.

Design constraints

  1. Tab-isolated session. The admin's original tab keeps using its localStorage-persisted access token. The impersonation tab uses an entirely separate token stored in sessionStorage only — so closing the tab ends the session, and the token never reaches any other tab or the admin's primary tab.
  2. Distinct token scope. The impersonation JWT carries scope: "IMPERSONATION" plus both sub (target user) and impersonatorId (admin). The backend rejects this token from any endpoint that would mutate sensitive identity (password change, 2FA setup, email change, deletion, payment) and stamps every other resolver call with the impersonation session id for audit.
  3. Audit-by-default, admin-visible only. A session row is created at start, every GraphQL operation during the session is logged with operation name + variable hash, and a row is closed at end (manual stop, tab close, or expiry). The audit trail is surfaced to other admins via the impersonation admin tab — the target user is never notified and sees no record of the session. No notifications, no inbox entries, no "logged in from new device" emails are emitted for the target.
  4. Visible only inside the impersonation tab. The impersonation tab shows a persistent banner at the top of every page with the impersonated user's identity and a big "End impersonation" button. The browser tab title is prefixed with [IMPERSONATING]. None of this is visible to the actual user — it lives only in the admin's tab.
  5. Reason required. Admin must enter a short reason (e.g. "debugging ticket #1234") before the session starts — stored on the audit row.
  6. Cannot escalate. Admins cannot impersonate other admins. Suspended/banned users can be impersonated (so that admins can confirm reported abuse).
  7. Short-lived. Sessions expire after 30 minutes by default; admin can extend once per session up to a 2 h hard cap.
  8. No side-effects bleeding into the user's view of their account. Notification, email, push, and "active session" channels are all suppressed for the impersonation token (see BE-IMPERSONATE-002 guards).

BE-IMPERSONATE-001 — Impersonation session model + audit tables

  • [x] Done

Files:

  • Edit: prisma/schema.prisma — add ImpersonationSession, ImpersonationActionLog models
  • Run: pnpm db:migrate

Schema:

prisma
model ImpersonationSession {
  id              String    @id @default(cuid())
  adminId         String
  admin           User      @relation("ImpersonationAdmin", fields: [adminId], references: [id])
  targetUserId    String
  targetUser      User      @relation("ImpersonationTarget", fields: [targetUserId], references: [id])
  reason          String    // required justification ("debugging ticket #1234")
  startedAt       DateTime  @default(now())
  expiresAt       DateTime  // startedAt + 30 min by default
  extendedAt      DateTime? // set once if admin extends; expiresAt is updated
  endedAt         DateTime? // null = still active
  endedBy         ImpersonationEndReason?  // MANUAL | EXPIRED | TAB_CLOSED | ADMIN_REVOKED
  ip              String?
  userAgent       String?
  actionLogs      ImpersonationActionLog[]

  @@index([adminId, startedAt])
  @@index([targetUserId, startedAt])
  @@map("impersonation_sessions")
}

enum ImpersonationEndReason {
  MANUAL
  EXPIRED
  TAB_CLOSED
  ADMIN_REVOKED   // a higher-level admin revoked the session
}

model ImpersonationActionLog {
  id              String    @id @default(cuid())
  sessionId       String
  session         ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
  operationType   String    // QUERY | MUTATION
  operationName   String    // GraphQL operation name (e.g. "updateTalentProfile")
  variablesHash   String    // SHA256 of redacted variables JSON — no PII in logs
  blocked         Boolean   @default(false)  // true if a forbidden mutation was attempted
  blockedReason   String?
  createdAt       DateTime  @default(now())

  @@index([sessionId, createdAt])
  @@map("impersonation_action_logs")
}

Acceptance criteria:

  • Both relations on User (admin side + target side) declared with distinct relation names
  • Cascade delete logs when session is deleted, but sessions themselves are never user-deletable (admin-only soft state via endedAt)
  • Indexes support "all sessions for this admin" and "all sessions where this user was impersonated" queries

BE-IMPERSONATE-002 — Impersonation token issuance + scoped JWT

  • [x] Done

Files:

  • Create: src/services/admin/impersonation.tsstartSession, endSession, extendSession, recordAction
  • Edit: src/config/context.ts — recognise scope: "IMPERSONATION" tokens
  • Edit: src/graphql/schema/index.ts — add mutations + queries
  • Edit: src/graphql/resolvers/admin.ts
  • Create: src/middleware/impersonationAudit.ts — Apollo plugin that writes an ImpersonationActionLog row per operation
  • Create: src/__tests__/services/impersonation.test.ts
  • Create: src/__tests__/resolvers/impersonation.test.ts

GraphQL:

graphql
type ImpersonationStartResult {
  token:        String!     # short-lived JWT, scope=IMPERSONATION
  sessionId:    ID!
  expiresAt:    String!     # ISO
  targetUser:   User!
}

type ImpersonationSession {
  id:           ID!
  admin:        User!
  targetUser:   User!
  reason:       String!
  startedAt:    String!
  expiresAt:    String!
  endedAt:      String
  endedBy:      String
  ip:           String
  userAgent:    String
  actionCount:  Int!
  blockedCount: Int!
}

extend type Mutation {
  startImpersonation(targetUserId: ID!, reason: String!): ImpersonationStartResult!
  endImpersonation:                                       Boolean!     # called by the impersonation tab itself
  revokeImpersonation(sessionId: ID!):                    Boolean!     # admin-only escape hatch
  extendImpersonation:                                    String!      # new expiresAt
}

extend type Query {
  impersonationSessions(filter: ImpersonationFilter, page: Int, pageSize: Int): ImpersonationSessionPage!
  impersonationSession(id: ID!): ImpersonationSession!
  impersonationActionLogs(sessionId: ID!, page: Int, pageSize: Int): ImpersonationActionLogPage!
  myActiveImpersonation: ImpersonationSession  # if the admin currently has one active
}

input ImpersonationFilter {
  adminId:      ID
  targetUserId: ID
  activeOnly:   Boolean
  from:         String
  to:           String
}

JWT shape (impersonation only):

json
{
  "sub":            "<targetUserId>",
  "impersonatorId": "<adminId>",
  "sessionId":      "<impersonationSessionId>",
  "scope":          "IMPERSONATION",
  "tokenVersion":   <target user's tokenVersion at issue time>,
  "iat":            <issued>,
  "exp":            <issued + 30 min>
}

Context changes (createContext):

  • Parse JWT as today.
  • If scope === "IMPERSONATION":
    1. Load the impersonation session by sessionId. Reject if endedAt is set, expiresAt is past, or the row was revoked.
    2. Hydrate user with the target user's id and profile flags (so all resolvers run as them).
    3. Attach impersonation: { adminId, sessionId } to context (typed in GraphQLContext).
    4. Touch the session row (lastSeenAt) on each request — optional; skip if hot-path cost is too high.

Resolver guards (re-used everywhere):

  • New helper requireNotImpersonating(ctx) used in mutations that must not be performed under impersonation:
    • changePassword, enable2FA / disable2FA / verify2FA, updateEmail, deleteAccount, purchaseCasTarsBundle (and any future Stripe action), socialLoginLink, confirmLiveness
    • When called under impersonation: log a blocked: true action row with blockedReason, throw FORBIDDEN_DURING_IMPERSONATION.
  • New helper requireAdmin(ctx) already exists — it must explicitly reject impersonation contexts (an admin impersonating a non-admin must not regain admin powers via the token).

Apollo audit plugin (impersonationAudit.ts):

  • didResolveOperation: if ctx.impersonation present, capture operationName, operationType, redact known-sensitive keys from variables (passwords, tokens, files), SHA256 the result.
  • willSendResponse: insert ImpersonationActionLog row (fire-and-forget but awaited so it cannot be silently dropped in tests).

Side-effect suppression under impersonation: The target user must NOT be able to tell that an impersonation session happened. Wire the impersonation context through to the side-effect layer:

  • src/services/notifications/index.tscreateNotification(userId, …): if ctx.impersonation is present, skip writing the notification entirely (never even an "admin signed in" entry).
  • src/services/email/index.tssendEmail(…): skip transactional emails when called from an impersonation context. This explicitly suppresses "new sign-in from device" / "security alert" / "weekly digest" mails that would otherwise fire from actions the admin performs.
  • Push / web-push delivery: same suppression.
  • "Active sessions" / "logged-in devices" UI (if/when added): the impersonation JWT must NOT appear in the target user's session list.
  • lastLoginAt and friends on User are NOT updated by impersonation tokens.

This is enforced centrally by the service layer reading ctx.impersonation — individual resolvers do not need to special-case it. Add a regression test per channel (notifications.test.ts, email.test.ts) that calls a mutation under an impersonation context and asserts zero side-effects were emitted.

startImpersonation behaviour:

  • requireAdmin(ctx) — must NOT itself be impersonating (no nesting).
  • Reject if targetUserId === ctx.user.sub (no self-impersonation).
  • Reject if target user has isAdmin: trueCANNOT_IMPERSONATE_ADMIN.
  • Reject if the same admin already has an active (non-expired) session — they must end it first.
  • Trim/validate reason (1–200 chars).
  • Create ImpersonationSession with expiresAt = now + 30 min, capture req.ip + req.headers['user-agent'].
  • Sign + return JWT.

endImpersonation behaviour:

  • Only callable by an impersonation-scoped token (ctx.impersonation present).
  • Set endedAt = now, endedBy = MANUAL.

revokeImpersonation(sessionId) behaviour:

  • Admin-only; can revoke any session. Sets endedBy = ADMIN_REVOKED.

extendImpersonation behaviour:

  • Impersonation-scoped token only. Allowed once per session. Sets new expiresAt = now + 30 min, capped at startedAt + 2 h. Returns new ISO timestamp. The client must re-issue a new JWT — done by also returning a refreshed token in the mutation payload (extend the type accordingly: return { token, expiresAt }).

Acceptance criteria:

  • Impersonation token used on changePassword → throws FORBIDDEN_DURING_IMPERSONATION AND an ImpersonationActionLog row with blocked: true is written
  • Impersonation token used on a normal query (me) → returns the target user's data, action log row written with blocked: false
  • Admin's normal access token continues to work in their original tab while an impersonation session is active
  • startImpersonation on an admin target → throws CANNOT_IMPERSONATE_ADMIN
  • Expired token → context drops user to null (UNAUTHENTICATED), and an attempt to use it logs nothing (no session = no audit row)
  • A mutation under impersonation that would normally write a notification (e.g. a follow, a message) → notification row count is unchanged, and no email/push is sent. Regression test asserts both.
  • All paths covered by tests in src/__tests__/resolvers/impersonation.test.ts

FE-IMPERSONATE-001 — Start-impersonation action in admin user detail

  • [x] Done

Files:

  • Edit: apps/app/src/pages/admin/AdminUserDetailPage.tsx — add "Impersonate" button + reason modal
  • Create: apps/app/src/hooks/useStartImpersonation.ts
  • Create: apps/app/src/lib/queries/impersonation.ts

Description:

  • "Impersonate user" button visible on AdminUserDetailPage (admin role only, hidden for admin targets).
  • Click opens a DS Modal with:
    • Big warning: "You're about to log in as <name>. Every action you take will be logged and reviewable by other admins. Sensitive actions (password change, 2FA, payments) will be blocked. The user will NOT be notified — keep this session strictly for debugging."
    • Required reason Textarea (1–200 chars).
    • "Start impersonation" primary button.
  • On submit → call startImpersonation → receive { token, sessionId, expiresAt, targetUser }.
  • Open a new tab via window.open('/_impersonate/handoff#token=' + encodeURIComponent(token), '_blank', 'noopener,noreferrer'). The token rides in the URL hash (not query) so it never reaches the server log. noopener,noreferrer severs the link to the admin tab.
  • Show a confirmation toast: "Impersonation tab opened. Don't forget to end the session when done."

Acceptance criteria:

  • Button is hidden for admin targets and for the admin's own profile row.
  • Modal validates the reason field (non-empty, ≤ 200 chars) before enabling submit.
  • Closing the modal without submitting does NOT start a session.
  • New tab opens via window.open with noopener,noreferrer.
  • All UI from @castyou/design-system.

FE-IMPERSONATE-002 — Impersonation tab handoff + isolated session

  • [x] Done

Files:

  • Create: apps/app/src/pages/impersonate/ImpersonateHandoffPage.tsx
  • Create: apps/app/src/components/ImpersonationBanner.tsx — moved to DS in follow-up
  • Create: apps/app/src/lib/impersonationStorage.ts — tab-scoped storage helpers
  • Edit: apps/app/src/lib/graphqlClient.ts — prefer impersonation token when present
  • Edit: apps/app/src/App.tsx — add /_impersonate/handoff route; mount banner inside ProtectedShellLayout
  • Edit: apps/app/src/stores/auth.ts — read from impersonationStorage first when the tab is in impersonation mode

Tab isolation strategy:

The whole problem reduces to: how does the new tab use a totally different auth identity from the admin's original tab, on the same origin? Storage facts the design relies on:

StoragePer-tab?Survives reload?Shared across tabs?
localStoragenoyesyes (same origin)
sessionStorageyes (per tab)yesno — even with window.open, noopener severs inheritance
Cookiesnoyesyes
URL hashper navigationnono

The flow:

  1. Admin tab calls window.open('/_impersonate/handoff#token=...', '_blank', 'noopener,noreferrer'). noopener is critical — it prevents the new tab from inheriting the admin's sessionStorage and prevents window.opener reach-back.
  2. New tab boots to ImpersonateHandoffPage. It:
    • Reads window.location.hash, extracts token.
    • Calls sessionStorage.setItem('castyou_impersonation_token', token) AND sessionStorage.setItem('castyou_impersonation_mode', '1').
    • Calls window.history.replaceState(null, '', '/discover') (or wherever the post-handoff landing is) — token is gone from the URL on the very first paint, never reaching browser history or any logging.
    • Fetches me using the impersonation token → hydrates a tab-local Zustand store (NOT the persisted useAuthStore) with the target user's profile.
  3. From this point on, the GraphQL client checks impersonation mode first:
    ts
    const impersonationToken = sessionStorage.getItem('castyou_impersonation_token');
    const isImpersonating = sessionStorage.getItem('castyou_impersonation_mode') === '1';
    const token = isImpersonating
      ? impersonationToken
      : localStorage.getItem('castyou_access_token');
    So the impersonation tab never reads or writes the admin's localStorage token.
  4. App.tsx checks sessionStorage.getItem('castyou_impersonation_mode') very early (before the persisted auth store hydrates) and short-circuits the normal auth bootstrap path — otherwise the persisted admin user would briefly flash on screen.
  5. The persisted castyou-auth localStorage key is read-only in impersonation mode and is NEVER written to. The Zustand persistence is disabled for the impersonation tab via a skipHydration guard.

ImpersonationBanner (always visible in impersonation tabs):

  • Sticky bar at the top of every screen (above AppHeader).
  • Background: bg-warning (DS semantic token to add if missing).
  • Content: "You're impersonating <displayName> (<email>). Session ends in <countdown>." + End impersonation button + Extend 30 min button (only enabled if not yet extended).
  • "End impersonation" → calls endImpersonation mutation, then window.close() (best-effort) and clears sessionStorage.
  • Countdown timer updates every second; when it hits zero, the banner shows "Session expired — please close this tab" and the GraphQL client refuses further requests.
  • Tab title is set via useEffect: document.title = '[IMPERSONATING] ' + originalTitle.

Hardening:

  • Clicking external links inside the impersonation tab still keeps the user in the impersonation tab — that's intentional. But any navigation to /admin/* is blocked with a friendly notice ("Admin pages are disabled during impersonation. End the session in this tab to return to admin.")
  • The window-beforeunload handler calls endImpersonation synchronously via navigator.sendBeacon so the audit row's endedBy becomes TAB_CLOSED even when the tab is closed abruptly.
  • If the user opens the app in another tab while a normal session exists, that other tab is unaffected (different sessionStorage).
  • A safety check on app boot: if sessionStorage.castyou_impersonation_mode === '1' but no token can be parsed/decoded, redirect to a friendly "Session expired" screen and clear sessionStorage. Never silently fall back to the admin's localStorage token.

Acceptance criteria:

  • Opening the impersonation tab does NOT affect the admin's original tab — both stay logged in as their respective identity.
  • Closing the impersonation tab automatically fires endImpersonation (verify via audit log row showing endedBy: TAB_CLOSED).
  • Reloading the impersonation tab preserves the impersonation state (because sessionStorage survives reload).
  • Opening a fresh tab to the same app URL is NOT impersonated (because sessionStorage is per-tab and was severed by noopener).
  • The admin's profile is never visible inside the impersonation tab — even briefly during boot.
  • The banner is present on every protected page; clicking "End impersonation" closes the session and the tab.
  • All DS rules respected — banner lives in @castyou/design-system.

FE-IMPERSONATE-003 — Audit log viewer (admin)

  • [x] Done

Files:

  • Edit: apps/app/src/pages/admin/AdminPage.tsx — add "Impersonation" tab
  • Create: apps/app/src/pages/admin/AdminImpersonationPage.tsx
  • Create: apps/app/src/pages/admin/AdminImpersonationSessionDetail.tsx
  • Create: apps/app/src/hooks/useImpersonationSessions.ts

Description:

  • Admin tab "Impersonation" showing a paginated table of all sessions (filter by admin, by target user, by date range, active-only).
  • Columns: admin → target → reason → started → ended → duration → action count → blocked count → status badge (Active / Ended / Revoked / Expired).
  • Row click → session detail page: full metadata + action log timeline (operationName, type, blocked flag, time).
  • Active sessions show a "Revoke" button (calls revokeImpersonation).
  • A panel on the admin's own profile lists their own past sessions for quick self-review.
  • Pagination per feedback_pagination.md — page/pageSize controls below the table.
  • All from @castyou/design-system.

Acceptance criteria:

  • Active sessions are visually distinguishable (status badge + subtle row highlight).
  • Revoke action is gated behind a confirmation dialog.
  • Action log paginates independently from the session list.
  • Tests cover: empty state, active vs ended rendering, revoke flow, blocked-action highlighting in the timeline.