Skip to content

Commit 5dc5f70

Browse files
committed
fix(webapp): let an admin switch impersonation target without stopping first
1 parent cb6088c commit 5dc5f70

4 files changed

Lines changed: 91 additions & 24 deletions

File tree

apps/webapp/app/models/admin.server.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
setImpersonationId,
1010
} from "~/services/impersonation.server";
1111
import { authenticator } from "~/services/auth.server";
12-
import { requireUser } from "~/services/session.server";
12+
import { getRealUser, requireUser } from "~/services/session.server";
1313
import { extractClientIp } from "~/utils/extractClientIp.server";
1414
import { impersonationDestinationPath } from "~/utils/pathBuilder";
1515

@@ -210,35 +210,62 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se
210210
};
211211
}
212212

213+
/**
214+
* Starts (or switches) impersonation.
215+
*
216+
* The admin gate resolves the *real* authenticated user itself. `requireUser` returns the
217+
* impersonation target while impersonating, so callers that gated on it refused an admin who was
218+
* already impersonating someone — they had to stop first — and would have attributed the audit row
219+
* to the target rather than the admin.
220+
*
221+
* `verifiedAdmin` exists only so tests can supply an admin without a session cookie. Production
222+
* callers must not pass it: passing a `requireUser` result is exactly the bug described above.
223+
*/
213224
export async function redirectWithImpersonation(
214225
request: Request,
215226
userId: string,
216227
path: string,
217-
currentUser?: { id: string; admin: boolean },
228+
verifiedAdmin?: { id: string; admin: boolean },
218229
prismaClient: PrismaClientOrTransaction = prisma
219230
) {
220-
const user = currentUser ?? (await requireUser(request));
221-
if (!user.admin) {
231+
const admin = verifiedAdmin ?? (await getRealUser(request, prismaClient));
232+
if (!admin?.admin) {
222233
throw new Error("Unauthorized");
223234
}
224235

225236
const xff = request.headers.get("x-forwarded-for");
226237
const ipAddress = extractClientIp(xff);
238+
const previousTargetId = await getImpersonationId(request);
227239

228240
try {
229-
await prismaClient.impersonationAuditLog.create({
230-
data: {
231-
action: "START",
232-
adminId: user.id,
233-
targetId: userId,
234-
ipAddress,
235-
},
241+
await prismaClient.impersonationAuditLog.createMany({
242+
data: [
243+
// Switching straight from one target to another never passes through `clearImpersonation`,
244+
// so close the previous session here or the trail shows two overlapping STARTs.
245+
...(previousTargetId && previousTargetId !== userId
246+
? [
247+
{
248+
action: "STOP" as const,
249+
adminId: admin.id,
250+
targetId: previousTargetId,
251+
ipAddress,
252+
},
253+
]
254+
: []),
255+
{
256+
action: "START" as const,
257+
adminId: admin.id,
258+
targetId: userId,
259+
ipAddress,
260+
},
261+
],
236262
});
237263
} catch (error) {
238264
logger.error("Failed to create impersonation audit log", {
239265
error,
240-
adminId: user.id,
266+
adminId: admin.id,
241267
targetId: userId,
268+
previousTargetId,
242269
});
243270
}
244271

@@ -308,7 +335,8 @@ export async function startImpersonation(
308335
request: Request,
309336
organizationSlug: string,
310337
path: string,
311-
currentUser: { id: string; admin: boolean },
338+
// Test-only, forwarded to `redirectWithImpersonation` — see its docstring.
339+
verifiedAdmin?: { id: string; admin: boolean },
312340
clients: { read: PrismaClientOrTransaction; write: PrismaClientOrTransaction } = {
313341
read: $replica,
314342
write: prisma,
@@ -325,7 +353,7 @@ export async function startImpersonation(
325353
request,
326354
target.userId,
327355
impersonationDestinationPath(organizationSlug, path, new URL(request.url).search),
328-
currentUser,
356+
verifiedAdmin,
329357
clients.write
330358
);
331359
}

apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
6161
// the consent page below instead, whose "Impersonate" button posts back from
6262
// our own page and so satisfies the same check.
6363
if (isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
64-
throw await startImpersonation(request, organizationSlug, path, user);
64+
throw await startImpersonation(request, organizationSlug, path);
6565
}
6666

6767
// Expected for any link opened outside the app (address bar, bookmark, a link
@@ -148,7 +148,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
148148
// The consent form posts to an explicit absolute path (see
149149
// `impersonationConsentPostBackPath`), so the organization slug, the splat
150150
// path and the query string all arrive here intact.
151-
return startImpersonation(request, organizationSlug, params["*"] ?? "", user);
151+
return startImpersonation(request, organizationSlug, params["*"] ?? "");
152152
}
153153

154154
export default function Page() {

apps/webapp/app/routes/admin.impersonate.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,30 @@ import {
55
} from "@remix-run/server-runtime";
66
import { z } from "zod";
77
import { redirectWithImpersonation } from "~/models/admin.server";
8-
import { requireUser } from "~/services/session.server";
8+
import { getRealUser } from "~/services/session.server";
99
import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server";
1010
import { logger } from "~/services/logger.server";
1111

1212
const FormSchema = z.object({ id: z.string() });
1313

14+
/**
15+
* The real authenticated user, or null when they aren't an admin.
16+
*
17+
* Must not use `requireUser`: while impersonating it resolves to the impersonation target, whose
18+
* `admin` is false, so an admin switching to a second target was bounced to `/` and left on the
19+
* first one.
20+
*/
21+
async function requireRealAdmin(request: Request) {
22+
const admin = await getRealUser(request);
23+
return admin?.admin ? admin : null;
24+
}
25+
1426
async function handleImpersonationRequest(request: Request, userId: string): Promise<Response> {
15-
const user = await requireUser(request);
16-
if (!user.admin) {
27+
const admin = await requireRealAdmin(request);
28+
if (!admin) {
1729
return redirect("/");
1830
}
19-
return redirectWithImpersonation(request, userId, "/", user);
31+
return redirectWithImpersonation(request, userId, "/");
2032
}
2133

2234
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -33,9 +45,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
3345
return redirect("/");
3446
}
3547

36-
// Check admin BEFORE consuming the one-time token
37-
const user = await requireUser(request);
38-
if (!user.admin) {
48+
// Check admin BEFORE consuming the one-time token, so a rejected request leaves the token usable.
49+
const admin = await requireRealAdmin(request);
50+
if (!admin) {
3951
return redirect("/");
4052
}
4153

@@ -46,7 +58,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
4658
return redirect("/");
4759
}
4860

49-
return redirectWithImpersonation(request, impersonateUserId, "/", user);
61+
return redirectWithImpersonation(request, impersonateUserId, "/");
5062
};
5163

5264
export async function action({ request }: ActionFunctionArgs) {

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { redirect } from "@remix-run/node";
2+
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
23
import { getUserById } from "~/models/user.server";
34
import { sanitizeRedirectPath } from "~/utils";
45
import { extractClientIp } from "~/utils/extractClientIp.server";
@@ -124,6 +125,32 @@ export async function requireUserId(request: Request, redirectTo?: string) {
124125
return userId;
125126
}
126127

128+
/**
129+
* The user the request actually authenticated as, ignoring any impersonation cookie.
130+
*
131+
* `getUserId` deliberately resolves to the *impersonated* id while impersonating, so `getUser` /
132+
* `requireUser` answer "who is this request acting as". That is the wrong question for anything
133+
* gating on admin rights or attributing an admin action: while impersonating a customer,
134+
* `requireUser().admin` is that customer's flag, so an admin check silently fails and an audit
135+
* record would name the customer as the actor.
136+
*
137+
* Returns null when unauthenticated or the row is gone.
138+
*/
139+
export async function getRealUser(
140+
request: Request,
141+
prismaClient: PrismaClientOrTransaction = prisma
142+
) {
143+
const authUser = await authenticator.isAuthenticated(request);
144+
if (!authUser?.userId) return null;
145+
146+
// Narrow select: callers only ever need the id and the admin flag. Takes a client so a caller
147+
// already scoped to one reads the admin from the same database it writes to.
148+
return prismaClient.user.findFirst({
149+
where: { id: authUser.userId },
150+
select: { id: true, admin: true },
151+
});
152+
}
153+
127154
export type UserFromSession = Awaited<ReturnType<typeof requireUser>>;
128155

129156
export async function requireUser(request: Request) {

0 commit comments

Comments
 (0)