sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for me". reproducing that with a password reset means locking somebody out of their own account in order to look at it, which is worse than the bug. THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and reaches every handler, because user-middleware puts the whole payload on the context. without it an impersonated action is indistinguishable from that person's own — in a log, in a transcript, in anything keyed on a user id — and a support tool that can forge activity as somebody else is a different and much worse thing than a support tool. ONE HOUR, not thirty days. a session lasts a month because a person uses their own account daily; impersonation is a task you finish. an hour is long enough to reproduce a problem and short enough that a forgotten tab stops being a live credential for somebody else's account by lunchtime. IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any token carrying `impersonatedBy`, so impersonating another Super Admin does NOT hand user management back — which would have made this a way to launder owner actions through somebody else's id. stated twice, in the gate and in the handler, because one file away is too far for this one. THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an owner session — a far more dangerous thing to own than this whole feature. the cost is that clearing browser storage strands you as the other person until the hour expires, and signing in again fixes that. the banner is loud and sits ABOVE the header, outside everything a fullscreen panel can hide. the failure it prevents is not a hole, it is forgetting: reading a mailbox or deleting something while believing it is yours. it asks the server who it is rather than decoding the token, so it cannot keep showing a name after the hour has run out. verified end to end against a throwaway account, since this host has one user: impersonate 200 with the target's id and impersonatedBy naming the owner, 60 minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining 403, user management while impersonating 403; one [SECURITY] line per start. the probe account was deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
||||
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
|
||||
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
|
||||
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
|
||||
<ImpersonationBanner />
|
||||
<Header dockItems={visibleItems} hidden={panelFullscreen} />
|
||||
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
||||
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { startImpersonation } from 'officerdev';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Crown,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
SquareTerminal as TerminalIcon,
|
||||
Trash2,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -128,6 +138,25 @@ export const UsersSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign in as this person, for an hour.
|
||||
*
|
||||
* No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
|
||||
* nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
|
||||
* new password and a deletion, which are neither.
|
||||
*/
|
||||
const impersonate = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
try {
|
||||
const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
|
||||
// Swaps the token and reloads — nothing after this line runs.
|
||||
startImpersonation(token);
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
setConfirmDelete(null);
|
||||
@@ -263,6 +292,22 @@ export const UsersSection = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Not for the owner: impersonating yourself is refused by the route, and the row that
|
||||
offers it would be the one row where it cannot work. */}
|
||||
{!user.isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
disabled={busy}
|
||||
aria-label={`Sign in as ${user.email}`}
|
||||
title="Sign in as this user for one hour"
|
||||
onClick={() => void impersonate(user)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCog className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUserById } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { clientIp } from '../auth/client-ip';
|
||||
|
||||
/**
|
||||
* Sign in AS another account, from user management.
|
||||
*
|
||||
* The support tool this is for: "it does not work for them and it does for me". Reproducing that with a
|
||||
* password reset means locking the person out of their own account to look at it, which is worse than
|
||||
* the bug.
|
||||
*
|
||||
* ── The token says whose it is, and who is really holding it ──
|
||||
*
|
||||
* `impersonatedBy` is on the token and reaches every handler, because `user-middleware` puts the whole
|
||||
* payload on the context. Without it an impersonated action is indistinguishable from the person's own
|
||||
* — in a log, in a chat transcript, in anything keyed on the user id — and a support tool that can
|
||||
* forge activity as somebody else is a different and much worse thing.
|
||||
*
|
||||
* ── One hour, not thirty days ──
|
||||
*
|
||||
* A session token lasts a month because a person uses their own account daily. Impersonation is a task
|
||||
* you finish. An hour is long enough to reproduce a problem and short enough that a forgotten tab stops
|
||||
* being a live credential for somebody else's account by lunchtime.
|
||||
*
|
||||
* ── What it cannot do ──
|
||||
*
|
||||
* It cannot chain: `ownerGate` refuses a request whose token carries `impersonatedBy`, so an
|
||||
* impersonated session cannot impersonate a third account, and — the case that matters — impersonating
|
||||
* another Super Admin does not hand back user management. Otherwise this route would be a way to launder
|
||||
* owner actions through somebody else's id.
|
||||
*
|
||||
* It also cannot outlive the account: `user-middleware` re-reads the row on every request and refuses a
|
||||
* deleted or non-Active user, exactly as it does for an ordinary session.
|
||||
*
|
||||
* ── What it deliberately does NOT do ──
|
||||
*
|
||||
* It does not touch the owner's own token. Returning to yourself is the client putting back what it
|
||||
* kept, so there is no server state to leak, expire or get wrong — and closing the tab is not a way to
|
||||
* be stranded as somebody else.
|
||||
*/
|
||||
export const impersonateUserHandler: Handler = async (ctx) => {
|
||||
const actor = ctx.get('user') as { id: number; email?: string; impersonatedBy?: unknown };
|
||||
|
||||
// Belt to `ownerGate`'s braces: that gate already refuses an impersonated token, and this is the route
|
||||
// where being wrong about it is worst, so it is stated here too rather than assumed one file away.
|
||||
if (actor.impersonatedBy) throw errors.FORBIDDEN('An impersonated session cannot impersonate');
|
||||
|
||||
const targetId = Number(ctx.req.param('id'));
|
||||
if (!Number.isInteger(targetId) || targetId <= 0) throw errors.BAD_REQUEST('A user id is required');
|
||||
if (targetId === actor.id) throw errors.BAD_REQUEST('You are already yourself');
|
||||
|
||||
const target = await getUserById(targetId);
|
||||
if (!target) throw errors.NOT_FOUND('No such user');
|
||||
// The same refusal sign-in makes. Impersonating a blocked account would be a way to use it.
|
||||
if (target.status !== 'Active') throw errors.FORBIDDEN(`That account is ${target.status}`);
|
||||
|
||||
const { id, email, name, username } = target;
|
||||
const token = await sign({ id, email, name, username, impersonatedBy: { id: actor.id, email: actor.email } }, '1h');
|
||||
|
||||
// Same channel and shape as the distress revoke, so both land in one place when somebody asks who did
|
||||
// what. Names both parties: the id in every downstream record from here on will be the TARGET's.
|
||||
console.warn(
|
||||
`[SECURITY] impersonation started — actor=${actor.email ?? actor.id}, target=${email ?? id}, ` +
|
||||
`ip=${clientIp(ctx)} — ${new Date().toISOString()}`,
|
||||
);
|
||||
|
||||
return ctx.json({ token, user: { id, email, name, username }, expiresInMinutes: 60 });
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { createUserHandler } from './create-user';
|
||||
import { provisionLinuxHandler } from './provision-linux-route';
|
||||
import { resetUserPasswordHandler } from './reset-user-password';
|
||||
import { permissionAdminRouter } from './permissions-routes';
|
||||
import { impersonateUserHandler } from './impersonate';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
@@ -23,6 +24,17 @@ usersRouter.put('/', updateUserHandler);
|
||||
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
|
||||
const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
|
||||
/*
|
||||
An impersonated session is not the owner, whoever it is wearing.
|
||||
|
||||
`isSuperAdmin` reads the id on the token, so impersonating another Super Admin would otherwise hand
|
||||
user management straight back — and every action taken there would be recorded against the person
|
||||
impersonated rather than the person doing it. This one line is what stops impersonation being a way
|
||||
to launder owner actions through somebody else's account, and it is also what stops it chaining.
|
||||
*/
|
||||
if ((ctx.get('user') as { impersonatedBy?: unknown }).impersonatedBy) {
|
||||
throw errors.FORBIDDEN('An impersonated session cannot manage users');
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
@@ -36,6 +48,8 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
|
||||
// thing as auth/reset-password.ts, which is the token flow the person runs on themselves.
|
||||
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
|
||||
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
||||
// Sign in AS somebody, for an hour, on a token that says who is really holding it. See impersonate.ts.
|
||||
usersRouter.post('/:id/impersonate', ownerGate, impersonateUserHandler);
|
||||
|
||||
// Which permissions each role holds. Owner-gated inside its own router.
|
||||
usersRouter.route('/', permissionAdminRouter);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UserCog, LogOut } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { isImpersonating, endImpersonation } from './impersonation';
|
||||
|
||||
/**
|
||||
* A bar across the top, for as long as you are somebody else.
|
||||
*
|
||||
* Loud on purpose. The failure this prevents is not a security hole, it is a person forgetting — reading
|
||||
* a mailbox, sending a message or deleting something believing it is theirs. Every action taken here is
|
||||
* recorded against the account being worn, so the only defence is that you cannot miss it.
|
||||
*
|
||||
* It asks the server who it is rather than decoding the token: the token is the thing being tested, and a
|
||||
* banner that reads its own claims would keep showing a name after the hour ran out.
|
||||
*/
|
||||
export const ImpersonationBanner = () => {
|
||||
const client = useClient();
|
||||
const active = isImpersonating();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['auth', 'me', 'impersonation'],
|
||||
queryFn: () => client.get<{ email?: string; name?: string }>('/auth/me'),
|
||||
enabled: active,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-xs font-medium text-black">
|
||||
<UserCog className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
You are signed in as <b>{data?.name ?? data?.email ?? 'another account'}</b>. Everything you do is
|
||||
recorded as theirs.
|
||||
</span>
|
||||
<button
|
||||
onClick={endImpersonation}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-black/15 px-2 py-0.5 transition-colors hover:bg-black/25"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
Back to yourself
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Being somebody else, and getting back.
|
||||
*
|
||||
* ── Why the way back is a stashed token and not a server route ──
|
||||
*
|
||||
* Returning to yourself is putting back the token you already had. There is no server state to expire,
|
||||
* leak or get wrong, and no route that mints an owner token — which would be a far more dangerous thing
|
||||
* to own than this whole feature. The cost is that clearing browser storage strands you as the other
|
||||
* person until their hour runs out; signing in again fixes it, and that is the right trade against a
|
||||
* route that hands out owner sessions.
|
||||
*
|
||||
* ── Why `localStorage` and not `sessionStorage` ──
|
||||
*
|
||||
* The same storage the client reads its token from, so the two cannot disagree, and so a second tab
|
||||
* opened mid-support sees the same account rather than silently being the owner.
|
||||
*/
|
||||
|
||||
const TOKEN_KEYS = ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN'] as const;
|
||||
/** Where the owner's own token waits. Distinct from every key the client reads, so it is never picked up. */
|
||||
const STASH_KEY = 'officer:impersonation:owner-token';
|
||||
|
||||
const writeToken = (token: string) => {
|
||||
for (const key of TOKEN_KEYS) localStorage.setItem(key, token);
|
||||
(window as { officerBearerToken?: string }).officerBearerToken = token;
|
||||
};
|
||||
|
||||
/** True while this browser is wearing somebody else's session. */
|
||||
export const isImpersonating = (): boolean => Boolean(localStorage.getItem(STASH_KEY));
|
||||
|
||||
/**
|
||||
* Swap to the target's token, keeping your own.
|
||||
*
|
||||
* A full reload rather than a re-render: every query in flight, every socket and every cached page
|
||||
* belongs to the previous account. Refetching them piecemeal is how a support view ends up showing two
|
||||
* people's data at once.
|
||||
*/
|
||||
export const startImpersonation = (token: string) => {
|
||||
const mine = localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ?? localStorage.getItem('BEARER_TOKEN');
|
||||
// Refuse rather than stash nothing: without the owner's token there is no way back, and discovering
|
||||
// that after the swap is the worst possible moment.
|
||||
if (!mine) throw new Error('Could not find your own session to return to');
|
||||
// Never overwrite an existing stash — that would be a second hop overwriting the way home.
|
||||
if (!localStorage.getItem(STASH_KEY)) localStorage.setItem(STASH_KEY, mine);
|
||||
writeToken(token);
|
||||
window.location.assign('/');
|
||||
};
|
||||
|
||||
/** Put your own token back and reload. Safe to call when not impersonating: it does nothing. */
|
||||
export const endImpersonation = () => {
|
||||
const mine = localStorage.getItem(STASH_KEY);
|
||||
if (!mine) return;
|
||||
localStorage.removeItem(STASH_KEY);
|
||||
writeToken(mine);
|
||||
window.location.assign('/');
|
||||
};
|
||||
@@ -139,3 +139,7 @@ export type { StoreItem as AppStoreItem } from './apps/AppStore';
|
||||
// docs/localization.md's open question and is not answered by this.
|
||||
export { panelName } from './locales/panelName';
|
||||
export { useTranslation } from 'i18n';
|
||||
|
||||
// Impersonation: the banner the owner cannot miss, and the token swap behind it. See impersonation.ts.
|
||||
export { ImpersonationBanner } from './apps/Impersonation/ImpersonationBanner';
|
||||
export { startImpersonation, endImpersonation, isImpersonating } from './apps/Impersonation/impersonation';
|
||||
|
||||
Reference in New Issue
Block a user