Skip to content

Commit 411ad27

Browse files
matt-aitkenclaude
andcommitted
fix(webapp): restore admin verification on impersonation in getUserId
The previous refactor moved all admin-status verification into getUser, leaving requireUserId-only routes silently operating as the impersonation target if the admin's role was revoked mid-session (or their session expired). Restore the pre-refactor behaviour: on the impersonation path getUserId verifies admin status and enforces the admin's nextSessionEnd, falling back to the admin's own userId when admin is revoked. The non-impersonation fast path stays cookie-only (zero DB queries). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4fa011d commit 411ad27

1 file changed

Lines changed: 34 additions & 31 deletions

File tree

apps/webapp/app/services/session.server.ts

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ import { logger } from "./logger.server";
1212
* The deadline is written at login (and any time the effective duration is
1313
* recomputed — see `commitAuthenticatedSession`) and shortened in bulk when
1414
* an admin lowers an org cap (see the admin `session-duration` route).
15-
* Reading here is a free piggyback on the User row that `requireUser`/
16-
* `getUser` already fetches — there is no per-request DB query added by this
17-
* check. `requireUserId`/`getUserId` deliberately do NOT enforce: enforcement
18-
* happens at the next page navigation (root.tsx loader calls `getUser`),
19-
* which matches HIPAA auto-logoff semantics — terminate sessions at the
20-
* navigation boundary, not on every polling fetch.
15+
* Non-impersonation `requireUserId`/`getUserId` paths do NOT enforce —
16+
* enforcement happens at the next page navigation (root.tsx calls `getUser`),
17+
* which matches HIPAA auto-logoff semantics: terminate at the navigation
18+
* boundary, not on every polling fetch. Impersonation paths DO enforce in
19+
* `getUserId` (against the admin's deadline) so a `requireUserId`-only route
20+
* can't keep operating as the impersonation target after the admin's session
21+
* has expired or their admin role was revoked.
2122
*
2223
* `nextSessionEnd === null` means "no enforced deadline" — applies to legacy
2324
* sessions from before this feature shipped. The default `User.sessionDuration`
@@ -52,41 +53,43 @@ function maybeAutoLogout(
5253
}
5354

5455
export async function getUserId(request: Request): Promise<string | undefined> {
55-
// Cookie-only fast path: zero DB queries. Impersonation admin-verification
56-
// and auto-logout enforcement happen in `getUser`/`requireUser`, where we
57-
// already pay for a User row fetch.
5856
const impersonatedUserId = await getImpersonationId(request);
59-
if (impersonatedUserId) return impersonatedUserId;
6057

61-
const authUser = await authenticator.isAuthenticated(request);
62-
return authUser?.userId;
63-
}
64-
65-
export async function getUser(request: Request) {
66-
const impersonatedUserId = await getImpersonationId(request);
67-
const authUser = await authenticator.isAuthenticated(request);
68-
69-
if (impersonatedUserId && authUser?.userId) {
70-
// Impersonating: verify the real user is still an admin and enforce the
71-
// *admin's* deadline (the cap belongs to the cookie, not the
72-
// impersonation target). If the admin is no longer admin, fall back to
73-
// operating as the admin themselves — same defense-in-depth as before.
58+
if (impersonatedUserId) {
59+
// Impersonating: verify the real user (the admin) is still an admin and
60+
// enforce against their deadline (the cap belongs to the cookie, not the
61+
// target). If admin status was revoked, fall through to operate as the
62+
// admin themselves — otherwise a `requireUserId`-only route would keep
63+
// letting them act as the impersonation target after losing the role.
64+
const authUser = await authenticator.isAuthenticated(request);
65+
if (!authUser?.userId) return undefined;
7466
const realUser = await getUserById(authUser.userId);
75-
if (!realUser) throw await logout(request);
67+
if (!realUser) return authUser.userId;
7668
if (realUser.admin) {
7769
maybeAutoLogout(request, realUser, impersonatedUserId);
78-
const target = await getUserById(impersonatedUserId);
79-
if (!target) throw await logout(request);
80-
return target;
70+
return impersonatedUserId;
8171
}
8272
maybeAutoLogout(request, realUser);
83-
return realUser;
73+
return authUser.userId;
8474
}
8575

86-
if (!authUser?.userId) return null;
87-
const user = await getUserById(authUser.userId);
76+
// Non-impersonation fast path: zero DB queries. Auto-logout enforcement
77+
// for this path happens in `getUser`, where we already pay for the User
78+
// row fetch. `requireUserId` callers stay cookie-only.
79+
const authUser = await authenticator.isAuthenticated(request);
80+
return authUser?.userId;
81+
}
82+
83+
export async function getUser(request: Request) {
84+
const userId = await getUserId(request);
85+
if (userId === undefined) return null;
86+
const user = await getUserById(userId);
8887
if (!user) throw await logout(request);
89-
maybeAutoLogout(request, user);
88+
// Auto-logout for the non-impersonation path. The impersonation path was
89+
// already enforced inside `getUserId` against the admin's deadline, so
90+
// skip re-checking against the (impersonation target's) row here.
91+
const impersonationId = await getImpersonationId(request);
92+
if (!impersonationId) maybeAutoLogout(request, user);
9093
return user;
9194
}
9295

0 commit comments

Comments
 (0)