-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(webapp): two customer-card and impersonation fixes #4571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cb6088c
5dc5f70
6551a58
3c90ce3
4aadcd5
f1c6eb3
4f7fc96
7be4c3b
e5e910b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import { redirect } from "@remix-run/server-runtime"; | ||
| import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server"; | ||
| import { $replica, $transaction, prisma, type PrismaClientOrTransaction } from "~/db.server"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import type { SearchParams } from "~/routes/admin._index"; | ||
| import { | ||
|
|
@@ -9,7 +9,7 @@ import { | |
| setImpersonationId, | ||
| } from "~/services/impersonation.server"; | ||
| import { authenticator } from "~/services/auth.server"; | ||
| import { requireUser } from "~/services/session.server"; | ||
| import { getRealUser } from "~/services/session.server"; | ||
| import { extractClientIp } from "~/utils/extractClientIp.server"; | ||
| import { impersonationDestinationPath } from "~/utils/pathBuilder"; | ||
|
|
||
|
|
@@ -210,35 +210,76 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se | |
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Starts (or switches) impersonation. | ||
| * | ||
| * The admin gate resolves the *real* authenticated user itself. `requireUser` returns the | ||
| * impersonation target while impersonating, so callers that gated on it refused an admin who was | ||
| * already impersonating someone — they had to stop first — and would have attributed the audit row | ||
| * to the target rather than the admin. | ||
| * | ||
| * `verifiedAdmin` exists only so tests can supply an admin without a session cookie. Production | ||
| * callers must not pass it: passing a `requireUser` result is exactly the bug described above. | ||
| */ | ||
|
Comment on lines
+213
to
+223
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Pull request bundles two unrelated fixes The change combines two unrelated fixes — support-tool card validation and impersonation switching — in one pull request, which the project's contribution rules do not accept. Repository rule: one issue per PR
Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| export async function redirectWithImpersonation( | ||
| request: Request, | ||
| userId: string, | ||
| path: string, | ||
| currentUser?: { id: string; admin: boolean }, | ||
| verifiedAdmin?: { id: string; admin: boolean }, | ||
| prismaClient: PrismaClientOrTransaction = prisma | ||
| ) { | ||
| const user = currentUser ?? (await requireUser(request)); | ||
| if (!user.admin) { | ||
| const admin = verifiedAdmin ?? (await getRealUser(request, prismaClient)); | ||
| if (!admin?.admin) { | ||
| throw new Error("Unauthorized"); | ||
| } | ||
|
|
||
| const xff = request.headers.get("x-forwarded-for"); | ||
| const ipAddress = extractClientIp(xff); | ||
| const previousTargetId = await getImpersonationId(request); | ||
|
|
||
| // Switching straight from one target to another never passes through `clearImpersonation`, so the | ||
| // previous session is closed here, or the trail shows two overlapping STARTs. | ||
| // | ||
| // Both rows are written in one transaction: as separate statements, a failure between them could | ||
| // start an impersonation whose only audit row is the STOP for the previous target — an admin | ||
| // acting as someone with no record of it. | ||
| // | ||
| // `createdAt` is stamped explicitly rather than left to `@default(now())`, because Postgres `now()` | ||
| // is the *transaction* timestamp: inside one transaction both rows would take the same value, and | ||
| // an audit view ordered by that column couldn't tell which came first. | ||
| const startedAt = new Date(); | ||
| const closedAt = new Date(startedAt.getTime() - 1); | ||
|
|
||
| try { | ||
| await prismaClient.impersonationAuditLog.create({ | ||
| data: { | ||
| action: "START", | ||
| adminId: user.id, | ||
| targetId: userId, | ||
| ipAddress, | ||
| }, | ||
| await $transaction(prismaClient, "startImpersonationAudit", async (tx) => { | ||
| if (previousTargetId && previousTargetId !== userId) { | ||
| await tx.impersonationAuditLog.create({ | ||
| data: { | ||
| action: "STOP", | ||
| adminId: admin.id, | ||
| targetId: previousTargetId, | ||
| ipAddress, | ||
| createdAt: closedAt, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| await tx.impersonationAuditLog.create({ | ||
| data: { | ||
| action: "START", | ||
| adminId: admin.id, | ||
| targetId: userId, | ||
| ipAddress, | ||
| createdAt: startedAt, | ||
| }, | ||
| }); | ||
| }); | ||
|
Comment on lines
253
to
276
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Wrapping STOP+START in one transaction can now lose the START audit row
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } catch (error) { | ||
| logger.error("Failed to create impersonation audit log", { | ||
| error, | ||
| adminId: user.id, | ||
| adminId: admin.id, | ||
| targetId: userId, | ||
| previousTargetId, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -308,7 +349,8 @@ export async function startImpersonation( | |
| request: Request, | ||
| organizationSlug: string, | ||
| path: string, | ||
| currentUser: { id: string; admin: boolean }, | ||
| // Test-only, forwarded to `redirectWithImpersonation` — see its docstring. | ||
| verifiedAdmin?: { id: string; admin: boolean }, | ||
| clients: { read: PrismaClientOrTransaction; write: PrismaClientOrTransaction } = { | ||
| read: $replica, | ||
| write: prisma, | ||
|
|
@@ -325,7 +367,7 @@ export async function startImpersonation( | |
| request, | ||
| target.userId, | ||
| impersonationDestinationPath(organizationSlug, path, new URL(request.url).search), | ||
| currentUser, | ||
| verifiedAdmin, | ||
| clients.write | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,7 +61,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { | |
| // the consent page below instead, whose "Impersonate" button posts back from | ||
| // our own page and so satisfies the same check. | ||
| if (isSameOriginNavigation(request, env.LOGIN_ORIGIN)) { | ||
| throw await startImpersonation(request, organizationSlug, path, user); | ||
| throw await startImpersonation(request, organizationSlug, path); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 The /@/orgs entry point still forces a stop-then-restart when switching target The fix removes the "you must stop impersonating first" behaviour for Was this helpful? React with 👍 or 👎 to provide feedback.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intended, confirmed. That route short-circuits on Left as-is for two reasons: the audit trail is still unambiguous there (the rows come from separate requests, so their timestamps genuinely differ), and the clear-then-restart is what makes that path work today rather than something it works around. Changing it would widen this PR into the consent flow for no correctness gain. |
||
| } | ||
|
|
||
| // Expected for any link opened outside the app (address bar, bookmark, a link | ||
|
|
@@ -148,7 +148,7 @@ export async function action({ request, params }: ActionFunctionArgs) { | |
| // The consent form posts to an explicit absolute path (see | ||
| // `impersonationConsentPostBackPath`), so the organization slug, the splat | ||
| // path and the query string all arrive here intact. | ||
| return startImpersonation(request, organizationSlug, params["*"] ?? "", user); | ||
| return startImpersonation(request, organizationSlug, params["*"] ?? ""); | ||
| } | ||
|
|
||
| export default function Page() { | ||
|
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { | ||
| redirect, | ||
| type ActionFunctionArgs, | ||
| type LoaderFunctionArgs, | ||
| } from "@remix-run/server-runtime"; | ||
| import { z } from "zod"; | ||
| import { redirectWithImpersonation } from "~/models/admin.server"; | ||
| import { authenticator } from "~/services/auth.server"; | ||
| import { rbac } from "~/services/rbac.server"; | ||
| import { getRealUser } from "~/services/session.server"; | ||
| import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import { sanitizeRedirectPath } from "~/utils"; | ||
|
|
||
| /** | ||
| * Served at `/admin/impersonate`, but the trailing `_` on `admin_` keeps it out of the `admin.tsx` | ||
| * layout on purpose. | ||
| * | ||
| * That layout's loader is `dashboardLoader({ authorization: { requireSuper: true } })`, which | ||
| * resolves the user through `getUserId` — the impersonated id while impersonating. So starting on a | ||
| * second target ran the parent gate against the target, which isn't a super admin, and it answered | ||
| * with its own `redirect("/")`. Nesting would leave this route's behaviour depending on the router | ||
| * preferring the deepest redirect; opting out removes the question. Nothing is lost — this route | ||
| * only ever redirects, so it never rendered inside the layout anyway. | ||
| */ | ||
|
|
||
| const FormSchema = z.object({ id: z.string() }); | ||
|
|
||
| /** | ||
| * The real authenticated user, or null when they're signed in but not an admin. | ||
| * | ||
| * Must not use `requireUser`: while impersonating it resolves to the impersonation target, whose | ||
| * `admin` is false, so an admin switching to a second target was bounced to `/` and left on the | ||
| * first one. | ||
| * | ||
| * Throws a login redirect when nobody is signed in, keeping this URL as `redirectTo` so the | ||
| * impersonation survives the round trip — the one-time token is validated after this gate, so it's | ||
| * still unconsumed when the browser comes back. Collapsing that into the non-admin `/` redirect | ||
| * would drop the link the agent clicked. | ||
| */ | ||
| async function requireRealAdmin(request: Request) { | ||
| if (!(await authenticator.isAuthenticated(request))) { | ||
| const url = new URL(request.url); | ||
| const redirectTo = sanitizeRedirectPath(`${url.pathname}${url.search}`); | ||
| throw redirect(`/login?${new URLSearchParams([["redirectTo", redirectTo]])}`); | ||
| } | ||
|
|
||
| const admin = await getRealUser(request); | ||
| if (!admin) return null; | ||
|
|
||
| // Same gate `dashboardLoader({ authorization: { requireSuper: true } })` applies, evaluated | ||
| // against the real admin. It can't be reached through the builder here, because the builder | ||
| // resolves its subject with `getUserId` — the impersonated id while impersonating, which is the | ||
| // bug this route exists to fix. So the ability is built explicitly for `admin.id` instead of | ||
| // trusting the raw `User.admin` column: `canSuper()` is only equal to that column in the OSS | ||
| // fallback, and a plugin is free to be stricter. requireSuper needs no org/project scope. | ||
| const auth = await rbac.authenticateSession(request, { userId: admin.id }); | ||
| if (!auth.ok || !auth.ability.canSuper()) return null; | ||
|
|
||
| return admin; | ||
| } | ||
|
Comment on lines
+41
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 canSuper gate and the raw admin column can disagree, producing a 500
Was this helpful? React with 👍 or 👎 to provide feedback.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — this was a real gap my rename introduced, now closed.
const auth = await rbac.authenticateSession(request, { userId: admin.id });
if (!auth.ok || !auth.ability.canSuper()) return null;
|
||
|
|
||
| async function handleImpersonationRequest(request: Request, userId: string): Promise<Response> { | ||
| const admin = await requireRealAdmin(request); | ||
| if (!admin) { | ||
| return redirect("/"); | ||
| } | ||
| return redirectWithImpersonation(request, userId, "/"); | ||
| } | ||
|
|
||
| export const loader = async ({ request }: LoaderFunctionArgs) => { | ||
| const url = new URL(request.url); | ||
| const impersonateUserId = url.searchParams.get("impersonate"); | ||
| const impersonationToken = url.searchParams.get("impersonationToken"); | ||
|
|
||
| if (!impersonateUserId) { | ||
| return redirect("/admin"); | ||
| } | ||
|
|
||
| if (!impersonationToken) { | ||
| logger.warn("Impersonation request missing token"); | ||
| return redirect("/"); | ||
| } | ||
|
|
||
| // Check admin BEFORE consuming the one-time token, so a rejected request leaves the token usable. | ||
| const admin = await requireRealAdmin(request); | ||
| if (!admin) { | ||
| return redirect("/"); | ||
| } | ||
|
|
||
| const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken); | ||
|
|
||
| if (!validatedUserId || validatedUserId !== impersonateUserId) { | ||
| logger.warn("Invalid or expired impersonation token"); | ||
| return redirect("/"); | ||
| } | ||
|
|
||
| return redirectWithImpersonation(request, impersonateUserId, "/"); | ||
| }; | ||
|
|
||
| export async function action({ request }: ActionFunctionArgs) { | ||
| if (request.method.toLowerCase() !== "post") { | ||
| return new Response("Method not allowed", { status: 405 }); | ||
| } | ||
|
|
||
| const payload = Object.fromEntries(await request.formData()); | ||
| const { id } = FormSchema.parse(payload); | ||
|
|
||
| return handleImpersonationRequest(request, id); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.