-
-
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 7 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() { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.