Skip to content

Commit 539e792

Browse files
committed
fix(webapp): let an admin switch impersonation target without stopping first
getUserId resolves to the impersonated user id while impersonating, by design, so requireUser answers "who is this request acting as". Every impersonation entry point gated on it, so while impersonating a customer user.admin was that customer's flag and starting on a second target silently redirected to / — you had to stop impersonating first. - New getRealUser resolves the authenticated user, ignoring the impersonation cookie, and applies the same session controls getUserId does for the real user (SSO revalidation and the auto-logout deadline) so this can't become a way around them. - redirectWithImpersonation gates on it rather than taking a user from the caller, and attributes the audit row to the real admin. - The route moves to admin_.impersonate.tsx to opt out of the admin layout, whose requireSuper gate resolves the same impersonated identity. It checks canSuper() against the real admin directly, since the raw User.admin column only equals canSuper() in the OSS fallback. - Unauthenticated requests redirect to login carrying the original URL, so the impersonation link survives the round trip. - The view-as-user flag is cleared when the target changes; it is scoped to a single impersonation session. Switching straight between targets never passes through clearImpersonation, so a STOP for the previous target is written alongside the new START, both in one transaction with explicit timestamps — Postgres now() is the transaction timestamp, so the default would stamp both rows identically.
1 parent 4569657 commit 539e792

6 files changed

Lines changed: 216 additions & 78 deletions

File tree

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

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { redirect } from "@remix-run/server-runtime";
2-
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
2+
import { $replica, $transaction, prisma, type PrismaClientOrTransaction } from "~/db.server";
33
import { logger } from "~/services/logger.server";
44
import type { SearchParams } from "~/routes/admin._index";
55
import {
@@ -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 } from "~/services/session.server";
1313
import { extractClientIp } from "~/utils/extractClientIp.server";
1414
import { impersonationDestinationPath } from "~/utils/pathBuilder";
1515

@@ -210,35 +210,76 @@ 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);
239+
240+
// Switching straight from one target to another never passes through `clearImpersonation`, so the
241+
// previous session is closed here, or the trail shows two overlapping STARTs.
242+
//
243+
// Both rows are written in one transaction: as separate statements, a failure between them could
244+
// start an impersonation whose only audit row is the STOP for the previous target — an admin
245+
// acting as someone with no record of it.
246+
//
247+
// `createdAt` is stamped explicitly rather than left to `@default(now())`, because Postgres `now()`
248+
// is the *transaction* timestamp: inside one transaction both rows would take the same value, and
249+
// an audit view ordered by that column couldn't tell which came first.
250+
const startedAt = new Date();
251+
const closedAt = new Date(startedAt.getTime() - 1);
227252

228253
try {
229-
await prismaClient.impersonationAuditLog.create({
230-
data: {
231-
action: "START",
232-
adminId: user.id,
233-
targetId: userId,
234-
ipAddress,
235-
},
254+
await $transaction(prismaClient, "startImpersonationAudit", async (tx) => {
255+
if (previousTargetId && previousTargetId !== userId) {
256+
await tx.impersonationAuditLog.create({
257+
data: {
258+
action: "STOP",
259+
adminId: admin.id,
260+
targetId: previousTargetId,
261+
ipAddress,
262+
createdAt: closedAt,
263+
},
264+
});
265+
}
266+
267+
await tx.impersonationAuditLog.create({
268+
data: {
269+
action: "START",
270+
adminId: admin.id,
271+
targetId: userId,
272+
ipAddress,
273+
createdAt: startedAt,
274+
},
275+
});
236276
});
237277
} catch (error) {
238278
logger.error("Failed to create impersonation audit log", {
239279
error,
240-
adminId: user.id,
280+
adminId: admin.id,
241281
targetId: userId,
282+
previousTargetId,
242283
});
243284
}
244285

@@ -308,7 +349,8 @@ export async function startImpersonation(
308349
request: Request,
309350
organizationSlug: string,
310351
path: string,
311-
currentUser: { id: string; admin: boolean },
352+
// Test-only, forwarded to `redirectWithImpersonation` — see its docstring.
353+
verifiedAdmin?: { id: string; admin: boolean },
312354
clients: { read: PrismaClientOrTransaction; write: PrismaClientOrTransaction } = {
313355
read: $replica,
314356
write: prisma,
@@ -325,7 +367,7 @@ export async function startImpersonation(
325367
request,
326368
target.userId,
327369
impersonationDestinationPath(organizationSlug, path, new URL(request.url).search),
328-
currentUser,
370+
verifiedAdmin,
329371
clients.write
330372
);
331373
}

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: 0 additions & 61 deletions
This file was deleted.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {
2+
redirect,
3+
type ActionFunctionArgs,
4+
type LoaderFunctionArgs,
5+
} from "@remix-run/server-runtime";
6+
import { z } from "zod";
7+
import { redirectWithImpersonation } from "~/models/admin.server";
8+
import { authenticator } from "~/services/auth.server";
9+
import { rbac } from "~/services/rbac.server";
10+
import { getRealUser } from "~/services/session.server";
11+
import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server";
12+
import { logger } from "~/services/logger.server";
13+
import { sanitizeRedirectPath } from "~/utils";
14+
15+
/**
16+
* Served at `/admin/impersonate`, but the trailing `_` on `admin_` keeps it out of the `admin.tsx`
17+
* layout on purpose.
18+
*
19+
* That layout's loader is `dashboardLoader({ authorization: { requireSuper: true } })`, which
20+
* resolves the user through `getUserId` — the impersonated id while impersonating. So starting on a
21+
* second target ran the parent gate against the target, which isn't a super admin, and it answered
22+
* with its own `redirect("/")`. Nesting would leave this route's behaviour depending on the router
23+
* preferring the deepest redirect; opting out removes the question. Nothing is lost — this route
24+
* only ever redirects, so it never rendered inside the layout anyway.
25+
*/
26+
27+
const FormSchema = z.object({ id: z.string() });
28+
29+
/**
30+
* The real authenticated user, or null when they're signed in but not an admin.
31+
*
32+
* Must not use `requireUser`: while impersonating it resolves to the impersonation target, whose
33+
* `admin` is false, so an admin switching to a second target was bounced to `/` and left on the
34+
* first one.
35+
*
36+
* Throws a login redirect when nobody is signed in, keeping this URL as `redirectTo` so the
37+
* impersonation survives the round trip — the one-time token is validated after this gate, so it's
38+
* still unconsumed when the browser comes back. Collapsing that into the non-admin `/` redirect
39+
* would drop the link the agent clicked.
40+
*/
41+
async function requireRealAdmin(request: Request) {
42+
if (!(await authenticator.isAuthenticated(request))) {
43+
const url = new URL(request.url);
44+
const redirectTo = sanitizeRedirectPath(`${url.pathname}${url.search}`);
45+
throw redirect(`/login?${new URLSearchParams([["redirectTo", redirectTo]])}`);
46+
}
47+
48+
const admin = await getRealUser(request);
49+
if (!admin) return null;
50+
51+
// Same gate `dashboardLoader({ authorization: { requireSuper: true } })` applies, evaluated
52+
// against the real admin. It can't be reached through the builder here, because the builder
53+
// resolves its subject with `getUserId` — the impersonated id while impersonating, which is the
54+
// bug this route exists to fix. So the ability is built explicitly for `admin.id` instead of
55+
// trusting the raw `User.admin` column: `canSuper()` is only equal to that column in the OSS
56+
// fallback, and a plugin is free to be stricter. requireSuper needs no org/project scope.
57+
const auth = await rbac.authenticateSession(request, { userId: admin.id });
58+
if (!auth.ok || !auth.ability.canSuper()) return null;
59+
60+
return admin;
61+
}
62+
63+
async function handleImpersonationRequest(request: Request, userId: string): Promise<Response> {
64+
const admin = await requireRealAdmin(request);
65+
if (!admin) {
66+
return redirect("/");
67+
}
68+
return redirectWithImpersonation(request, userId, "/");
69+
}
70+
71+
export const loader = async ({ request }: LoaderFunctionArgs) => {
72+
const url = new URL(request.url);
73+
const impersonateUserId = url.searchParams.get("impersonate");
74+
const impersonationToken = url.searchParams.get("impersonationToken");
75+
76+
if (!impersonateUserId) {
77+
return redirect("/admin");
78+
}
79+
80+
if (!impersonationToken) {
81+
logger.warn("Impersonation request missing token");
82+
return redirect("/");
83+
}
84+
85+
// Check admin BEFORE consuming the one-time token, so a rejected request leaves the token usable.
86+
const admin = await requireRealAdmin(request);
87+
if (!admin) {
88+
return redirect("/");
89+
}
90+
91+
const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken);
92+
93+
if (!validatedUserId || validatedUserId !== impersonateUserId) {
94+
logger.warn("Invalid or expired impersonation token");
95+
return redirect("/");
96+
}
97+
98+
return redirectWithImpersonation(request, impersonateUserId, "/");
99+
};
100+
101+
export async function action({ request }: ActionFunctionArgs) {
102+
if (request.method.toLowerCase() !== "post") {
103+
return new Response("Method not allowed", { status: 405 });
104+
}
105+
106+
const payload = Object.fromEntries(await request.formData());
107+
const { id } = FormSchema.parse(payload);
108+
109+
return handleImpersonationRequest(request, id);
110+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ export async function getImpersonationId(request: Request) {
4545
export async function setImpersonationId(userId: string, request: Request) {
4646
const session = await getImpersonationSession(request);
4747

48+
// Switching straight to a different target begins a new impersonation session, so the view-as-user
49+
// flag must not carry over from the previous one — it's scoped to a single impersonation, which is
50+
// why `clearImpersonationId` drops it too. Reachable only since switching stopped requiring a stop
51+
// first; before that, every second target arrived via `clearImpersonationId`.
52+
if (session.get(IMPERSONATED_USER_ID_KEY) !== userId) {
53+
session.unset(VIEWING_AS_USER_KEY);
54+
}
55+
4856
session.set(IMPERSONATED_USER_ID_KEY, userId);
4957

5058
return session;

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

Lines changed: 39 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,44 @@ 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+
145+
// Apply the same session controls `getUserId`/`getUser` apply to the real user, so this helper
146+
// can't become a way around them: a session the IdP has revoked throws to /logout here, and one
147+
// past its effective duration is caught below. Skipping either would let an admin whose session
148+
// should have ended still start impersonation.
149+
await revalidateSsoSession(request, authUser);
150+
if (!authUser?.userId) return null;
151+
152+
// Narrow select — callers need the id and the admin flag, plus `nextSessionEnd` for the deadline
153+
// check. Takes a client so a caller already scoped to one reads the admin from the same database
154+
// it writes to.
155+
const user = await prismaClient.user.findFirst({
156+
where: { id: authUser.userId },
157+
select: { id: true, admin: true, nextSessionEnd: true },
158+
});
159+
if (!user) return null;
160+
161+
maybeAutoLogout(request, user);
162+
163+
return user;
164+
}
165+
127166
export type UserFromSession = Awaited<ReturnType<typeof requireUser>>;
128167

129168
export async function requireUser(request: Request) {

0 commit comments

Comments
 (0)