# Admin Impersonation

Admin impersonation (a "support session") lets a support admin log in _as_ a dealer to reproduce issues and poke at their UI state. Start and end are audit-logged; sessions auto-expire after 1 hour.

See also:

- [ADMIN_STATUS_MANAGEMENT.md](./ADMIN_STATUS_MANAGEMENT.md) - admin dealer panel
- [STATS_API.md](./STATS_API.md) - the analytics admin-bypass response shape
- [OPERATIONS_CHECKLIST.md](./OPERATIONS_CHECKLIST.md) - QA procedures referenced by the impersonation routes

## Design: stateless JWT (no server-side session state)

Impersonation is **stateless**. Everything that identifies a support session lives inside the signed NextAuth JWT in the browser cookie - there is **no server-side record of an active session**. The only server-side trace is the `start_impersonation` / `end_impersonation` audit rows.

> Historical note: the very first version of this feature (Dec 2025) was DB-backed - it stored session state in `User.impersonatedById/impersonatedAt/impersonationExpiry` via a `lib/impersonation-service.ts`. Two days later it was rewritten to the JWT approach described here (`fix: rewrite impersonation to use JWT session switching`). The old service has since been deleted. The three `User` columns still exist in the schema but are **vestigial - not read or written by any live code** (safe to drop in a future migration). Don't reintroduce DB-backed session tracking without a deliberate design decision; stateless was chosen on purpose. The original design lives in `docs/archive/implemented/2025-12-03-admin-impersonation-*`.

## What Impersonation Does

When an admin starts a support session (`app/api/admin/impersonate/start/route.ts`):

1. The route verifies the caller is an admin/superadmin and the role rules below.
2. A `start_impersonation` row is written to `AdminAction` **first** - before anything else changes - so an impersonation can never begin without an audit record.
3. A new JWT is minted with `sub = targetUser.id` (the session becomes the dealer's), carrying impersonation metadata in the same signed token: `isImpersonating: true`, `originalAdminId`, `originalAdminEmail`, `originalAdminRole`, `impersonationExpiresAt`, and a 1-hour `exp`.
4. The session cookie is replaced with that token.

No `User` rows are modified - there is no server-side session state.

Scope: the admin sees and does everything the dealer can, including dashboard, CMS, and analytics. Mutations are attributed to the dealer in normal DB writes; the `start_impersonation` / `end_impersonation` audit rows preserve the original admin identity, and individual support actions can be logged via `logImpersonationAction` (`support_*` actions in `AdminAction`).

UI cue: the frontend reads `session.impersonation` (populated from the JWT in the `lib/auth.ts` session callback) and shows the "Support Mode - Viewing as <email>" banner with an "Exit Support Mode" button (`components/ImpersonationBanner.tsx`).

## Viewing Analytics During a Support Session

The analytics dashboard is tier-gated (Enhanced/Professional only), which would otherwise stop an admin from debugging a lower-tier dealer's metrics during a support session. PR #902 added an **admin bypass** so a support session can see metrics regardless of tier - plus a caching fix so the numbers are actually the impersonated dealer's.

### Admin bypass at the tier gate

`GET /api/stats/dashboard` resolves the dealer from `session.user.id` - which, during impersonation, is the **impersonated dealer's** user id (see the session callback in `lib/auth.ts`). The route detects an active support session whose original operator is an admin-level role and skips the tier gate.

- **Defence-in-depth:** the check uses `session.impersonation.originalAdminRole` (admin **or** superadmin), **not** `session.user.role`. During impersonation `session.user` is the dealer, so checking the dealer's role would never pass. `originalAdminRole` comes from the same server-signed impersonation token, so it can't be forged independently of `isActive`.
- When bypassed for a dealer **without** analytics access, the route returns `200` (not the usual `upgrade_required` 403) with an extra field: `adminBypass: { dealerTier }`.
- The UI (`components/dashboard/PerformanceMetrics.tsx`, `app/dashboard/stats/page.tsx`) reads `adminBypass` and renders the metrics grayed-out (`MetricCard` gains a `locked` prop; click cards disabled) behind an amber "Admin support view" banner, rather than the upgrade wall.

### Caching fix

Without the fix, a browser could serve a **cached pre-impersonation** stats payload after the session changed. The route now sets `Vary: Cookie` on the served stats response, and `Cache-Control: private, no-cache` **only during an active admin impersonation session** (regular dealers keep `private, max-age=300`, since this endpoint runs several aggregate queries). See `app/api/stats/dashboard/route.ts`.

See [docs/analytics/README.md](./analytics/README.md#admin-bypass-during-support-sessions) and [STATS_API.md](./STATS_API.md) for the response shape and the platform-wide admin analytics endpoints.

## Role Rules

Enforced at the route layer (`app/api/admin/impersonate/start/route.ts`):

| Caller     | Target     | Allowed?                                      |
| ---------- | ---------- | --------------------------------------------- |
| dealer     | anyone     | No - 403, caller must be admin/superadmin     |
| admin      | dealer     | Yes                                           |
| admin      | admin      | No - superadmin required                      |
| admin      | superadmin | No - superadmin required                      |
| superadmin | dealer     | Yes                                           |
| superadmin | admin      | Yes                                           |
| superadmin | superadmin | Yes (technically allowed by current code)     |
| anyone     | self       | No - self-impersonation is blocked            |

**Note:** the code as written allows a superadmin to impersonate another superadmin. If policy forbids that, tighten the role check in the start route.

Ending a session relies on the JWT carrying `isImpersonating` + `originalAdminId` (`app/api/admin/impersonate/end/route.ts`), so only the browser holding the impersonation cookie can call it - implicitly the admin who started it. There is no server-side "only the starting admin can end" record because there is no server-side session state.

## Expiry

Duration: **1 hour**, defined once as `IMPERSONATION_SESSION_MAX_AGE` in the start route. It is applied in three places, all stateless:

1. **JWT `exp` claim** - once it lapses, NextAuth rejects the token and `getServerSession` returns no session. This is the hard guarantee.
2. **Cookie `maxAge`** - the browser drops the cookie after an hour.
3. **`impersonationExpiresAt` in the token** - recomputed to `session.impersonation.isActive` in the `lib/auth.ts` session callback (defence-in-depth) and re-checked by the proxy, which redirects expired impersonation sessions on protected routes. (Not every API path is in the proxy's protected set, so the token `exp` + the `isActive` recompute are the real guarantees.)

### What happens at the boundary

- **At expiry:** the token is invalid, so the admin's browser simply loses the dealer session and is bounced to sign-in. Nothing to clean up server-side.
- **Manual end before expiry:** `POST /api/admin/impersonate/end` mints a fresh admin-session JWT and swaps the cookie back, then writes the `end_impersonation` audit row.

### No revocation / single-session enforcement

Because sessions are stateless, there is **no kill switch** to end a session early from the server, and **no enforcement that a dealer can only be impersonated by one admin at a time**. This is an accepted trade-off: sessions are admin-only and hard-capped at 1 hour, an admin can always exit manually, and the real remediation for a compromised/deprovisioned admin is disabling that admin account (not revoking a session, which they could simply restart). See `docs/archive/implemented/2025-12-03-admin-impersonation-*` for the abandoned DB-backed model that would have supported revocation.

## Audit Logging

Every start and end writes to the `AdminAction` table (schema: see `ADMIN_STATUS_MANAGEMENT.md`).

### Start

Written in `app/api/admin/impersonate/start/route.ts`, **before** the session cookie is swapped:

```
action:     'start_impersonation'
targetType: 'user'
targetId:   targetUserId
details:    { targetEmail, targetRole, dealerName, expiresAt }
ipAddress:  from request
userAgent:  from request
```

### End

Written in `app/api/admin/impersonate/end/route.ts`:

```
action:     'end_impersonation'
targetType: 'user'
targetId:   impersonatedUserId
details:    { adminEmail, impersonatedUserId, endedAt }
ipAddress:  from request
userAgent:  from request
```

### Actions during impersonation

Actions taken while impersonating appear as normal dealer activity in whatever tables they touch; the paired `start_impersonation` / `end_impersonation` entries are the audit boundary. Where a support action is explicitly logged, it is written via `logImpersonationAction` as a `support_*` action carrying the original admin id and the impersonated user id (`lib/admin-auth.ts`). To answer "what did the admin do while impersonating dealer X at 3pm?", cross-reference the start/end timestamps against dealer-activity tables.

### Log viewing

`AdminAction` rows surface in the admin panel per-dealer view and in the global admin audit log. There is no separate "impersonation log" page; filter `AdminAction` by `action IN ('start_impersonation', 'end_impersonation')` (and `support_*` for in-session actions).

## Incident Runbook: Confused / Stuck Support Session

Because sessions are stateless and 1-hour-bounded, the old "stuck in the database" failure mode no longer exists. Realistic symptoms and fixes:

- **A dealer reports seeing admin-only UI or odd behavior.** They're hitting the app with an impersonation JWT in their browser. Have them clear cookies for the site (or wait out the ≤1-hour expiry). The JWT is signed with `NEXTAUTH_SECRET` and can't be forged; the likely cause is an admin who left an impersonating browser open somewhere the dealer could reach it - treat as a security issue and rotate the admin's session / credentials.
- **An admin wants out of a session.** Click "Exit Support Mode" (`POST /api/admin/impersonate/end`). If they've lost the cookie entirely, the session self-expires within the hour - there is nothing to clear server-side.
- **Audit check.** Confirm the session is bounded:

  ```sql
  SELECT * FROM "AdminAction"
  WHERE "targetId" = '<user_id>' AND action LIKE '%impersonation%'
  ORDER BY "createdAt" DESC LIMIT 10;
  ```

  Look for an unpaired `start_impersonation` with no matching `end_impersonation` - that just means the session was left to expire rather than exited explicitly, which is normal and harmless.

There are no `User.impersonated*` fields to clear during an incident - they are not used by the live system.

## Related Files

| File                                                                          | Purpose                                                         |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `app/api/admin/impersonate/start/route.ts`                                    | Start session: audit, mint impersonation JWT, swap cookie      |
| `app/api/admin/impersonate/end/route.ts`                                      | End session: restore admin JWT, write audit                    |
| `lib/auth.ts` (session callback)                                              | Populates `session.impersonation` + `isActive` from the token  |
| `lib/admin-auth.ts`                                                           | `isAdminLevel`, `requireAdminOrImpersonation`, `logImpersonationAction` |
| `components/ImpersonationBanner.tsx`                                           | "Support Mode" banner + exit                                   |
| `app/api/stats/dashboard/route.ts`                                            | Analytics admin-bypass + per-session caching (PR #902)         |
| `prisma/schema.prisma` (`User.impersonatedById/At/Expiry`)                    | **Vestigial** v1 columns - unused by live code                 |
