Skip to content

fix(webapp): two customer-card and impersonation fixes - #4571

Closed
isshaddad wants to merge 9 commits into
mainfrom
fix/plain-card-and-impersonation-switch
Closed

fix(webapp): two customer-card and impersonation fixes#4571
isshaddad wants to merge 9 commits into
mainfrom
fix/plain-card-and-impersonation-switch

Conversation

@isshaddad

Copy link
Copy Markdown
Collaborator

Two unrelated bugs in admin tooling

1. Customer cards 400'd for customers with no external id

api/v1/plain/customer-cards validated customer.email and customer.externalId with
z.string().optional(). Plain sends these keys as explicit nulls rather than omitting them, and
.optional() accepts undefined but rejects null — so the request failed validation before any
lookup ran. Customers we don't set an externalId for got a 400 every time; the rest worked, which
is why it looked intermittent.

  • email, externalId and thread are now .nullish(). The refine still requires one of
    email/externalId, and the route's existing email fallback resolves these customers.
  • The schema and a small response helper moved to app/utils/plainCustomerCards.ts so they can be
    unit-tested without pulling in the db and env modules.

Also fixed in the same file: when no user matched, the route returned { cards: [] }. Plain records
an integration error for any requested card key it doesn't get back, so a partial response shows up
as a broken card rather than a hidden one. Every requested key is now answered, with
components: null where there's no data.

2. An admin couldn't switch impersonation target

getUserId resolves to the impersonated user id while impersonating — by design, so requireUser
answers "who is this request acting as". The impersonation entry points gated on it, so while
impersonating a customer user.admin was that customer's flag (false) and starting on a second
target silently redirected to /. You had to stop impersonating first.

  • New getRealUser(request, prismaClient?) in session.server.ts resolves the authenticated user,
    ignoring the impersonation cookie.
  • redirectWithImpersonation gates on it rather than taking a user from the caller, and uses that
    id for the audit row — which previously would have named the impersonated customer as the actor.
  • Switching straight from one target to another never passes through clearImpersonation, so a
    STOP for the previous target is now recorded alongside the new START. Without it the audit
    trail showed two overlapping STARTs with no close.

redirectWithImpersonation keeps a verifiedAdmin parameter, used only by
test/impersonationConsent.test.ts to supply an admin without a session cookie. No production
caller passes it, and its docstring says not to.

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: e5e910b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The changes add real-admin authentication for impersonation authorization and audit attribution. Impersonation requests authorize before token consumption and record STOP and START events transactionally. Callers use revised impersonation signatures. The plain customer card route now uses shared request validation and response completion utilities. Responses include null entries for requested card keys without data. Tests cover the shared schema and card completion behavior.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains both fixes in detail but omits the required issue reference, checklist, testing steps, changelog, and screenshots sections. Add the template sections, complete the checklist, document the tests that were run, provide a changelog entry, and add screenshots or state that they are not applicable.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies both customer-card and impersonation fixes, which are the main changes in the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/plain-card-and-impersonation-switch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Observability map

As of e5e910b.

19/100 over 417 measured of 433 entry points (base 18, up 1)

What this PR changed

route base head now failing
/admin_/impersonate new 0 auth-boundary, request-context
/api/v1/dashboard-agent/eval-policy (suppressed: request-context) new 0

1 entries removed

FIX FIRST

  • /admin_/impersonate (sensitive) - auth-boundary, request-context
  • /api/v1/projects/:projectRef/envvars (sensitive) - auth-boundary, request-context
  • /auth/sso (sensitive) - auth-boundary, request-context

AUDIT 3 of 50 sensitive mutations record an actor. 47 without one.
CONTEXT 14 of 417 entry points name a tenant on a failure path. 324 appear only here, 38 of them sensitive, in the JSON rather than the fix list.

What the score is made of
CHECKS
  error-classification  171 applicable,  96 pass,   0 sole, global without it 10
  auth-boundary          62 applicable,  56 pass,   0 sole, global without it 15
  auth-scope             19 applicable,  17 pass,   0 sole, global without it 18
  request-context       417 applicable,  14 pass, 224 sole, global without it 63
  audit-trail            50 applicable,   3 pass,   0 sole, not in the score

The score and findings here are report-only and never gate the merge. Separately, a required test suite keeps this tool's symbol and route lists in sync with the code they name, and can fail a pull request that renames or removes a symbol they reference, or that adds the first route with a segment they anticipate. Each failure names the list to edit. The rules and their reasons: internal-packages/observability-map/README.md.

coderabbitai[bot]

This comment was marked as resolved.

@isshaddad
isshaddad marked this pull request as ready for review August 11, 2026 18:14

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 4 potential issues.

Open in Devin Review

Comment thread apps/webapp/app/routes/admin_.impersonate.tsx
@@ -0,0 +1,59 @@
import { z } from "zod";

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.

🟡 Server-only change ships without the required release-notes entry

This change touches only server code under apps/webapp/ but adds no .server-changes/ entry (see the new apps/webapp/app/utils/plainCustomerCards.ts), which the repository requires so the fix appears in the user-facing release notes.

Impact: The customer-card and impersonation fixes will be missing from the published release notes.

Rule source

AGENTS.md ("Changesets and Server Changes") and CONTRIBUTING.md ("Adding server changes") both state: when a PR changes only server components (apps/webapp/, apps/supervisor/, …) with no package changes, add a .server-changes/ markdown file with area and type frontmatter and a one-line, user-facing description. git diff --name-only for this PR shows only apps/webapp/** files and no .server-changes/ addition.

Prompt for agents
This PR modifies only apps/webapp (server code) and adds no .server-changes/ entry, which AGENTS.md and CONTRIBUTING.md require for server-only changes. Add a markdown file under .server-changes/ with `area: webapp` and `type: fix` frontmatter and a one-line, user-facing description of the change (plain language about behaviour, not implementation or internal tool names). See .server-changes/README.md for the format.
Open in Devin Review

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

Comment thread apps/webapp/app/models/admin.server.ts Outdated
// 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.

Signing in from an impersonation link dropped the destination: the admin gate
answered both "not signed in" and "signed in but not an admin" with a redirect
to /, so an agent who clicked Impersonate while logged out landed on the
dashboard afterwards. Unauthenticated requests now redirect to login carrying
the original URL as redirectTo — the one-time token is validated after the gate,
so it survives the round trip.

The paired STOP/START audit rows were written with createMany, a single insert,
so both took the same createdAt and a view ordered by that column could show the
new START ahead of the STOP closing the previous session. They are now two
statements so the sequence is unambiguous.
coderabbitai[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread apps/webapp/app/models/admin.server.ts Outdated
Comment on lines +248 to +258
if (previousTargetId && previousTargetId !== userId) {
await prismaClient.impersonationAuditLog.create({
data: {
action: "STOP",
adminId: admin.id,
targetId: previousTargetId,
ipAddress,
},
});
}

@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.

🟡 Audit record for a new impersonation is silently skipped when closing the previous one fails

The record that closes the previous impersonation is written before the record that opens the new one (impersonationAuditLog.create at apps/webapp/app/models/admin.server.ts:249-257), and both share one error handler, so if the first write fails the second never runs while the impersonation still starts.
Impact: An admin can begin impersonating someone with no trace of it in the audit trail.

How a failed STOP insert swallows the START insert

Both create calls live inside the same try block (apps/webapp/app/models/admin.server.ts:240-274). The STOP row references previousTargetId via a foreign key on ImpersonationAuditLog.targetId; if that user row has since been deleted (or any other write error occurs), the create throws, control jumps to the catch, and the START row for the new target is never inserted. Execution then continues to setImpersonationId and the redirect, so impersonation begins with no START record. Writing the START row first, or wrapping each create in its own try/catch, would keep the new session's record independent of the close-out record.

Open in Devin Review

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

Comment on lines +42 to +58
export function answerAllCardKeys<TCard extends { key: string }>(
cardKeys: string[],
cards: TCard[]
): (TCard | NoDataCard)[] {
const answered = new Set(cards.map((card) => card.key));

return [
...cards,
...cardKeys
.filter((key) => !answered.has(key))
.map(
(key): NoDataCard => ({
key,
components: null,
})
),
];

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.

🔍 Cards for keys Plain didn't request are passed through unchanged

answerAllCardKeys only adds missing keys; it never filters out cards whose key wasn't in cardKeys (explicitly asserted by the "ignores extra cards that were not requested" test). Today the route only builds cards while iterating cardKeys, so extras can't occur, but if PLAIN_CUSTOMER_CARDS_KEY is misconfigured or a future branch pushes an unrequested key, Plain will receive a card it did not ask for. Worth confirming Plain tolerates that rather than treating it as an integration error.

Open in Devin Review

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

Closing the previous impersonation and opening the new one were two separate
statements sharing one error handler, so a failure between them could start an
impersonation whose only record was the STOP for the previous target — an admin
acting as someone with no trace of it. Both rows now go through the $transaction
helper.

createdAt is stamped explicitly because Postgres now() is the transaction
timestamp: inside one transaction the default would give both rows the same
value, leaving an audit view ordered by that column unable to tell which came
first.
devin-ai-integration[bot]

This comment was marked as resolved.

… target

The flag is scoped to a single impersonation session, which is why stopping
impersonation drops it. Switching straight from one target to another only
became possible in this branch, and that path sets the impersonated id without
touching the flag, so an admin viewing target A as the user landed on target B
with the toggle still on. It is now cleared whenever the target changes.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 new potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread apps/webapp/app/routes/admin_.impersonate.tsx
Comment on lines +48 to +58
return [
...cards,
...cardKeys
.filter((key) => !answered.has(key))
.map(
(key): NoDataCard => ({
key,
components: null,
})
),
];

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.

🔍 No-data cards omit timeToLiveSeconds

Every answered card sets timeToLiveSeconds, but the filler cards produced here only carry key and components: null. Assuming Plain treats the field as optional this is fine; if it isn't, the filler cards would themselves be rejected and reintroduce the integration error this change is meant to remove. Also note that duplicate entries in cardKeys would produce duplicate filler entries, since the filter only dedupes against already-answered keys.

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.

Both are fine, deliberately.

Omitted TTL — Plain documents the field as optional on purpose: "timeToLiveSeconds (optional, nullable): can either be omitted or null. If provided it will override the default time to live value." Omitting it means the filler card falls back to the TTL configured for that card in workspace settings, which is the behaviour we want for a card with no data — pinning a TTL here would override a setting somebody chose.

Duplicate keys — Plain enforces uniqueness: "Card keys must be unique within a workspace", matching [a-zA-Z0-9_-]+. So a cardKeys array cannot contain the same key twice. I dropped a test asserting the duplicate behaviour for exactly that reason: it locked in incidental behaviour rather than a requirement.

The failure that matters is the opposite one — a requested key coming back missing, which Plain records as an integration error. That is what the helper exists to prevent.

The admin layout gates on requireSuper via getUserId, which resolves to the
impersonated user while impersonating — so starting on a second target ran that
gate against the target and it answered with its own redirect to /. The switch
worked only because the router prefers the deepest redirect, which is too subtle
a thing for an auth path to depend on.

The trailing underscore keeps the route at /admin/impersonate while opting it out
of the layout. Nothing is lost: the route only ever redirects, so it never
rendered inside it.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +213 to +223
/**
* 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.
*/

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.

Comment on lines +40 to +49
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);
return admin?.admin ? admin : null;
}

@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.

…lumn

Opting out of the admin layout also opted out of its requireSuper check, leaving
this the only admin entry point gated on the raw User.admin column. canSuper()
equals that column in the OSS fallback, but an RBAC plugin is free to be
stricter.

The ability is now built explicitly for the real admin's id and canSuper() is
checked directly. dashboardLoader can't be used: it resolves its subject with
getUserId, which is the impersonated id while impersonating — the bug this route
exists to fix.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines 253 to 276
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,
},
});
});

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.

@isshaddad

Copy link
Copy Markdown
Collaborator Author

Split into two PRs, one per fix, per CONTRIBUTING.md:

Both branch off current main as a single squashed commit each, so the review history here isn't carried over. Every review comment on this PR was addressed or answered before the split, and the fixes are unchanged.

Closing in favour of those two.

@isshaddad isshaddad closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant