From c5e21bb52401064745ae2d41f4b8dc58ffe86303 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Feb 2026 14:08:44 +0000 Subject: [PATCH 1/4] Impersonate a run automatically Using /runs/run_abcdef automatically impersonates if an admin is logged in --- apps/webapp/app/routes/runs.$runParam.ts | 32 +++++++++++++----------- apps/webapp/app/utils/pathBuilder.ts | 5 ++++ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/routes/runs.$runParam.ts b/apps/webapp/app/routes/runs.$runParam.ts index 4e9ce3d7a37..bad988ddd4f 100644 --- a/apps/webapp/app/routes/runs.$runParam.ts +++ b/apps/webapp/app/routes/runs.$runParam.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; import { requireUser } from "~/services/session.server"; -import { rootPath, v3RunPath } from "~/utils/pathBuilder"; +import { impersonate, rootPath, v3RunPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ runParam: z.string(), @@ -14,18 +14,22 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const { runParam } = ParamsSchema.parse(params); + const isAdmin = user.admin || user.isImpersonating; + const run = await prisma.taskRun.findFirst({ where: { friendlyId: runParam, - project: { - organization: { - members: { - some: { - userId: user.id, + ...(!isAdmin && { + project: { + organization: { + members: { + some: { + userId: user.id, + }, }, }, }, - }, + }), }, select: { runtimeEnvironment: { @@ -57,12 +61,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) { ); } - return redirect( - v3RunPath( - { slug: run.project.organization.slug }, - { slug: run.project.slug }, - { slug: run.runtimeEnvironment.slug }, - { friendlyId: runParam } - ) + const path = v3RunPath( + { slug: run.project.organization.slug }, + { slug: run.project.slug }, + { slug: run.runtimeEnvironment.slug }, + { friendlyId: runParam } ); + + return redirect(isAdmin ? impersonate(path) : path); } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index c39234a7bbb..20f3fcd7c29 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -56,6 +56,11 @@ export function rootPath() { return `/`; } +/** Given a path, it makes it an impersonation path */ +export function impersonate(path: string) { + return `/@${path}`; +} + export function accountPath() { return `/account`; } From 9e7cabf9f86db76025a8b3e8865eec99127bfe62 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Feb 2026 14:09:30 +0000 Subject: [PATCH 2/4] Fix: clear impersonation when already impersonating Fix for when switching impersonating --- apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts index 9115c1b21af..b430b4a2195 100644 --- a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts +++ b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts @@ -7,6 +7,14 @@ import { requireUser } from "~/services/session.server"; export async function loader({ request, params }: LoaderFunctionArgs) { const user = await requireUser(request); + + // If already impersonating, we need to clear the impersonation + if (user.isImpersonating) { + const url = new URL(request.url); + return clearImpersonation(request, url.pathname); + } + + // Only admins can impersonate if (!user.admin) { return redirect("/"); } From a220314a2dcb6c6d5694e0fffd3e59f5e6c10ff1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Mar 2026 10:54:33 +0000 Subject: [PATCH 3/4] Separate run impersonation into a separate @/runs/runid route --- apps/webapp/app/routes/@.runs.$runParam.ts | 67 ++++++++++++++++++++++ apps/webapp/app/routes/runs.$runParam.ts | 20 +++---- 2 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 apps/webapp/app/routes/@.runs.$runParam.ts diff --git a/apps/webapp/app/routes/@.runs.$runParam.ts b/apps/webapp/app/routes/@.runs.$runParam.ts new file mode 100644 index 00000000000..a52600628d8 --- /dev/null +++ b/apps/webapp/app/routes/@.runs.$runParam.ts @@ -0,0 +1,67 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { requireUser } from "~/services/session.server"; +import { impersonate, rootPath, v3RunPath } from "~/utils/pathBuilder"; + +const ParamsSchema = z.object({ + runParam: z.string(), +}); + +export async function loader({ params, request }: LoaderFunctionArgs) { + const user = await requireUser(request); + + const { runParam } = ParamsSchema.parse(params); + + const isAdmin = user.admin || user.isImpersonating; + + if (!isAdmin) { + return redirectWithErrorMessage( + rootPath(), + request, + "You're not an admin and cannot impersonate", + { + ephemeral: false, + } + ); + } + + const run = await prisma.taskRun.findFirst({ + where: { + friendlyId: runParam, + }, + select: { + runtimeEnvironment: { + select: { + slug: true, + }, + }, + project: { + select: { + slug: true, + organization: { + select: { + slug: true, + }, + }, + }, + }, + }, + }); + + if (!run) { + return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", { + ephemeral: false, + }); + } + + const path = v3RunPath( + { slug: run.project.organization.slug }, + { slug: run.project.slug }, + { slug: run.runtimeEnvironment.slug }, + { friendlyId: runParam } + ); + + return redirect(impersonate(path)); +} diff --git a/apps/webapp/app/routes/runs.$runParam.ts b/apps/webapp/app/routes/runs.$runParam.ts index bad988ddd4f..4a8d7a12d32 100644 --- a/apps/webapp/app/routes/runs.$runParam.ts +++ b/apps/webapp/app/routes/runs.$runParam.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; import { requireUser } from "~/services/session.server"; -import { impersonate, rootPath, v3RunPath } from "~/utils/pathBuilder"; +import { rootPath, v3RunPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ runParam: z.string(), @@ -14,22 +14,18 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const { runParam } = ParamsSchema.parse(params); - const isAdmin = user.admin || user.isImpersonating; - const run = await prisma.taskRun.findFirst({ where: { friendlyId: runParam, - ...(!isAdmin && { - project: { - organization: { - members: { - some: { - userId: user.id, - }, + project: { + organization: { + members: { + some: { + userId: user.id, }, }, }, - }), + }, }, select: { runtimeEnvironment: { @@ -68,5 +64,5 @@ export async function loader({ params, request }: LoaderFunctionArgs) { { friendlyId: runParam } ); - return redirect(isAdmin ? impersonate(path) : path); + return redirect(path); } From 7bc0dc58949de1b07d41540be7a859d5d96fbcee Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Mar 2026 12:50:25 +0000 Subject: [PATCH 4/4] Fix for clear impersonation logging --- apps/webapp/app/models/admin.server.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 35e68f89998..bd3adc8cbf2 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -8,15 +8,13 @@ import { getImpersonationId, setImpersonationId, } from "~/services/impersonation.server"; +import { authenticator } from "~/services/auth.server"; import { requireUser } from "~/services/session.server"; import { extractClientIp } from "~/utils/extractClientIp.server"; const pageSize = 20; -export async function adminGetUsers( - userId: string, - { page, search }: SearchParams, -) { +export async function adminGetUsers(userId: string, { page, search }: SearchParams) { page = page || 1; search = search ? decodeURIComponent(search) : undefined; @@ -231,7 +229,11 @@ export async function redirectWithImpersonation(request: Request, userId: string }, }); } catch (error) { - logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId: userId }); + logger.error("Failed to create impersonation audit log", { + error, + adminId: user.id, + targetId: userId, + }); } const session = await setImpersonationId(userId, request); @@ -242,10 +244,10 @@ export async function redirectWithImpersonation(request: Request, userId: string } export async function clearImpersonation(request: Request, path: string) { - const user = await requireUser(request); + const authUser = await authenticator.isAuthenticated(request); const targetId = await getImpersonationId(request); - if (targetId) { + if (targetId && authUser?.userId) { const xff = request.headers.get("x-forwarded-for"); const ipAddress = extractClientIp(xff); @@ -253,13 +255,17 @@ export async function clearImpersonation(request: Request, path: string) { await prisma.impersonationAuditLog.create({ data: { action: "STOP", - adminId: user.id, + adminId: authUser.userId, targetId, ipAddress, }, }); } catch (error) { - logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId }); + logger.error("Failed to create impersonation audit log", { + error, + adminId: authUser.userId, + targetId, + }); } }