ProjectsmahiImpersonation

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Impersonation

@mahiframework/impersonation lets one user act as another: an admin starts impersonating, works as that user for a while, and stops.

import { Impersonation } from "@mahiframework/impersonation";

// once, in a provider's boot()
Impersonation.authorize<User>((admin, user) => admin.isSuperadmin() && !user.isSuperadmin());

// in a route
await Impersonation.start(request, user);
await Impersonation.stop(request);

Installing the package grants nobody anything. The gate defaults to deny-all, so until an app calls authorize(), every attempt is refused. That is the opposite of convenient and the only safe default for a feature whose whole purpose is bypassing authentication.

The HTTP routes are opt-in. Omit impersonation.routes from config and the package contributes only the manager, the model and the migration — an app drives it from its own controllers, behind its own middleware, with its own logging and response shapes, while still going through the same gate.

Session guards only. Impersonation works by logging in as someone else, and a TokenGuard deliberately has no login(): a bearer token is minted out of band, so there is nothing to swap. start() throws NotStatefulGuardError against one. canImpersonate() is guard-agnostic, which is what lets a token-based app build its own flow on the same gate.

Setup

ImpersonationServiceProvider binds an ImpersonationManager singleton at IMPERSONATION_TOKEN, owns the impersonations table, and registers the start/stop routes when impersonation.routes is configured.

Order it after AuthServiceProvider (the manager resolves AUTH_TOKEN, and authenticate() on the routes needs it bound) and before HttpServiceProvider, so the routes() hook is collected when the kernel walks the providers. The same slot AuthorizationServiceProvider occupies.

// config/app.ts
export const providers: ServiceProviderClass[] = [
  // ...
  AuthServiceProvider,
  AuthorizationServiceProvider,
  ImpersonationServiceProvider,
  // ...
  HttpServiceProvider,
];

Then run the migration (./artisan migrate) and schedule the sweep alongside auth:gc:

schedule.call(async (app) => {
  await new ImpersonationGcCommand(app).handle();
}).daily().name("impersonation-gc").withoutOverlapping();

Defining who may impersonate

Register the gate once, from a provider's boot() — not register(), which runs before other providers have bound anything:

// src/providers/app.provider.ts
boot(): void {
  Impersonation.authorize<User>((admin, user) =>
    admin.isSuperadmin() && !user.isSuperadmin());
}

The callback receives the admin and the target, in that order, and may be async. Both users are non-nullable: a guest has no identity to impersonate as, so unlike a Gate ability there is no null arm to handle.

authorize() replaces any previous gate; last call wins. There is one answer to "who may impersonate", and an appending registry would make that answer depend on provider order — not something anyone should have to reason about to know who can log in as whom. Register it once, in one provider, and use before() to add further conditions.

before() hooks

A before() hook runs after the gate has already allowed, and can only ever narrow. Deny by throwing:

Impersonation.before(() => Mfa.requireVerification("auth"));

Impersonation.before((admin, user) => {
  if (user.isLocked()) {
    throw new Error("Locked accounts cannot be impersonated.");
  }
});

Throwing rather than returning false is what lets an existing guard drop in with no adapter — that Mfa call already throws — and what lets a hook raise a 401-with-challenge that reaches the client as itself rather than being flattened into a 403. Hook errors propagate exactly as thrown.

All hooks run, in registration order, and any one may deny. A literal false return also denies, because TypeScript's void-assignability rule accepts a boolean-returning arrow where void is declared, so before((a, u) => a.id !== u.id) compiles and silently allowing it would be a security bug rather than a surprise.

ImpersonationManager

MethodSignaturePurpose
authorize(cb)thisDefine the gate. Replaces any previous one.
before(cb)thisAdd a veto. Appended; throw to deny.
hasGate()booleanWhether a gate has been registered.
canImpersonate(admin, user)Promise<boolean>Self-check + gate. Runs no hooks.
assertCanImpersonate(admin, user, request?)Promise<void>Everything, including hooks. Throws.
start(request, user, opts?)Promise<ImpersonationRecord>Authorize, then begin.
stop(request)Promise<ImpersonationRecord | null>Unwind one link. null when idle.
current(request)Promise<ImpersonationRecord | null>The live link. Cached per-request.
isImpersonating(request)Promise<boolean>current() !== null.
impersonator(request)Promise<TUser | null>Who started the current link.
rootImpersonator(request)Promise<TUser | null>The real human at the top.
chain(request)Promise<ImpersonationRecord[]>The whole chain, root first.
gc()Promise<number>Delete lapsed links.

canImpersonate() versus assertCanImpersonate()

The split exists because hooks have side effects — an MFA hook prompts.

Use canImpersonate() to render: it runs no hooks, so it is safe behind an "Impersonate" button's disabled state. Use assertCanImpersonate() to guard the action: it runs the chain rules and every hook, and throws.

Consequently canImpersonate() returning true is not a guarantee that start() will succeed. A hook may still veto, and the depth rules need the request.

Rolling your own routes

Omit impersonation.routes and wire it up yourself. The gate and the hooks still apply, because they live on the manager:

export class ImpersonateController extends Controller {
  async handle(request: Request) {
    const target = await User.findOrFail(request.route("user"));

    // Throws ImpersonationDeniedError (a 403) when refused.
    await Impersonation.start(request, target);

    await AuditLog.record("impersonation.start", { target: target.id });

    return HttpResponse.json({ ok: true });
  }
}

For a read-only check, say to decorate an API response:

const canImpersonate = await Impersonation.canImpersonate(Auth.user<User>(), target);

The built-in routes

Set impersonation.routes (even to {}) and two routes are registered, both behind authenticate():

RouteNamePurpose
POST /impersonate/{user}impersonation.startBegin impersonating.
DELETE /impersonateimpersonation.stopEnd the current impersonation.

DELETE rather than POST .../stop: the impersonation is a resource, and stopping deletes it.

// config/impersonation.ts
export function impersonationConfig(): ImpersonationConfig {
  return {
    routes: {}, // omit this key entirely to register no routes
    maxDepth: 1,
  };
}
OptionDefaultPurpose
routes(absent)Presence registers the routes.
routes.prefix/impersonatePath they mount under.
routes.guardthe auth defaultWhich (stateful) guard to use.
routes.parameteruserRoute parameter carrying the target.
maxDepth1How many links may nest.

Key presence is the switch rather than routes: { enabled: false }, matching http.liveness and http.healthCheck. In this framework an opt-in feature gates on its key existing; only on-by-default behaviour uses an enabled flag.

Stopping is never authorized

stop() runs no gate and no hooks. You are, at that moment, the impersonated user, and re-checking permission to leave is how an admin gets trapped inside someone else's account after their own access is revoked mid-session. Possession of the row is the authorization.

Nested impersonation

maxDepth: 2 lets Bob impersonate Alice and then Alice-as-Bob impersonate Jane. The use case is real: a platform admin impersonating a tenant admin who needs to see what one of their users sees.

Bob → start(Alice) → start(Jane)
  stop()  // back to Alice, still impersonating, depth 1
  stop()  // back to Bob, done

Each stop() unwinds exactly one link. impersonator() returns whoever started the innermost link (Alice, above), while rootImpersonator() returns the real human (Bob) whatever the depth — that is the one to log.

Values <= 0 are normalised to 1, not treated as "disabled". A configured 0 can only mean "nobody may impersonate", which is already the default gate's job and is better said by not registering the provider. Honouring it would make an app that typo'd a 0 present as impersonation inexplicably never working, with a gate callback that looks correct.

Impersonating someone already in the chain is refused (already-in-chain), as is impersonating yourself (self).

Events

ImpersonationStarted and ImpersonationFinished carry the record plus both resolved users, so a listener need not re-fetch what every listener wants:

listeners(): ReadonlyArray<ListenerRegistration> {
  return [[ImpersonationStarted, LogImpersonation]];
}

Dispatch is a soft dependency: an app with no EventsServiceProvider gets working impersonation and no events.

ImpersonationFinished fires on stop() only, not when an impersonation merely lapses. Nothing observes that moment except the gc command, and firing a "finished" event from a cron hours later would misreport when it happened and hand listeners a request-less context they cannot act in. The command reports a count instead.

Denials

ImpersonationDeniedError extends HttpError with status 403, so it renders correctly from a hand-rolled route that never imported it, while staying instanceof-checkable. Its reason is one of:

ReasonMeaning
not-authorizedThe gate said no, or no gate is registered, or a hook returned false.
selfAdmin and target are the same user.
max-depthThe chain is already maxDepth deep.
already-in-chainThe target is already in the current chain.

The reason is carried so a logging hook can tell "an admin attempted something they are not permitted to do" from "an admin double-clicked", which are not the same signal.

ImpersonatorMissingError (409) is thrown by stop() when the impersonator's account was deleted during the impersonation. The built-in controller catches it and logs the session out entirely: leaving it authenticated as the impersonated user would silently convert a deleted admin's impersonation into a permanent, unaudited login to someone else's account.

What to know before shipping this

An impersonated session can do everything the target can, including change their password and delete their account. This package does not restrict abilities; that is your Gate, your policies, and your before() hooks. If impersonation should be read-mostly, enforce that yourself.

The admin's session id rotates twice — once on start(), once on stop(). SessionGuard.login() always mints a fresh id and destroys the old one, which is its session-fixation defence. Remember-me is restored by stop(), but its clock restarts: an admin 300 days into a remembered session gets a fresh 400, not the remaining 100.

The impersonated user is not logged out. Their own sessions, on their own devices, are untouched.

A revoked admin keeps their impersonation until it lapses or they stop, because the impersonation is the live session. Deleting their other sessions does not touch it. Delete the impersonations row to revoke immediately — that revocability is why the chain lives in a table rather than a cookie.

See also