From fb0cc880acbe8cc569d497d2050b61ad496bc5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 28 Aug 2026 21:51:20 +0100 Subject: [PATCH] sign in as a user, from user management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../Dashboard/Layout/DashboardLayout.tsx | 5 +- .../Settings/UserManagement/UsersSection.tsx | 47 ++++++++++++- src/servers/api/users/impersonate.ts | 70 +++++++++++++++++++ src/servers/api/users/users-router.ts | 14 ++++ .../Impersonation/ImpersonationBanner.tsx | 45 ++++++++++++ .../src/apps/Impersonation/impersonation.ts | 55 +++++++++++++++ src/workspaces/officerdev/src/index.ts | 4 ++ 7 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/servers/api/users/impersonate.ts create mode 100644 src/workspaces/officerdev/src/apps/Impersonation/ImpersonationBanner.tsx create mode 100644 src/workspaces/officerdev/src/apps/Impersonation/impersonation.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index e5e86585..6aa341d8 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -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 (
+ {/* 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. */} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Impersonation/impersonation.ts b/src/workspaces/officerdev/src/apps/Impersonation/impersonation.ts new file mode 100644 index 00000000..ae084bf6 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Impersonation/impersonation.ts @@ -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('/'); +}; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index bdc88a1a..ce0b4804 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -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';