GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — fix(webapp): two customer-card and impersonation fixes by isshaddad · Pull Request #4571 · triggerdotdev/trigger.dev

Conversation

@isshaddad

@isshaddad isshaddad commented Aug 11, 2026

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
isshaddad added 2 commits August 11, 2026 11:46
@isshaddad
fix(webapp): accept Plain customers without an external id on custome…
cb6088c
…r cards
@isshaddad
fix(webapp): let an admin switch impersonation target without stoppin…
5dc5f70
…g first
@changeset-bot

changeset-bot Bot commented Aug 11, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

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

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

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
  • X
  • Mastodon
  • Reddit
  • LinkedIn

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

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@github-actions

github-actions Bot commented Aug 11, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

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.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

coderabbitai[bot]

This comment was marked as resolved.

Sign in to view

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@isshaddad
fix(webapp): scope impersonation to id-matched customers and enforce …
6551a58
…session controls
@isshaddad
isshaddad marked this pull request as ready for review August 11, 2026 18:14
devin-ai-integration[bot]
devin-ai-integration Bot reviewed Aug 11, 2026
View reviewed changes

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread apps/webapp/app/routes/admin_.impersonate.tsx Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread apps/webapp/app/utils/plainCustomerCards.ts
@@ -0,0 +1,59 @@
import { z } from "zod";

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread apps/webapp/app/models/admin.server.ts Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread apps/webapp/app/routes/[email protected].$organizationSlug.$.tsx
// 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 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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/[email protected].$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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@isshaddad isshaddad Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
isshaddad added 2 commits August 11, 2026 14:21
@isshaddad
chore(webapp): drop the now-unused requireUser import
3c90ce3
@isshaddad
fix(webapp): keep the impersonation link through login, order audit rows
4aadcd5
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.

Sign in to view

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

devin-ai-integration[bot]
devin-ai-integration Bot reviewed Aug 11, 2026
View reviewed changes

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
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 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread apps/webapp/app/utils/plainCustomerCards.ts
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,
})
),
];

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@isshaddad
fix(webapp): write the impersonation audit pair atomically
f1c6eb3
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.

Sign in to view

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@isshaddad
fix(webapp): reset the view-as-user flag when switching impersonation…
4f7fc96
… 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[bot]
devin-ai-integration Bot reviewed Aug 11, 2026
View reviewed changes

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread apps/webapp/app/routes/admin_.impersonate.tsx Resolved

Uh oh!

There was an error while loading. Please reload this page.

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

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@isshaddad isshaddad Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@isshaddad
fix(webapp): take the impersonate route out of the admin layout
7be4c3b
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[bot]
devin-ai-integration Bot reviewed Aug 11, 2026
View reviewed changes

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

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

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread apps/webapp/app/routes/admin_.impersonate.tsx
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 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@isshaddad isshaddad Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@isshaddad
fix(webapp): gate the impersonate route on canSuper, not the admin co…
e5e910b
…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[bot]
devin-ai-integration Bot reviewed Aug 11, 2026
View reviewed changes

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread apps/webapp/app/models/admin.server.ts
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,
},
});
});

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

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
This was referenced Aug 11, 2026
fix(webapp): accept Plain customers without an external id on customer cards #4575
Merged
fix(webapp): let an admin switch impersonation target without stopping first #4576
Open
@isshaddad

isshaddad commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

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

  • fix(webapp): accept Plain customers without an external id on customer cards #4575 — the customer-card null externalId fix
  • fix(webapp): let an admin switch impersonation target without stopping first #4576 — the impersonation switch fix

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.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@isshaddad isshaddad closed this Aug 11, 2026
isshaddad added a commit that referenced this pull request Aug 12, 2026
@isshaddad
fix(webapp): accept Plain customers without an external id on custome…
4b4f6f2
…r cards (#4575)

Plain sends `customer.externalId` as an explicit `null` rather than
omitting the key. The schema validated it with `z.string().optional()`,
which accepts `undefined` but rejects `null`, so every customer we don't
set an `externalId` for got a 400 instead of a card — while the rest
worked, which made it look intermittent.

`email`, `externalId` and `thread` are now `nullish`. One of
email/externalId is still required, and the route's existing email
fallback resolves these customers.

Three related fixes in the same path:

- The route returned `{ cards: [] }` when no user matched. Plain records
an integration error for any requested key it doesn't get back, so that
surfaced as a broken card rather than a hidden one. Every requested key
is now answered, with `components: null` where there's no data.
- The impersonation link is offered only when the customer matched on
`externalId` — a value we set ourselves. An email match is a weaker
claim, since the address on a Plain customer isn't verified and for
customers created outside our own writes it comes from whoever sent the
message. Email-matched customers get the account rows without a
one-click impersonation link.
- The not-found log recorded raw customer identifiers; it now keeps
presence flags only.

The schema and the response helper moved to
`app/utils/plainCustomerCards.ts` so they can be unit-tested without
pulling in the db and env modules.

## Testing

`app/utils/plainCustomerCards.test.ts` — 11 tests covering the null
shapes, the every-key-answered response, and the missing-vs-zero
distinction. Verified locally.

Split out of #4571, which bundled this with an unrelated impersonation
fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@coderabbitai coderabbitai[bot] coderabbitai[bot] left review comments
@devin-ai-integration devin-ai-integration[bot] devin-ai-integration[bot] left review comments

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@isshaddad
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.