# Admin Login-as-Tenant (Impersonation) — Implementation Plan

**Spec:** docs/specs/2026-05-31-admin-impersonation.md  ·  **Slug:** admin-impersonation  ·  **Wave:** 12
**Depends on:** admin-dashboard, audit-compliance, foundation-auth-rbac

## Goal
Allow `SUPER_ADMIN` Zync staff to impersonate a tenant OWNER session from the admin plane (`admin.zync.is`) for support and debugging. Impersonation mints a short-lived (15 min, non-renewable) signed JWT that the tenant app (`app.zync.is`) exchanges for a temporary session cookie carrying an `impersonation` flag. A persistent, non-dismissible banner with a live countdown is shown while impersonating, destructive operations are blocked at the API layer, and every action is tagged in the tenant's audit log (`tenant_audit_log`) plus the system admin audit trail (start/end events) for full transparency.

## Architecture
- **Admin plane (`zync-api` admin routes, `admin.zync.is`):** A new route `POST /api/admin/tenants/:slug/impersonate` is guarded by `requireAdminSession()` and a `SUPER_ADMIN`-role check. It loads the tenant by `slug` (rejecting `frozen` tenants), mints an impersonation JWT signed with a **separate** `IMPERSONATION_SECRET`, and returns `{ token, expiresAt }`. It emits `admin.impersonation_started` to the admin/system audit sink.
- **Tenant app (`zync-api` tenant routes / app worker, `app.zync.is`):** `GET /impersonate?token=…` validates the impersonation JWT (timing-safe), resolves the tenant OWNER role + permissions, builds a `SessionPayload` extended with `impersonation: true` (+ impersonating admin id), and sets the `zync_session` cookie with a 15-minute TTL. `POST /api/admin/impersonation/end` clears that cookie, emits `admin.impersonation_ended`, and returns the redirect target back to the admin plane.
- **Consumes upstream:** `requireAdminSession`, `signSession`/`verifySession`, `buildSessionPayload`, `SessionPayload`, `AdminSessionPayload`, `tenants` table (`slug`, `status` enum `active|frozen`), `admin_users` table (`id`, `email`), `users`/`roles` for resolving the tenant OWNER role + expanded permissions, `tenant_audit_log` + `logAuditEvent(ctx, event)`/`AuditEvent` (from tenant-audit-log), `timingSafeEqual`, `--color-warning` design token, app-shell header slot.
- **Audit resolution (spec internal naming drift):** the impersonation spec sketches `actorType: 'admin_impersonation'` / `actor_context`, but the real upstream table `tenant_audit_log` has **no** `actor_type`/`actor_context` column. We resolve this by writing impersonated actions through `logAuditEvent` with `actorName: "Admin: {adminUserEmail}"` and `metadata.actor_type = 'admin_impersonation'` (+ `metadata.impersonating_admin_id`). The tenant audit UI keys its 🔒 icon off `metadata.actor_type === 'admin_impersonation'`. This plan defines **no new audit table** — impersonation consumes existing ones.
- **System audit sink (assumption, flagged):** `admin-dashboard` references an "Admin audit log" for every admin mutation but defines no concrete table in its DDL. This plan logs `admin.impersonation_started` / `admin.impersonation_ended` via that existing admin-plane audit mechanism (the same one already logging admin mutations per admin-dashboard Security section). If no such helper exists at build time, route these two events through `logAuditEvent` against `tenant_audit_log` with `event_type` = `admin.impersonation_started`/`admin.impersonation_ended` and `actorName: "Admin: {email}"` — do **not** introduce a new table.

## Tech Stack
- **Apps:** `apps/zync-api` (Hono, Cloudflare Workers — both admin and tenant route groups), `apps/zync-app` (Vite + React — impersonation banner + `/impersonate` landing handling).
- **Packages:** `@zync/auth` (token signing/verification, session payload extension, middleware), `@zync/db` (audit helper reuse, tenant lookup), `@zync/ui` (banner uses `Alert`/design tokens), `@zync/types` (payload + token type deltas).
- **Libraries:** `jose` (JWT sign/verify, already used by `@zync/auth`), `zod` (route input validation), Drizzle ORM.
- **Cloudflare bindings:** Worker secrets `IMPERSONATION_SECRET` (new) and existing `JWT_SECRET`; KV for `user_version` (read-only here). No new bindings.
- **Cross-cutting (preserved):** timing-safe token comparison via `timingSafeEqual`; banner is non-dismissible with `role="alert"` + `aria-live="polite"` and honors `prefers-reduced-motion` on the countdown; CSP unchanged; cookies `HttpOnly; Secure; SameSite=Strict`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Foundation deltas | 1, 2 | `@zync/types`, `@zync/auth` token + session helpers, env/secret config | Task 1 first; Task 2 depends on it |
| B — Admin-plane API | 3 | `apps/zync-api` admin routes | After A |
| C — Tenant-app API | 4, 5 | `apps/zync-api` tenant routes, blocked-op middleware | After A; 4 and 5 parallel |
| D — Audit wiring | 6 | `apps/zync-api` middleware + audit helper | After C |
| E — Frontend (tenant app) | 7, 8 | `apps/zync-app` banner + `/impersonate` landing | After C; parallel with D |
| F — Frontend (admin app) | 9 | `apps/zync-admin` Tenant Detail trigger button | After B |
| G — Tests | 10 | test files across api + app | Last |

## Tasks

### Task 1: Impersonation token + session payload type deltas
**Blocks:** 2, 3, 4, 6  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/auth.ts`
- Modify: `packages/types/src/index.ts` (re-export new types)
**Steps:**
- [ ] Add an `ImpersonationTokenPayload` interface for the signed impersonation JWT.
- [ ] Extend the existing `SessionPayload` interface with optional `impersonation?: true` and `impersonating_admin_id?: string` fields (additive — regular sessions omit both).
- [ ] Export both from the package barrel so `@zync/auth` and route handlers can consume them.
**Schema / Interfaces:**
```ts
// packages/types/src/auth.ts

// Short-lived JWT minted by the admin plane, signed with IMPERSONATION_SECRET.
export interface ImpersonationTokenPayload {
  sub: string            // "admin:{adminUserId}"
  tenant_id: string      // target tenant UUID
  impersonated_as: 'owner' // always OWNER role
  type: 'impersonation'
  exp: number            // 15 min from issue
  iat: number
}

// Additive delta to the existing SessionPayload. The existing base fields are
// sub, tid, role, permissions, tier, type, exp, iat, v — leave them unchanged and
// only append the two optional fields below.
export interface SessionPayload {
  sub: UserId
  tid: TenantId | null
  role: string
  permissions: string[]
  tier: TenantTier
  type: 'user' | 'admin'
  exp: number
  iat: number
  v: number
  impersonation?: true            // present only on impersonation sessions
  impersonating_admin_id?: string // admin_users.id of the impersonating SUPER_ADMIN
}
```
**Acceptance:**
- [ ] `ImpersonationTokenPayload` and the extended `SessionPayload` are exported from `@zync/types`.
- [ ] Existing non-impersonation session construction still type-checks without the new fields.

### Task 2: Impersonation token sign/verify helpers + secret config
**Blocks:** 3, 4  ·  **Blocked by:** 1
**Files:**
- Create: `packages/auth/src/impersonation.ts`
- Modify: `packages/auth/src/index.ts` (export new helpers)
- Modify: `apps/zync-api/src/env.ts` (declare `IMPERSONATION_SECRET` on `Env`)
- Modify: `apps/zync-api/wrangler.toml` (document `IMPERSONATION_SECRET` as a required secret)
- Modify: `.dev.vars.example` (add `IMPERSONATION_SECRET=`)
**Steps:**
- [ ] Implement `signImpersonationToken(adminUserId, tenantId, secret)` returning `{ token, expiresAt }` with a 15-minute, non-renewable expiry (`exp = now + 900s`, `impersonated_as: 'owner'`, `type: 'impersonation'`).
- [ ] Implement `verifyImpersonationToken(token, secret)` that verifies signature + expiry with `jose`, asserts `type === 'impersonation'` and `impersonated_as === 'owner'`, and returns the decoded `ImpersonationTokenPayload` (throws on any failure).
- [ ] Use `timingSafeEqual` for any non-`jose` constant comparisons (e.g. `type` discriminator guard) to avoid early-exit timing leaks.
- [ ] Add `IMPERSONATION_SECRET: string` to the `Env` interface; never fall back to `JWT_SECRET`.
- [ ] Export `signImpersonationToken`, `verifyImpersonationToken`, and `IMPERSONATION_TTL_SECONDS = 900` from `@zync/auth`.
**Schema / Interfaces:**
```ts
// packages/auth/src/impersonation.ts
export const IMPERSONATION_TTL_SECONDS = 900 // 15 min, non-renewable

export async function signImpersonationToken(
  adminUserId: string,
  tenantId: string,
  secret: string,
): Promise<{ token: string; expiresAt: number }>

export async function verifyImpersonationToken(
  token: string,
  secret: string,
): Promise<ImpersonationTokenPayload> // throws on invalid/expired/wrong-type
```
**Acceptance:**
- [ ] A token signed with `IMPERSONATION_SECRET` fails `verifyImpersonationToken` when checked against `JWT_SECRET`.
- [ ] A token with `exp` in the past throws.
- [ ] A token whose `type !== 'impersonation'` throws.

### Task 3: Admin-plane endpoint `POST /api/admin/tenants/:slug/impersonate`
**Blocks:** 9, 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/admin/impersonation.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts` (mount route)
**Steps:**
- [ ] Guard with `requireAdminSession()` then assert the admin session role is `SUPER_ADMIN` (return `403` otherwise — `SUPPORT`/`BILLING` cannot impersonate).
- [ ] Validate the `:slug` path param with zod (`z.string().min(1)`); never accept `tenantId` from the body.
- [ ] Load the tenant by `slug` via `systemQuery`; `404` if not found; `409 Tenant is frozen` if `tenants.status === 'frozen'`.
- [ ] Call `signImpersonationToken(adminSession.sub, tenant.id, env.IMPERSONATION_SECRET)`.
- [ ] Emit `admin.impersonation_started` to the admin/system audit sink with `{ adminUserId, adminUserEmail, tenantId, tenantSlug, ip, requestId }` (see Architecture "System audit sink").
- [ ] Respond `{ token, expiresAt }`. The admin SPA redirects the browser to `https://app.zync.is/impersonate?token={token}`.
**Schema / Interfaces:**
```ts
// Route: POST /api/admin/tenants/:slug/impersonate  (admin.zync.is, SUPER_ADMIN only)
// Response 200: { token: string; expiresAt: number }
// Errors: 403 (not SUPER_ADMIN), 404 (no tenant), 409 (tenant frozen)
```
**Acceptance:**
- [ ] Non-`SUPER_ADMIN` admin session receives `403`.
- [ ] Frozen tenant returns `409` and no token is minted.
- [ ] Successful call returns a token verifiable by `verifyImpersonationToken` and an `admin.impersonation_started` audit event is written.

### Task 4: Tenant-app endpoint `GET /impersonate` (token exchange → session cookie)
**Blocks:** 7, 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/impersonate.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount at `app.zync.is` root, unauthenticated)
**Steps:**
- [ ] Read `token` from the query string; validate presence with zod.
- [ ] Call `verifyImpersonationToken(token, env.IMPERSONATION_SECRET)`; on failure redirect to `/login?error=impersonation_invalid` (do not leak failure reason).
- [ ] Parse `adminUserId` from `sub` (`"admin:{id}"`); load the tenant by `tenant_id`; re-check `status !== 'frozen'` (defense in depth).
- [ ] Resolve the tenant's OWNER role and its expanded permission set (reuse `buildSessionPayload` for the OWNER role within `tenant_id`).
- [ ] Build a `SessionPayload` with `type: 'user'`, OWNER `role`/`permissions`/`tier`, plus `impersonation: true` and `impersonating_admin_id: adminUserId`. Set `exp` to **min(token.exp, now + 900s)** so the session never outlives the impersonation token.
- [ ] Sign it with `signSession` and set the `zync_session` cookie: `HttpOnly; Secure; SameSite=Strict; domain=.zync.is; Max-Age=900`. Do **not** issue a `zync_refresh` cookie (impersonation is non-renewable).
- [ ] `302` redirect to `/`.
**Schema / Interfaces:**
```ts
// Route: GET /impersonate?token={jwt}   (app.zync.is, no prior session required)
// Success: Set-Cookie zync_session=<signed SessionPayload with impersonation:true>; Max-Age=900 → 302 /
// Failure: 302 /login?error=impersonation_invalid  (no refresh cookie ever set)
```
**Acceptance:**
- [ ] Valid token sets a `zync_session` cookie whose decoded payload has `impersonation === true`, `role` = OWNER, and `exp ≤ now + 900`.
- [ ] No `zync_refresh` cookie is set.
- [ ] Invalid/expired token redirects to `/login?error=impersonation_invalid`.

### Task 5: Blocked-destructive-operations middleware for impersonation sessions
**Blocks:** 10  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/middleware/block-impersonation-ops.ts`
- Modify: target route registrations to attach the guard (tenant delete, tier downgrade, billing payment-method change)
**Steps:**
- [ ] Implement `blockDuringImpersonation()` middleware: if `session.impersonation === true`, return `403 { error: 'Impersonation sessions cannot perform this action' }`.
- [ ] Attach it to the concrete destructive endpoints enumerated below.
- [ ] Ensure the guard runs **after** `authMiddleware` (so `session` is populated) and **before** the handler.
**Schema / Interfaces:**
```ts
// Concrete blocked endpoints — tenant self-service routes an OWNER (type:'user')
// session actually hits. Admin-plane routes are NOT listed: requireAdminSession
// already rejects an impersonation (tenant-user) session before any handler runs.
// Return 403 when session.impersonation === true:
//   DELETE /api/zync-subscription            (cancel/delete tenant subscription = tier downgrade)
//   POST   /api/zync-subscription/checkout   (start checkout = add/change payment method)
//   Any tenant-account deletion route (e.g. DELETE /api/settings/account)
//   Any /api/settings/billing/payment-method* route (add/edit/remove payment methods)
export function blockDuringImpersonation(): MiddlewareHandler
```
**Acceptance:**
- [ ] Each enumerated route returns `403 'Impersonation sessions cannot perform this action'` when called within an impersonation session.
- [ ] The same routes behave normally for a non-impersonation OWNER session.

### Task 6: Audit tagging of impersonated actions + end-of-session event
**Blocks:** 10  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/impersonation-end.ts` (`POST /api/admin/impersonation/end`)
- Modify: `apps/zync-api/src/middleware/audit-context.ts` (or the `logAuditEvent` call-site builder) to enrich audit context during impersonation
- Modify: `apps/zync-api/src/routes/index.ts` (mount end route)
**Steps:**
- [ ] In the audit-context builder, when `session.impersonation === true`, set `actorName: "Admin: {adminUserEmail}"` and merge `metadata.actor_type = 'admin_impersonation'` + `metadata.impersonating_admin_id = session.impersonating_admin_id` into every `AuditEvent` produced for that request. Resolve `adminUserEmail` from `admin_users` by `impersonating_admin_id` (cache per-request).
- [ ] This routes through the existing `logAuditEvent(ctx, event)` queue helper — no schema change to `tenant_audit_log`.
- [ ] Implement `POST /api/admin/impersonation/end`: require a current `impersonation` session; resolve the tenant `slug` from `tenants` by `session.tid` (the session carries `tid`, not slug); clear the `zync_session` cookie (`Max-Age=0`); emit `admin.impersonation_ended` to the system audit sink with computed `duration_seconds` (= now − session.iat); respond `{ redirectTo: 'https://admin.zync.is/tenants/{slug}' }`.
- [ ] On natural expiry (cookie TTL elapses, server-side `exp` check fails) the session simply stops authenticating — no explicit end call needed; the `started`/`ended` pair plus token `exp` bounds the window.
**Schema / Interfaces:**
```ts
// Every audited write during impersonation is logged via the existing helper.
// metadata = the call site's domain metadata object merged with
//            { actor_type: 'admin_impersonation', impersonating_admin_id: '<uuid>' }.
//   logAuditEvent(ctx, {
//     tenantId, userId: undefined,
//     actorName: "Admin: {adminUserEmail}",
//     eventType, entityType, entityId, entityLabel,
//     metadata: mergedMetadata,
//     ipAddress,
//   })
//
// Route: POST /api/admin/impersonation/end  (app.zync.is)
//   → clears zync_session, emits admin.impersonation_ended { duration_seconds },
//     returns { redirectTo: string }
```
**Acceptance:**
- [ ] A write performed during impersonation produces a `tenant_audit_log` row whose `actor_name` starts with `"Admin: "` and whose `metadata.actor_type === 'admin_impersonation'`.
- [ ] `POST /api/admin/impersonation/end` clears the session cookie and emits `admin.impersonation_ended` with a numeric `duration_seconds`.

### Task 7: Impersonation banner component
**Blocks:** 10  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/components/ImpersonationBanner.tsx`
- Modify: `apps/zync-app/src/components/AppShell.tsx` (render banner above header when impersonating)
- Modify: `apps/zync-app/src/hooks/useSession.ts` (expose `impersonation` flag + expiry from `/api/auth/me`)
**Steps:**
- [ ] Read the current session via `GET /api/auth/me`; the response must surface `impersonation: true`, the tenant name, and the session `exp` (extend the `/api/auth/me` serializer if `exp` is not already returned).
- [ ] When `impersonation === true`, render a persistent top banner that visually **replaces** the normal app-shell header region.
- [ ] Banner uses `--color-warning` background (orange), is **non-dismissible** (no close control), has `role="alert"` and `aria-live="polite"`, and label text: `Admin mode: Viewing as {tenantName} OWNER`.
- [ ] Render a live countdown `MM:SS` from `exp − now`, updating each second. Provide `[End session]` button.
- [ ] Honor `prefers-reduced-motion`: when reduced motion is preferred, update the countdown text only (no transition/animation/pulse); otherwise normal text update is still fine — never add motion that violates the preference.
- [ ] At `00:00`, auto-trigger the same flow as `[End session]` (the server-side session is already invalid).
- [ ] `[End session]` calls `POST /api/admin/impersonation/end`, then `window.location.assign(redirectTo)`.
**Schema / Interfaces:**
```tsx
// apps/zync-app/src/components/ImpersonationBanner.tsx
export interface ImpersonationBannerProps {
  tenantName: string
  expiresAt: number   // unix seconds (session exp)
}
export function ImpersonationBanner(props: ImpersonationBannerProps): JSX.Element
```
**Acceptance:**
- [ ] Banner renders only when the session is an impersonation session and cannot be dismissed.
- [ ] Countdown decrements once per second and reaching zero ends the session and redirects.
- [ ] Banner has `role="alert"`, uses the `--color-warning` token (no hardcoded color), and the countdown respects `prefers-reduced-motion`.

### Task 8: `/impersonate` client landing route
**Blocks:** 10  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/routes/impersonate.tsx`
- Modify: `apps/zync-app/src/router.tsx` (register `/impersonate`)
**Steps:**
- [ ] Because the cookie exchange happens server-side at `GET /impersonate` (Task 4), the SPA route is reached only after the `302 → /` redirect; add a lightweight client route that shows a "Starting admin session…" spinner if the browser briefly lands on `/impersonate` before the server redirect resolves.
- [ ] On mount, if no impersonation session is detected after the redirect, navigate to `/login?error=impersonation_invalid`.
**Acceptance:**
- [ ] Navigating to `/impersonate?token=…` ends with the user on `/` inside an active impersonation session (banner visible).
- [ ] An invalid token lands the user on `/login` with the error surfaced.

### Task 9: Admin Tenant Detail "Login as Tenant" trigger button
**Blocks:** 10  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-admin/src/pages/tenants/[slug]/overview.tsx` (Tenant Detail Overview page from admin-dashboard spec 8)
- Modify: `apps/zync-admin/src/api/tenants.ts` (add `impersonateTenant(slug)` client call)
**Steps:**
- [ ] On the Tenant Detail page (`/admin/tenants/:slug`), render a `[Login as Tenant →]` action button alongside the existing `[View Audit Log]` / `[Freeze Tenant]` actions.
- [ ] Show the button only when the current admin session role is `SUPER_ADMIN` **and** the loaded tenant `status !== 'frozen'`; otherwise hide it (not merely disable — matches spec gating).
- [ ] On click, `POST /api/admin/tenants/:slug/impersonate`; on success, `window.location.assign('https://app.zync.is/impersonate?token=' + encodeURIComponent(token))`.
- [ ] On `409` (frozen) or `403`, surface an inline error toast; do not redirect.
**Schema / Interfaces:**
```ts
// apps/zync-admin/src/api/tenants.ts
export async function impersonateTenant(slug: string): Promise<{ token: string; expiresAt: number }>
```
**Acceptance:**
- [ ] Button is visible only for `SUPER_ADMIN` on a non-frozen tenant.
- [ ] Clicking it mints a token and navigates the browser to `app.zync.is/impersonate?token=…`.

### Task 10: Tests
**Blocks:** —  ·  **Blocked by:** 3, 4, 5, 6, 7, 8, 9
**Files:**
- Create: `apps/zync-api/test/impersonation.test.ts`
- Create: `apps/zync-app/test/ImpersonationBanner.test.tsx`
**Steps:**
- [ ] API: `SUPER_ADMIN` can mint a token; `SUPPORT`/`BILLING`/non-admin cannot (`403`); frozen tenant → `409`.
- [ ] API: token signed with `IMPERSONATION_SECRET` does not verify under `JWT_SECRET`; expired/wrong-`type` tokens rejected; `GET /impersonate` sets `zync_session` (impersonation, OWNER, Max-Age 900) and no `zync_refresh`.
- [ ] API: each blocked destructive endpoint returns `403 'Impersonation sessions cannot perform this action'` under impersonation and succeeds for a normal OWNER.
- [ ] API: an audited write during impersonation yields a `tenant_audit_log` row with `actor_name` `"Admin: …"` and `metadata.actor_type === 'admin_impersonation'`; `start`/`end` system events emitted; `end` clears the cookie and reports `duration_seconds`.
- [ ] UI: banner is non-dismissible, has `role="alert"`, uses `--color-warning`, counts down, and triggers end+redirect at zero; reduced-motion respected.
**Acceptance:**
- [ ] All listed tests pass in CI.

## Foundation Deltas (summary)
- **New secret:** `IMPERSONATION_SECRET` — JWT signing key for impersonation tokens, distinct from `JWT_SECRET` (enables independent rotation).
- **Type deltas:** `ImpersonationTokenPayload` (new); `SessionPayload` gains optional `impersonation` + `impersonating_admin_id`.
- **No new tables.** Consumes `tenant_audit_log` (+ `logAuditEvent`/`AuditEvent`), `admin_users`, `tenants`, `users`, `roles`.
