Skip to content

Commit 6807b68

Browse files
committed
fix(webapp): clear impersonation before the consent action's admin check
Two review findings, both about "who is this request" being answered differently in different places. The consent page's action went straight from requireUser to the admin check. requireUser resolves to the impersonated user whenever the impersonation cookie is set, so user.admin was false for a legitimate admin who already had an impersonation session: render the consent page with nothing active, start impersonating in another tab, then press Impersonate on the still-open page, and the POST redirected to / with no explanation. The action now mirrors the loader's ordering — clear the impersonation first and come back to the same URL (search included) under the admin's own identity, where the normal flow re-authorizes them and they can confirm. The impersonation mutation never runs on a request whose resolved identity is the impersonated user. The view-as-user flag had two definitions. requireUser required the impersonated id to match the resolved user; the root loader only required that some impersonated id was present in the session. Those disagree in exactly one state: getUserId deliberately falls back to the real admin's id when they are no longer an admin, so the cookie still names a target that is not the resolved user. The client-side flag read true while the server-computed user.isViewingAsUser read false, and client-hidden admin UI drifted from server-computed display flags. The strict rule now lives in one place, resolveImpersonationState, with getImpersonationState reading the cookie and delegating to it. requireUser, the root loader and the bulk-action loader all go through it, so the value published to the client is the value the server computed. A de-admined session correctly reports neither impersonating nor viewing as user, which also restores its admin chrome — right, since it is not an admin session any more. The organization loader's own isImpersonating is left as it was; it is pre-existing and nothing here depends on it. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a24b49f commit 6807b68

8 files changed

Lines changed: 197 additions & 26 deletions

apps/webapp/app/root.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { TimezoneSetter } from "./components/TimezoneSetter";
1919
import { env } from "./env.server";
2020
import { featuresForRequest } from "./features.server";
2121
import { usePostHog } from "./hooks/usePostHog";
22-
import { getViewingAsUser } from "./services/impersonation.server";
22+
import { getImpersonationState } from "./services/impersonation.server";
2323
import { getUser } from "./services/session.server";
2424
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
2525
import { appEnvTitleTag } from "./utils";
@@ -74,7 +74,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
7474
// Display-only: while impersonating, an admin can ask to see the dashboard
7575
// the way the impersonated user sees it. Exposed from root so every route can
7676
// read it.
77-
const isViewingAsUser = await getViewingAsUser(request);
77+
//
78+
// Resolved against the user this request authenticated as, which is the same
79+
// condition `requireUser` applies — otherwise the flag the client reads and
80+
// the `user.isViewingAsUser` the server computes could disagree, and the
81+
// client-side admin UI would hide itself on a session that is not
82+
// impersonating.
83+
const { isViewingAsUser } = await getImpersonationState(request, user?.id);
7884

7985
const headers = new Headers();
8086
headers.append("Set-Cookie", await commitSession(session));

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,22 @@ export async function action({ request, params }: ActionFunctionArgs) {
107107

108108
const user = await requireUser(request);
109109

110+
// Same ordering as the loader, and it matters for the same reason: while an
111+
// impersonation cookie is set `requireUser` resolves to the *impersonated*
112+
// user, so `user.admin` is false even for a legitimate admin. An admin who
113+
// started impersonating in another tab and then submitted this page would
114+
// fail the check below and be bounced to `/` with no explanation. Clear the
115+
// impersonation first and come back to this same URL under their own
116+
// identity, where the normal flow re-authorizes them and they can confirm.
117+
// Never run the impersonation mutation on a request whose resolved identity
118+
// is the impersonated user.
119+
if (user.isImpersonating) {
120+
const url = new URL(request.url);
121+
// Keep the search for the same reason the loader does: the follow-up GET
122+
// rebuilds the destination and post-back paths from it.
123+
return clearImpersonation(request, `${url.pathname}${url.search}`);
124+
}
125+
110126
if (!user.admin) {
111127
return redirect("/");
112128
}

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPrese
5656
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
5757
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
5858
import { logger } from "~/services/logger.server";
59-
import { getViewingAsUser } from "~/services/impersonation.server";
59+
import { getImpersonationState } from "~/services/impersonation.server";
6060
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
6161
import { hasAdminDisplayAccess } from "~/services/session.server";
6262
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
@@ -87,11 +87,11 @@ export const loader = dashboardLoader(
8787
}
8888

8989
// Display only: hidden regions stay listed for admins, unless they've asked
90-
// to see the dashboard the way the impersonated user sees it.
91-
const isAdmin = hasAdminDisplayAccess({
92-
...user,
93-
isViewingAsUser: await getViewingAsUser(request),
94-
});
90+
// to see the dashboard the way the impersonated user sees it. The route
91+
// builder's `user` doesn't carry the flag, so read it off the session with
92+
// the same strict condition every other consumer uses.
93+
const { isViewingAsUser } = await getImpersonationState(request, user.id);
94+
const isAdmin = hasAdminDisplayAccess({ ...user, isViewingAsUser });
9595

9696
const presenter = new CreateBulkActionPresenter();
9797
const [data, regionsResult] = await Promise.all([

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { singleton } from "~/utils/singleton";
55
import { createRedisClient, type RedisClient } from "~/redis.server";
66
import { env } from "~/env.server";
77
import { logger } from "~/services/logger.server";
8+
import { resolveImpersonationState, type ImpersonationState } from "~/utils/impersonationState";
89

910
export const impersonationSessionStorage = createCookieSessionStorage({
1011
cookie: {
@@ -61,16 +62,25 @@ export async function clearImpersonationId(request: Request) {
6162
}
6263

6364
/**
64-
* Whether the admin has asked to see the dashboard the way the impersonated
65-
* user sees it. Only ever true inside an impersonation session.
65+
* The impersonation state for a request, resolved against `resolvedUserId` — the
66+
* id the request actually authenticated as (what `getUser`/`getUserId` return).
67+
*
68+
* This is the one place the impersonation flags come from, so the values the
69+
* server computes for a route and the value the root loader publishes to the
70+
* client cannot disagree. See `resolveImpersonationState` for why the
71+
* impersonated id has to match the resolved user rather than merely be present.
6672
*/
67-
export async function getViewingAsUser(request: Request) {
73+
export async function getImpersonationState(
74+
request: Request,
75+
resolvedUserId: string | undefined
76+
): Promise<ImpersonationState> {
6877
const session = await getImpersonationSession(request);
6978

70-
return (
71-
typeof session.get(IMPERSONATED_USER_ID_KEY) === "string" &&
72-
session.get(VIEWING_AS_USER_KEY) === true
73-
);
79+
return resolveImpersonationState({
80+
impersonatedUserId: session.get(IMPERSONATED_USER_ID_KEY),
81+
viewingAsUser: session.get(VIEWING_AS_USER_KEY),
82+
resolvedUserId,
83+
});
7484
}
7585

7686
export async function setViewingAsUser(value: boolean, request: Request) {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getUserById } from "~/models/user.server";
33
import { sanitizeRedirectPath } from "~/utils";
44
import { extractClientIp } from "~/utils/extractClientIp.server";
55
import { authenticator } from "./auth.server";
6-
import { getImpersonationId, getViewingAsUser } from "./impersonation.server";
6+
import { getImpersonationId, getImpersonationState } from "./impersonation.server";
77
import { logger } from "./logger.server";
88
import { revalidateSsoSession } from "./ssoSessionRevalidation.server";
99

@@ -135,8 +135,9 @@ export async function requireUser(request: Request) {
135135
throw redirect(`/login?${searchParams}`);
136136
}
137137

138-
const impersonationId = await getImpersonationId(request);
139-
const isImpersonating = !!impersonationId && impersonationId === user.id;
138+
// Shared with the root loader so the client never reads a different answer
139+
// than the one computed here.
140+
const { isImpersonating, isViewingAsUser } = await getImpersonationState(request, user.id);
140141
return {
141142
id: user.id,
142143
email: user.email,
@@ -150,7 +151,7 @@ export async function requireUser(request: Request) {
150151
confirmedBasicDetails: user.confirmedBasicDetails,
151152
mfaEnabledAt: user.mfaEnabledAt,
152153
isImpersonating,
153-
isViewingAsUser: isImpersonating && (await getViewingAsUser(request)),
154+
isViewingAsUser,
154155
};
155156
}
156157

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveImpersonationState } from "./impersonationState";
3+
4+
const IMPERSONATED = "user_1";
5+
const ADMIN = "admin_1";
6+
7+
describe("resolveImpersonationState", () => {
8+
it("reports impersonation when the cookie's id is the resolved user", () => {
9+
expect(
10+
resolveImpersonationState({
11+
impersonatedUserId: IMPERSONATED,
12+
viewingAsUser: undefined,
13+
resolvedUserId: IMPERSONATED,
14+
})
15+
).toEqual({ isImpersonating: true, isViewingAsUser: false });
16+
});
17+
18+
it("carries the view-as-user flag inside an impersonation session", () => {
19+
expect(
20+
resolveImpersonationState({
21+
impersonatedUserId: IMPERSONATED,
22+
viewingAsUser: true,
23+
resolvedUserId: IMPERSONATED,
24+
})
25+
).toEqual({ isImpersonating: true, isViewingAsUser: true });
26+
});
27+
28+
// The case the strict comparison exists for: the session falls back to the
29+
// real admin's id when their admin role is revoked mid-session, while the
30+
// cookie still names the impersonation target. Both flags must read false so
31+
// the server-side values and the value published to the client agree.
32+
it("reports neither flag when the impersonated id is not the resolved user", () => {
33+
expect(
34+
resolveImpersonationState({
35+
impersonatedUserId: IMPERSONATED,
36+
viewingAsUser: true,
37+
resolvedUserId: ADMIN,
38+
})
39+
).toEqual({ isImpersonating: false, isViewingAsUser: false });
40+
});
41+
42+
it("reports neither flag when there is no impersonated id", () => {
43+
expect(
44+
resolveImpersonationState({
45+
impersonatedUserId: undefined,
46+
viewingAsUser: true,
47+
resolvedUserId: IMPERSONATED,
48+
})
49+
).toEqual({ isImpersonating: false, isViewingAsUser: false });
50+
});
51+
52+
it("reports neither flag when there is no resolved user", () => {
53+
expect(
54+
resolveImpersonationState({
55+
impersonatedUserId: IMPERSONATED,
56+
viewingAsUser: true,
57+
resolvedUserId: undefined,
58+
})
59+
).toEqual({ isImpersonating: false, isViewingAsUser: false });
60+
});
61+
62+
it("only treats a literal true as the view-as-user flag", () => {
63+
expect(
64+
resolveImpersonationState({
65+
impersonatedUserId: IMPERSONATED,
66+
viewingAsUser: "true",
67+
resolvedUserId: IMPERSONATED,
68+
}).isViewingAsUser
69+
).toBe(false);
70+
});
71+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* The rule for reading impersonation state off the impersonation cookie.
3+
*
4+
* Kept pure and free of server-only imports so it can be unit tested directly,
5+
* and so there is exactly one definition of "this request is impersonating" for
6+
* every caller to share.
7+
*/
8+
9+
export type ImpersonationState = {
10+
isImpersonating: boolean;
11+
isViewingAsUser: boolean;
12+
};
13+
14+
/**
15+
* Resolves the impersonation cookie's raw contents against the identity the
16+
* request actually authenticated as.
17+
*
18+
* Matching the impersonated id against `resolvedUserId` is deliberate. When an
19+
* admin's role is revoked mid-session the session falls back to the real admin's
20+
* id while the cookie still names the impersonation target, so "an impersonated
21+
* id is present" and "this request is impersonating" stop meaning the same
22+
* thing. Only the strict reading is correct there: that session is no longer
23+
* impersonating, and so it is not viewing as the user either.
24+
*
25+
* Every consumer has to agree on this, or the flags computed on the server and
26+
* the flag published to the client drift apart — the admin chrome would hide
27+
* itself on a session that is not impersonating at all.
28+
*/
29+
export function resolveImpersonationState(options: {
30+
impersonatedUserId: unknown;
31+
viewingAsUser: unknown;
32+
resolvedUserId: string | undefined;
33+
}): ImpersonationState {
34+
const { impersonatedUserId, viewingAsUser, resolvedUserId } = options;
35+
36+
const isImpersonating =
37+
typeof impersonatedUserId === "string" &&
38+
resolvedUserId !== undefined &&
39+
impersonatedUserId === resolvedUserId;
40+
41+
return {
42+
isImpersonating,
43+
// Display only, and meaningless outside an impersonation session, so it
44+
// never reads as on without one.
45+
isViewingAsUser: isImpersonating && viewingAsUser === true,
46+
};
47+
}

apps/webapp/test/viewAsUser.test.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
clearImpersonationId,
44
commitImpersonationSession,
55
getImpersonationId,
6-
getViewingAsUser,
6+
getImpersonationState,
77
setImpersonationId,
88
setViewingAsUser,
99
} from "~/services/impersonation.server";
@@ -19,27 +19,33 @@ async function requestWith(session: Session) {
1919
});
2020
}
2121

22+
// Reads the flag the way a request does: against the user the request resolved
23+
// as. Every fixture below impersonates `user_1`.
24+
async function readViewingAsUser(request: Request) {
25+
return (await getImpersonationState(request, "user_1")).isViewingAsUser;
26+
}
27+
2228
// The "view as user" flag rides on the impersonation cookie, so it is scoped to
2329
// the impersonation session by construction.
2430
describe("view as user flag", () => {
2531
it("is off when there is no impersonation cookie", async () => {
26-
expect(await getViewingAsUser(new Request("http://localhost:3030/orgs/acme"))).toBe(false);
32+
expect(await readViewingAsUser(new Request("http://localhost:3030/orgs/acme"))).toBe(false);
2733
});
2834

2935
it("can be turned on and back off within an impersonation session", async () => {
3036
const start = new Request("http://localhost:3030/orgs/acme");
3137

3238
const impersonating = await requestWith(await setImpersonationId("user_1", start));
3339
expect(await getImpersonationId(impersonating)).toBe("user_1");
34-
expect(await getViewingAsUser(impersonating)).toBe(false);
40+
expect(await readViewingAsUser(impersonating)).toBe(false);
3541

3642
const viewingAsUser = await requestWith(await setViewingAsUser(true, impersonating));
3743
expect(await getImpersonationId(viewingAsUser)).toBe("user_1");
38-
expect(await getViewingAsUser(viewingAsUser)).toBe(true);
44+
expect(await readViewingAsUser(viewingAsUser)).toBe(true);
3945

4046
const showingAdminUi = await requestWith(await setViewingAsUser(false, viewingAsUser));
4147
expect(await getImpersonationId(showingAdminUi)).toBe("user_1");
42-
expect(await getViewingAsUser(showingAdminUi)).toBe(false);
48+
expect(await readViewingAsUser(showingAdminUi)).toBe(false);
4349
});
4450

4551
it("is dropped when impersonation is cleared", async () => {
@@ -50,15 +56,29 @@ describe("view as user flag", () => {
5056
const cleared = await requestWith(await clearImpersonationId(viewingAsUser));
5157

5258
expect(await getImpersonationId(cleared)).toBeUndefined();
53-
expect(await getViewingAsUser(cleared)).toBe(false);
59+
expect(await readViewingAsUser(cleared)).toBe(false);
5460
});
5561

5662
it("never reads as on without an impersonated user", async () => {
5763
const start = new Request("http://localhost:3030/orgs/acme");
5864
// Set the flag with no impersonation in progress — it must not read back on.
5965
const flagOnly = await requestWith(await setViewingAsUser(true, start));
6066

61-
expect(await getViewingAsUser(flagOnly)).toBe(false);
67+
expect(await readViewingAsUser(flagOnly)).toBe(false);
68+
});
69+
70+
it("is off once the impersonated user is not who the request resolved as", async () => {
71+
const start = new Request("http://localhost:3030/orgs/acme");
72+
const impersonating = await requestWith(await setImpersonationId("user_1", start));
73+
const viewing = await requestWith(await setViewingAsUser(true, impersonating));
74+
75+
// An admin who loses the admin role mid-session resolves back to their own
76+
// id while the cookie still names the target. That session is not
77+
// impersonating any more, so it is not viewing as the user either.
78+
const state = await getImpersonationState(viewing, "admin_1");
79+
80+
expect(state.isImpersonating).toBe(false);
81+
expect(state.isViewingAsUser).toBe(false);
6282
});
6383
});
6484

0 commit comments

Comments
 (0)