Skip to content
Closed
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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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.
*/
Comment on lines +213 to +223

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.

🟡 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.
Impact: The PR risks being rejected or delayed, and either fix cannot be reverted independently of the other.

Repository rule: one issue per PR

CONTRIBUTING.md: "Important: We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one." The author's own description opens with "Two unrelated bugs in admin tooling", and the diff spans the Plain customer-card endpoint (apps/webapp/app/routes/api.v1.plain.customer-cards.ts, apps/webapp/app/utils/plainCustomerCards.ts) and the impersonation flow (apps/webapp/app/models/admin.server.ts, apps/webapp/app/routes/admin_.impersonate.tsx, apps/webapp/app/services/session.server.ts).

Prompt for agents
Split this PR into two: one for the Plain customer-card schema/response changes (api.v1.plain.customer-cards.ts, utils/plainCustomerCards.ts and its test) and one for the impersonation fixes (models/admin.server.ts, services/session.server.ts, services/impersonation.server.ts, the admin_.impersonate route move, and the consent route).
Open in Devin Review

Was 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

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.

🔍 Wrapping STOP+START in one transaction can now lose the START audit row

previousTargetId comes from the impersonation cookie, which is unvalidated: it can name a user row that has since been deleted (or was never valid). ImpersonationAuditLog.targetId is a FK to User (internal-packages/database/prisma/schema.prisma:2831), so a stale cookie makes the STOP insert fail, aborting the whole transaction and taking the START row with it. Before this change, only the START row was written and it always succeeded. The failure is swallowed by the surrounding try/catch (apps/webapp/app/models/admin.server.ts:277-284) and impersonation still proceeds, so the outcome is an impersonation session with no audit record at all — exactly the scenario the transaction comment says it wants to avoid. Consider writing the START first (or inserting the STOP best-effort outside the transaction) so a bad previous target can't erase the record of the new one.

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

@devin-ai-integration devin-ai-integration Bot Aug 11, 2026

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.

🔍 The /@/orgs entry point still forces a stop-then-restart when switching target

The fix removes the "you must stop impersonating first" behaviour for /admin/impersonate, but the /@/orgs/<slug>/… entry point still short-circuits on user.isImpersonating and clears impersonation before re-entering (loader at apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx:34-40 and action at :119-124). That path is functional (it clears, then the follow-up GET starts on the new target and now succeeds because the gate uses the real user), but it means switching via an org link still costs an extra round trip and produces a STOP from clearImpersonation rather than the new paired STOP+START. Worth confirming this asymmetry is intended.

Open in Devin Review

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Intended, confirmed. That route short-circuits on user.isImpersonating and clears before re-entering, so switching via an org link costs an extra round trip and produces a lone STOP from clearImpersonation followed by a separate START.

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
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";

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

@devin-ai-integration devin-ai-integration Bot Aug 11, 2026

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.

🔍 canSuper gate and the raw admin column can disagree, producing a 500

requireRealAdmin deliberately gates on auth.ability.canSuper() rather than User.admin, but redirectWithImpersonation then re-checks the raw column (if (!admin?.admin) throw new Error("Unauthorized") at apps/webapp/app/models/admin.server.ts:231-234). If an RBAC plugin ever grants canSuper() to someone whose User.admin is false, this route passes its own gate and then throws an uncaught Error, surfacing as a 500 rather than a redirect. The comment here notes a plugin may be stricter, which is safe; the looser direction is the one that produces the bad response. Worth confirming the plugin contract only ever narrows.

Open in Devin Review

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — this was a real gap my rename introduced, now closed.

buildFallbackAbility(isAdmin) returns superAbility (canSuper: () => true) for admins, so on OSS canSuper() and the User.admin column are identical. But you are right that a plugin is free to be stricter, and this route had become the only admin entry point not evaluated through the ability.

requireRealAdmin now builds the ability explicitly for the real admin and checks it:

const auth = await rbac.authenticateSession(request, { userId: admin.id });
if (!auth.ok || !auth.ability.canSuper()) return null;

dashboardLoader({ authorization: { requireSuper: true } }) is still not usable here, because it resolves its subject with getUserId — the impersonated id while impersonating, which is the bug this route exists to fix. requireSuper is the only global gate, so no org/project scope is needed.


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