# Impersonation

> Let admins safely impersonate a user for support, with the audit trail that records it.

## What impersonation gives you

A holder of `Admin.ImpersonateUser` can act in the app **as another user** for a short, capped
window, while every action they take is still attributed to them in the audit log. It is support
tooling: reproduce a bug a customer reports, check what they actually see, fix it, and step back out,
all without ever knowing their password.

The trick is a layered JWT. The **target user** drives the session (permissions, `GET /me`,
everything the app reads from `sub`), while the **acting admin** rides along in an extra `act` claim
that exists only for audit attribution and the stop gate.

```
sub = target user   // drives permissions, JWT validation, GetMe
act = admin user    // audit attribution + "stop impersonating" gate
```

Because `sub` is the target, the rest of the system needs zero impersonation awareness. The
permission cache loads the target's permissions, and the usual `!Enabled` short-circuit still kills
the session instantly if the target is disabled mid-flight. See [Authentication](/docs/authentication)
for how the access/refresh token pair works in the normal case.

## The three endpoints

| Step  | Endpoint                                          | Gate                          |
| ----- | ------------------------------------------------- | ----------------------------- |
| Start | `POST /api/v1/admin/users/{userId}/impersonate`   | `Allow.AdminImpersonateUser`  |
| Refresh | `POST /api/v1/auth/refresh` (the normal one)    | valid refresh cookie          |
| Stop  | `POST /api/v1/admin/impersonation/stop`           | presence of the `act` claim   |

### Start

The endpoint at `api/src/Slicekit.Api/Endpoints/v1/Admin/StartImpersonationEndpoint.cs` requires a
free-text reason (recorded in the audit log), runs the guards, revokes the admin's current session,
and issues a fresh one whose access token carries `act = adminId` and is capped at
`Auth:ImpersonationMinutes`:

```csharp
var adminId = principal.TryGetUserId() ?? throw new UnauthorizedAccessException();
var (rawRefresh, refreshHash) = tokenService.GenerateRefreshToken();

var result = await bus.InvokeAsync<Result>(
    new StartImpersonationCommand(adminId, adminSessionId, userId, refreshHash, request.Reason, deviceName), ct);
if (!result.IsSuccess) return result.Error.ToProblem();

var lifetime = TimeSpan.FromMinutes(authOptions.Value.ImpersonationMinutes);
var accessToken = tokenService.GenerateAccessToken(
    userId, result.Value.SessionId, result.Value.TargetPermissions,
    actingUserId: adminId, lifetime: lifetime);
CookieHelper.SetAuthCookies(response, accessToken, rawRefresh, lifetime, lifetime, CookieHelper.IsSecure(httpContext));
```

The handler (`api/src/Slicekit.Core/Features/Admin/StartImpersonation/Handler.cs`) creates a new
`RefreshToken` row with `ImpersonatedByUserId = adminId` and raises `ImpersonationStartedEvent`. That
nullable column is the only schema change impersonation needs, and it exists for one reason: to
survive token rotation.

### How the session carries it across a refresh

Access tokens are short-lived, so the client will hit `POST /api/v1/auth/refresh` long before a
30-minute impersonation window closes. The standard refresh path keeps the impersonation alive: the
rotate-session handler copies `ImpersonatedByUserId` onto the new row, caps `ExpiresAtUtc` at
`now + ImpersonationMinutes` (not the usual 30 days), and hands the acting id back so the new JWT
keeps its `act` claim. Nothing in the refresh endpoint is impersonation-specific beyond threading
that value through. This is why the DB column matters: the `act` claim alone would be lost on the
next rotation.

### Stop

`api/src/Slicekit.Api/Endpoints/v1/Admin/StopImpersonationEndpoint.cs` is deliberately gated on the
**claim**, not a permission:

```csharp
if (!principal.IsImpersonating) return AdminErrors.NotImpersonating.ToProblem();

var actingUserId = principal.TryGetActingUserId() ?? throw new UnauthorizedAccessException();
// ... ends the impersonation row, issues a fresh NORMAL session for the admin
```

The acting admin may not hold `Admin.ImpersonateUser` *through the target's* permission set, so
requiring the permission here would trap them inside the session. The precondition is simply: do you
carry an `act` claim? `IsImpersonating` and `TryGetActingUserId` live in
`api/src/Slicekit.Api/Auth/ClaimsPrincipalExtensions.cs`. Stop ends the impersonation row (raising
`ImpersonationEndedEvent`), then issues a fresh normal-lifetime session for the admin.

## The guards

All of these run in the start handler and return a `ForbiddenError` that maps to a localized toast on
the SPA:

| Condition                         | Error                          |
| --------------------------------- | ------------------------------ |
| target is yourself                | `CannotImpersonateSelf`        |
| target not found                  | `UserNotFound`                 |
| target is an admin                | `CannotImpersonateAdmin`       |
| target disabled or deleted        | `CannotImpersonateDisabledUser`|
| Stop called without an `act` claim| `NotImpersonating`             |

Two admins can impersonate the same user at once: each gets its own session family. Add a pre-flight
check in the start handler if you want exclusivity.

## The audit trail

This is the whole point. `AuditService.EnrichActor`
(`api/src/Slicekit.Core/Auditing/AuditService.cs`) reads the `act` claim and stamps
`AuditActor.OnBehalfOfUserId` onto **every** audit event raised during the session. So an unrelated
action the admin performs as the target (a profile update, an API-key creation) carries both ids,
and no individual emitter has to know impersonation is happening.

On top of that ambient attribution, two dedicated events bracket the session:

| Event                       | Action                       | Reason                  |
| --------------------------- | ---------------------------- | ----------------------- |
| `ImpersonationStartedEvent` | `Admin.ImpersonationStarted` | the admin's free-text reason |
| `ImpersonationEndedEvent`   | `Admin.ImpersonationEnded`   | n/a                     |

Both are pinned to the audit hash chain like every other security event. See [Auditing](/docs/auditing)
for how actor attribution and the tamper-evident chain work.

## Frontend surface

- The admin user-detail page shows an Impersonate action when the caller holds
  `Admin.ImpersonateUser` and is not already impersonating. It is disabled with a localized hint when
  the target is self, an admin, or disabled.
- `ImpersonateUserDialog.tsx` collects the required reason via React Hook Form plus Zod, posts to
  Start, invalidates the `me` query, and navigates home.
- `ImpersonationBanner.tsx` renders a sticky destructive bar whenever `me.impersonator` is set, with a
  Stop button:

```tsx
if (!me?.impersonator) return null;
// ... renders the banner with me.impersonator.email and a Stop button
```

`GET /api/v1/users/me` returns an `impersonator: { id, email } | null` built from the JWT `act` claim
(threaded through `GetMeQuery` via `principal.TryGetActingUserId()`), not from the database. See
[Adding a permission](/docs/adding-a-permission) for how the gating permission is declared and checked.

## Settings

`AuthSettings` (`appsettings.json` under `Auth:*`):

- `ImpersonationMinutes` (default `30`): rolling lifetime for both the access token and the
  impersonation refresh token, applied at Start and re-applied on every rotation.
- `ImpersonationAbsoluteMinutes` (default `60`): hard cap on the refresh-token family. Past this, the
  admin must Stop and Start again.

Set production values with env-var overrides (`Auth__ImpersonationMinutes=...`); do not edit
`appsettings.json` for environment-specific values.

## Checklist

- [ ] The caller holds `Admin.ImpersonateUser` and the start endpoint is reachable.
- [ ] Start issues a session whose access token carries `act` and is capped at `ImpersonationMinutes`.
- [ ] A refresh keeps the `act` claim and the `ImpersonatedByUserId` column, with the shortened expiry.
- [ ] An action performed as the target shows `Actor.UserId = target` and
  `Actor.OnBehalfOfUserId = admin` in the audit log.
- [ ] `Admin.ImpersonationStarted` and `Admin.ImpersonationEnded` events bracket the session.
- [ ] Stop restores the admin's normal session and `GET /me` no longer returns an `impersonator`.
- [ ] Each guard (self, admin, disabled target) surfaces a localized error.
