Skip to main content

Impersonate a tenant for support work

Outcome​

You see exactly what the customer sees, every action attributed to you in the audit log, with a banner reminding you the session is live.

Prerequisites​

  • PLATFORM_ADMIN or PLATFORM_SUPPORT. PLATFORM_READONLY is forbidden — a direct POST /platform/tenants/:tenantId/impersonation-handoff returns 403 and writes IMPERSONATION_FORBIDDEN to the audit log.
  • A recent second-factor verification. The mint sits behind requireMfaVerified(), whose default freshness window is 300 seconds — an older (or password-only) session gets a 403 MFA_STEP_UP_REQUIRED and the console prompts you to re-verify before the handoff is minted.
  • Tenant status must not be offboarding. Offboarding tenants reject impersonation to keep the export-only window clean.
  • JWT_IMPERSONATION_TOKEN_EXPIRY (default 3600 s) governs how long the session lasts. There is no refresh token.

What an impersonation is — and isn't​

IsIsn't
A short-lived JWT with scope=impersonation, your platform_user_id, and the target tenant_idA login as the tenant — there's no refresh token
Recorded as IMPERSONATION_STARTED / IMPERSONATION_ENDED in identity.tenant_auditA way to bypass PLATFORM_READONLY (forbidden, audited)
Persisted in sessionStorage (cleared on tab close)Persisted in localStorage (would survive tab close)
Subject to the audit middleware on every tenant API callBypass for suspended tenants — they still 503

Flow​

This is a cross-origin flow now — one request became two tokens

The console and the tenant app are on different origins, so the single same-origin POST /platform/tenants/:id/impersonate that used to mint a tenant JWT directly no longer exists: admin-service holds no tenant signing key, and sessionStorage does not cross an origin. admin-service mints a short-lived pointer, and rcm-core redeems it on the tenant's own origin — rcm-core remains the only issuer of tenant sessions. Full engineering detail: docs/operations/runbook-platform.md §148 in the repo.

The handoff token lives 30 seconds, is single-use, carries no session rights of its own, and travels in the URL fragment so it never reaches a server log or a Referer header.

Steps​

  1. Open the tenant page

    Platform Admin → Tenants → <tenant>. Click the Impersonate button in the page header. The button is hidden for PLATFORM_READONLY and disabled when the tenant is offboarding.

  2. Confirm the consequences

    The modal explains: every action you take is attributed to your platform user; the session ends on click or after the JWT TTL; closing the tab silently ends the session client-side (no IMPERSONATION_ENDED row).

  3. Work in tenant context

    After confirm, the page reloads to /. The ImpersonationBanner mounts at the top of the layout and counts down to expiry. Every API call you make during the session shows up in audit.access_log with your platform identity and in identity.tenant_audit if it touches platform-managed surfaces.

  4. End the session

    Click End impersonation in the banner. This posts to /auth/impersonation/end on the tenant origin (rcm-core), which writes IMPERSONATION_ENDED.

    Today that click leaves you on a Not Found page — known gap, not your mistake

    Where the browser goes after the session ends is controlled by PLATFORM_CONSOLE_ORIGIN on rcm-core. That setting is unset in every environment — it appears in no infra/terraform/envs/* app-settings block — so the app falls back to the same-origin path /platform/tenants, which no longer exists on a tenant host and falls through to the tenant app's Not Found screen. The IMPERSONATION_ENDED row is written and the session is over; only the navigation is broken. Go back to the console by typing admin.<env>.medsuiteone.ai in the address bar.

    Setting PLATFORM_CONSOLE_ORIGIN to the console origin per environment is a Terraform change, tracked as proposed work on #343. Note that the console routes its tenant list at <origin>/tenants, not <origin>/platform/tenants — the /platform/ prefix survives on API paths only.

    If you let the timer run out (or close the tab), the session ends but no IMPERSONATION_ENDED row is written. That's expected — the JWT is server-side invalid, but the operator never explicitly closed the session.

Validation​

CheckExpected
IMPERSONATION_STARTED row in identity.tenant_auditdetails.email = your platform email
IMPERSONATION_ENDED rowmatches the START's platform_user_id (within JWT TTL)
Platform Admin → Tenants → <tenant> → Impersonation tabshows the session paired with startedAt + endedAt
Tenant-side audit.access_logrows during the window are attributed to your impersonation token

Troubleshooting​

SymptomCauseFix
Impersonate button hiddenYou are PLATFORM_READONLYAsk a PLATFORM_ADMIN to act on your behalf, or escalate the role temporarily.
Click → 403 + IMPERSONATION_FORBIDDEN audit rowTenant is offboarding, or RBAC driftCheck identity.tenant.status. If offboarding, the customer is mid-export — flip to active only with a written exception.
Banner shows but no tenant data rendersThe tenant database is unreachableSee Tenant DB unreachable.
Session ends without IMPERSONATION_ENDED rowJWT TTL elapsed or tab closedExpected. To audit "abandoned" sessions per platform user, compare IMPERSONATION_STARTED count vs. IMPERSONATION_ENDED count over a window.
Click → 403 MFA_STEP_UP_REQUIRED, no audit rowYour step-up verification is older than 300 sRe-verify your second factor and retry. This refusal happens before the impersonation checks, which is why nothing is audited.
History tab shows endedAt: null for an old sessionThe IMPERSONATION_ENDED row falls outside the page's limitRe-query with a larger limit. Pairing logic matches each START with the next END for the same platform_user_id.

Cross-references​

Next​

1.3 — Suspend or read-only a tenant