Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 57 additions & 15 deletions apps/webapp/app/models/admin.server.ts
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 {
Expand All @@ -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";

Expand Down Expand Up @@ -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.
*/
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");
}
Comment on lines +221 to 234

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Restrict the verifiedAdmin authentication bypass.

verifiedAdmin skips getRealUser completely. The only protection is the docstring at Lines 221-222. startImpersonation also forwards this parameter as a public optional argument (Lines 352-353, 370), so both exported functions accept a caller-supplied admin identity that is never verified against the session.

A future caller can pass { id, admin: true } and start impersonation for any target without an authenticated admin session. The audit row then records that unverified id as the actor.

Prefer a test seam that cannot become an auth bypass. Two options:

  1. Inject the resolver instead of the result, so production always authenticates.
  2. Gate the override on a non-production environment flag read through env from app/env.server.ts.
🔒 Option 1: inject the resolver
 export async function redirectWithImpersonation(
   request: Request,
   userId: string,
   path: string,
-  verifiedAdmin?: { id: string; admin: boolean },
-  prismaClient: PrismaClientOrTransaction = prisma
+  prismaClient: PrismaClientOrTransaction = prisma,
+  resolveAdmin: (
+    request: Request,
+    client: PrismaClientOrTransaction
+  ) => Promise<{ id: string; admin: boolean } | null> = getRealUser
 ) {
-  const admin = verifiedAdmin ?? (await getRealUser(request, prismaClient));
+  const admin = await resolveAdmin(request, prismaClient);
   if (!admin?.admin) {
     throw new Error("Unauthorized");
   }

Update startImpersonation to forward the same seam.

Also applies to: 352-353

Source: Coding guidelines


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.
Comment on lines +243 to +245

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

The catch block contradicts the stated audit guarantee.

The comment at Lines 243-245 states the transaction prevents "an admin acting as someone with no record of it". The catch block at Lines 277-284 logs the failure and then execution continues. Lines 286-290 set the impersonation cookie and redirect. If the audit transaction fails, impersonation starts with no START record.

Choose one behavior and make the code and the comment agree:

  • Fail closed: rethrow after logging, so no impersonation begins without an audit row.
  • Fail open: keep the catch and correct the comment to state that the transaction only prevents a partial STOP-without-START trail.
🔒 Fail-closed variant
   } catch (error) {
     logger.error("Failed to create impersonation audit log", {
       error,
       adminId: admin.id,
       targetId: userId,
       previousTargetId,
     });
+    throw error;
   }

Also applies to: 277-284

//
// `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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Switching impersonation target can leave no record at all when one record fails to save

Both audit entries are now written together in a single all-or-nothing database write ($transaction at apps/webapp/app/models/admin.server.ts:254) while any failure is still ignored and impersonation continues, so one failing entry now also destroys the entry for the new session that would previously have been saved.
Impact: In the failure case an admin ends up acting as another user with no trace in the audit trail, where previously a record was still kept.

Mechanism: atomic write plus swallowed error removes the previously independent START row

Before this change, redirectWithImpersonation created only the START row; a failure there was logged and impersonation still proceeded.

Now, when previousTargetId is set and differs from the new target, a STOP row and the START row are created inside one transaction (apps/webapp/app/models/admin.server.ts:254-275). If the STOP insert fails — the most realistic case is a foreign-key violation because the previously impersonated user row has since been deleted, but any transient error qualifies — the whole transaction rolls back, so the START row is lost too. The surrounding try/catch (apps/webapp/app/models/admin.server.ts:277-284) only logs, and the cookie is then set at apps/webapp/app/models/admin.server.ts:286, so impersonation starts with zero audit rows.

The in-code comment claims the transaction prevents "an admin acting as someone with no record of it", but because the error is swallowed rather than aborting the impersonation, the transaction actually widens that window instead of closing it. Either the START row should be attempted separately when the STOP write fails, or the failure should abort the impersonation.

Prompt for agents
In apps/webapp/app/models/admin.server.ts, redirectWithImpersonation now writes the STOP row for the previous target and the START row for the new target inside one $transaction, but the surrounding try/catch swallows any error and impersonation proceeds anyway. That means a failure in the STOP insert (for example a foreign-key violation because the previously impersonated user has since been deleted, or any transient error) now also rolls back the START row, leaving an active impersonation with no audit record — the exact outcome the transaction comment says it prevents. Consider either making the audit failure fatal (do not set the impersonation cookie if the audit write fails), or falling back to writing the START row on its own when the combined transaction fails, so the new session is always recorded.
Open in Devin Review

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,
});
}

Expand Down Expand Up @@ -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,
Expand All @@ -325,7 +367,7 @@ export async function startImpersonation(
request,
target.userId,
impersonationDestinationPath(organizationSlug, path, new URL(request.url).search),
currentUser,
verifiedAdmin,
clients.write
);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

// Expected for any link opened outside the app (address bar, bookmark, a link
Expand Down Expand Up @@ -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() {
Expand Down
61 changes: 0 additions & 61 deletions apps/webapp/app/routes/admin.impersonate.tsx

This file was deleted.

110 changes: 110 additions & 0 deletions apps/webapp/app/routes/admin_.impersonate.tsx
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";
Comment on lines +1 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Release notes will not mention this webapp fix

This change only touches server code under apps/webapp/ but ships without the required release-note entry in .server-changes/, so the fix will be missing from user-facing release notes.
Impact: Users reading the release notes will not see that impersonation switching was fixed.

Repository rule: server-only changes require a `.server-changes/` file

AGENTS.md ("Changesets and Server Changes") and CONTRIBUTING.md ("Adding server changes") both state that a PR changing only server components (apps/webapp/, apps/supervisor/, …) with no package changes must add a .server-changes/ markdown file with area and type frontmatter. This PR modifies only apps/webapp/app/** and adds no such file (the directory contains only pre-existing entries).

Prompt for agents
The repository requires a `.server-changes/` entry for PRs that change only server components (see AGENTS.md "Changesets and Server Changes" and CONTRIBUTING.md "Adding server changes"). This PR changes only apps/webapp. Add a new markdown file under .server-changes/ with frontmatter `area: webapp` and `type: fix`, and a one-line, user-facing description of the behaviour change (an admin can switch who they are impersonating without stopping first), written for users rather than maintainers.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


/**
* 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;
}

async function handleImpersonationRequest(request: Request, userId: string): Promise<Response> {
const admin = await requireRealAdmin(request);
if (!admin) {
return redirect("/");
}
return redirectWithImpersonation(request, userId, "/");
}
Comment on lines +63 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the SameSite attribute of the auth and impersonation session cookies.
rg -nP --type=ts -C 6 'createCookieSessionStorage|sameSite' apps/webapp/app | head -120

# Confirm the same-origin helper contract used by the sibling route.
fd -t f 'sameOriginNavigation.ts' apps/webapp | while IFS= read -r f; do
  echo "=== $f ==="
  cat -n "$f"
done

Repository: triggerdotdev/trigger.dev

Length of output: 11284


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '=== admin impersonation route ==='
fd -t f 'admin_.impersonate.tsx' apps/webapp | while IFS= read -r f; do
  cat -n "$f"
done
printf '%s\n' '=== sibling action and token usage ==='
rg -n -C 12 'isSameOriginNavigation|impersonationToken|handleImpersonationRequest|redirectWithImpersonation' apps/webapp/app/routes apps/webapp/app/services apps/webapp/app/utils
printf '%s\n' '=== session cookie consumers ==='
rg -n -C 8 'sessionStorage|getSession\\(|__session|requireRealAdmin' apps/webapp/app/services apps/webapp/app/routes apps/webapp/app/utils | head -240

Repository: triggerdotdev/trigger.dev

Length of output: 36844


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '=== impersonation redirect implementation ==='
rg -n -C 16 'function redirectWithImpersonation|const redirectWithImpersonation|export .*redirectWithImpersonation' apps/webapp/app/models apps/webapp/app/services
printf '%s\n' '=== authentication session storage ==='
rg -n -C 12 'sessionStorage|authenticator|createCookieSessionStorage|sameSite' apps/webapp/app/services/auth.server.ts apps/webapp/app/services/session.server.ts apps/webapp/app/services/sessionStorage.server.ts
printf '%s\n' '=== all admin impersonation entry points ==='
rg -n -C 8 'admin/impersonate|redirectWithImpersonation\\(' apps/webapp/app

Repository: triggerdotdev/trigger.dev

Length of output: 16209


Add the same-origin check to the POST action.

The __session and __impersonate cookies use SameSite=Lax, so a normal cross-site form POST does not carry the authentication cookie. The action still permits same-site cross-origin requests and lacks the defense used by the sibling impersonation route. Reject non-same-origin requests before processing the form data.


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);
Comment on lines +106 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use safeParse and return a 400.

FormSchema.parse throws a ZodError for a missing or non-string id. Remix converts the uncaught throw into a 500 response. Malformed input must not produce a 5xx.

The parse also runs before requireRealAdmin, so an unauthenticated POST with a malformed body returns a 500 instead of the login redirect.

🐛 Proposed fix
   const payload = Object.fromEntries(await request.formData());
-  const { id } = FormSchema.parse(payload);
+  const parsed = FormSchema.safeParse(payload);
+  if (!parsed.success) {
+    return new Response("Bad request", { status: 400 });
+  }
 
-  return handleImpersonationRequest(request, id);
+  return handleImpersonationRequest(request, parsed.data.id);
 }


return handleImpersonationRequest(request, id);
}
8 changes: 8 additions & 0 deletions apps/webapp/app/services/impersonation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ export async function getImpersonationId(request: Request) {
export async function setImpersonationId(userId: string, request: Request) {
const session = await getImpersonationSession(request);

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

session.set(IMPERSONATED_USER_ID_KEY, userId);

return session;
Expand Down
39 changes: 39 additions & 0 deletions apps/webapp/app/services/session.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { redirect } from "@remix-run/node";
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
import { getUserById } from "~/models/user.server";
import { sanitizeRedirectPath } from "~/utils";
import { extractClientIp } from "~/utils/extractClientIp.server";
Expand Down Expand Up @@ -124,6 +125,44 @@ export async function requireUserId(request: Request, redirectTo?: string) {
return userId;
}

/**
* The user the request actually authenticated as, ignoring any impersonation cookie.
*
* `getUserId` deliberately resolves to the *impersonated* id while impersonating, so `getUser` /
* `requireUser` answer "who is this request acting as". That is the wrong question for anything
* gating on admin rights or attributing an admin action: while impersonating a customer,
* `requireUser().admin` is that customer's flag, so an admin check silently fails and an audit
* record would name the customer as the actor.
*
* Returns null when unauthenticated or the row is gone.
*/
export async function getRealUser(
request: Request,
prismaClient: PrismaClientOrTransaction = prisma
) {
const authUser = await authenticator.isAuthenticated(request);

// Apply the same session controls `getUserId`/`getUser` apply to the real user, so this helper
// can't become a way around them: a session the IdP has revoked throws to /logout here, and one
// past its effective duration is caught below. Skipping either would let an admin whose session
// should have ended still start impersonation.
await revalidateSsoSession(request, authUser);
if (!authUser?.userId) return null;

// Narrow select — callers need the id and the admin flag, plus `nextSessionEnd` for the deadline
// check. Takes a client so a caller already scoped to one reads the admin from the same database
// it writes to.
const user = await prismaClient.user.findFirst({
where: { id: authUser.userId },
select: { id: true, admin: true, nextSessionEnd: true },
});
if (!user) return null;

maybeAutoLogout(request, user);

return user;
}

export type UserFromSession = Awaited<ReturnType<typeof requireUser>>;

export async function requireUser(request: Request) {
Expand Down
Loading