Skip to content
fullstackhero

Concept

Operator impersonation

Time-bound, cross-tenant impersonation with a server-side revocation check on every request, full audit trail, and an IGlobalEntity persistence model.

views 0 Last updated

Operator impersonation lets a platform operator temporarily act as another user to debug a problem they’re seeing, validate a fix, or recover an account. The kit ships a complete impersonation flow: time-bound grant, cross-tenant support via IGlobalEntity, a server-side revocation check on every request, and an audit trail of every start / end / revoke event with full context.

The flow

1. Operator: POST /api/v1/identity/impersonation/start
Body: { targetUserId, targetTenantId, reason, durationMinutes? }
2. Identity module:
- Checks the Permissions.Users.Impersonate permission
- Cross-tenant targets allowed only for root-tenant actors;
tenant admins can impersonate within their own tenant
- Rejects self-impersonation and nested impersonation
- Builds the target user's claims, mints an access-only token
with a fresh jti + act_sub / act_tenant actor claims
- Persists an ImpersonationGrant row (IGlobalEntity) keyed by that jti
- Audits ImpersonationStarted with full context
3. Operator uses the impersonation token like any other access token
- On every request, the JWT validation hook sees act_sub and checks
the grant by jti (HybridCache-primed — no DB hit on the hot path)
- If the grant has been ended / revoked / expired, the request fails 401
4. Operator: POST /api/v1/identity/impersonation/end
→ grant marked ended; returns a fresh access + refresh pair
for the ORIGINAL actor (seamless swap back)
(or the token expires; or another operator revokes via
POST /impersonation/grants/{id}/revoke)

What ships

  • ImpersonationGrant aggregate in Modules.Identity/Domain/ImpersonationGrant.cs — implements IGlobalEntity, opting out of the tenant query filter because a cross-tenant grant doesn’t belong to a single tenant. Tenant access is re-restricted explicitly in the query layer via ActorTenantId / ImpersonatedTenantId filters.
  • Time-bound grants — caller may pass durationMinutes (clamped server-side to 1–60); omitted, the token gets the standard JwtOptions:AccessTokenMinutes lifetime.
  • Server-side revocation — the JWT bearer OnTokenValidated hook looks up the grant by jti for any token carrying the act_sub claim; ended/revoked grants reject the token immediately. Normal (non-impersonation) tokens skip the check entirely — zero cost.
  • Cross-tenant — actor’s tenant and impersonated user’s tenant can differ (root-tenant actors only); both are recorded.
  • Audit context — IP address, user agent, client id, reason all captured.
  • No refresh token — impersonation tokens are access-only. When they expire, the operator starts a new grant.

The grant record

public class ImpersonationGrant : IGlobalEntity
{
public Guid Id { get; private set; }
public string Jti { get; private set; } // JWT id; revocation key
public string ActorUserId { get; private set; }
public string? ActorUserName { get; private set; }
public string ActorTenantId { get; private set; }
public string ImpersonatedUserId { get; private set; }
public string? ImpersonatedUserName { get; private set; }
public string ImpersonatedTenantId { get; private set; }
public string Reason { get; private set; }
public DateTime StartedAtUtc { get; private set; }
public DateTime ExpiresAtUtc { get; private set; }
public DateTime? EndedAtUtc { get; private set; } // operator clicked End
public DateTime? RevokedAtUtc { get; private set; } // explicit revoke (kill switch)
public string? RevokedByUserId { get; private set; }
public string? RevokedByUserName { get; private set; }
public string? RevokeReason { get; private set; }
public string? ClientId { get; private set; }
public string? IpAddress { get; private set; }
public string? UserAgent { get; private set; }
public bool IsTerminal => EndedAtUtc.HasValue || RevokedAtUtc.HasValue;
public bool IsRevoked => RevokedAtUtc.HasValue;
}

A grant in any terminal state rejects all of its tokens on the next request; natural expiry is handled by the JWT’s own lifetime validation.

Endpoints

All under /api/v1/identity:

VerbRouteGateWhat
POST/impersonation/startPermissions.Users.ImpersonateStart a grant; returns the impersonation access token
POST/impersonation/endauthenticated impersonation sessionEnd the grant; returns fresh tokens for the original actor
GET/impersonation/grantsPermissions.Impersonation.ViewList grants (paginated, filterable)
POST/impersonation/grants/{id}/revokePermissions.Impersonation.RevokeRevoke a grant by id (kill switch)

end is deliberately gated only by authentication: the caller holds the impersonated user’s permissions, which may not include any admin permission — but anyone holding an impersonation token must always be able to end it.

Cross-tenant mechanics

An operator whose JWT carries tenant=root can impersonate a user in any tenant; tenant-scoped admins can only impersonate within their own tenant. Two cooperating pieces make the cross-tenant case work:

  1. The grant is IGlobalEntity, so it can be created and queried regardless of the caller’s current tenant context.
  2. Claims are built for the target tenant — the impersonation token’s tenant claim is the impersonated user’s tenant, so every downstream tenant-scoped query runs in the target tenant’s context, while act_sub / act_tenant (RFC 8693-style actor claims) preserve who is really acting.

Audit trail

ActionWhen
ImpersonationStartedstart succeeds
ImpersonationEndedend succeeds, or an operator revokes the grant (the revoke writes ImpersonationEnded with the revoke context)

Each row carries the actor user / tenant, the impersonated user / tenant, the IP, user agent, client id, and the reason. Natural token expiry doesn’t write an audit row — the start row’s ExpiresAtUtc already bounds the session. Query /api/v1/audits/security (filterable by action) for the full timeline.

Revocation mid-session

Revoking a grant takes effect on the impersonator’s next request — the token-validation hook fails it with 401. The tenant dashboard handles this gracefully: a 401 while the installed token carries the act_sub claim routes to a full-screen “Impersonation ended” page that clears the dead token and offers the way back to sign-in, instead of stranding the operator on a half-loaded screen.

What impersonation isn’t

  • It’s not “log in as another user”. The impersonation token is distinct from the impersonated user’s own tokens. The original user can still log in independently with their password, and their sessions are untouched.
  • It doesn’t bypass permissions. The impersonation token carries the impersonated user’s identity — permission checks resolve the impersonated user’s roles, not the actor’s. If the impersonated user can’t access an endpoint, neither can the impersonating operator.
  • It doesn’t extend across token refresh. Impersonation tokens are issued without a refresh token. When they expire, the operator starts a new grant.
  • It doesn’t nest. Starting an impersonation from an impersonation session is rejected — end the current one first.

Operational tips

  • Keep durations short. The server caps at 60 minutes; most debug sessions need far less. Shorter grants mean a forgotten-to-end session lingers less.
  • Require a reason on every start. Mandatory free text means there’s a paper trail of why each impersonation happened.
  • Alert on impersonation events. A single operator impersonating dozens of users in an hour is either a real incident response or a security incident. Either way you want to know.
  • Train your team. “Don’t impersonate just to look around” is a real cultural norm. Impersonation is an investigation tool, not a casual UX-debugging shortcut.