Skip to content

feat(admin): view the app as another user (#443)#445

Draft
alukach wants to merge 1 commit into
mainfrom
worktree-admin-view-as-user
Draft

feat(admin): view the app as another user (#443)#445
alukach wants to merge 1 commit into
mainfrom
worktree-admin-view-as-user

Conversation

@alukach

@alukach alukach commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Closes #443 — admin QA: view the product as a specified user.

What

An admin can open any individual user's profile and click View as user. The whole app then renders and behaves as that user. An always-on amber banner names the target and offers Exit.

How

The one seam is getPageSession() — the single server-side identity resolver every server component, server action, and cookie-based API call flows through. applyImpersonation() runs at the end and, only when the real resolved account is an admin and an encrypted sc_impersonate cookie is present, returns a session that fully assumes the target individual account:

  • identity_id, account, and memberships all swapped to the target
  • impersonator set to the real admin (for the banner + audit log line)
  • the admin's real orySession kept

Because it's a full identity swap, every one of the ~148 authz call sites and the data-proxy STS credential minting act as the target with no other changes. isAdmin() reads the swapped account, so the admin correctly loses admin powers while impersonating — you see exactly what the target sees.

Security

  • Gate is isAdmin(realSession). A forged cookie from a non-admin is inert, so the AES-GCM encryption (reusing the existing encrypted-cookie service) is defense-in-depth, not the boundary.
  • Start re-resolves the real admin session before setting the cookie, so an already-impersonated session can't chain into another user.
  • Only individual accounts (which have an Ory identity for the proxy to mint against) are assumable.
  • Cookie is httpOnly, secure, sameSite=lax; cleared on Exit and on /logout.
  • Blast radius is browser/cookie requests only — public /api/v1 Bearer auth never touches getPageSession(), so token clients can't be impersonated.
  • Self-healing data plane: proxy-cred cookies are bound to identity_id, so entering/exiting impersonation re-mints cleanly with no stale-cred leakage.

Decisions baked in (per design discussion)

  • Full identity (data plane included), not account-only.
  • Full interaction (mutations allowed, bounded by the target's own permissions); the always-on banner is the guardrail.
  • Entry point: profile-page button, admin-only.

Tests

impersonation.test.ts covers the four branches: admin+valid cookie → full swap; non-admin → inert (short-circuits before reading the cookie); admin+no cookie → unchanged; org target → ignored. tsc clean, existing admin/page-session suites pass.

Deliberately skipped (add when needed)

  • Read-only mode (block mutations while impersonating)
  • Audit-log table (currently a single LOGGER.info line)
  • Cookie TTL / auto-expiry

🤖 Generated with Claude Code

Adds an admin-only "View as user" control so admins can experience the
product exactly as a specified user for QA.

The single seam is getPageSession(): applyImpersonation() swaps identity_id,
account, and memberships to the target when the *real* resolved account is an
admin and an encrypted `sc_impersonate` cookie is present. Because it's a full
identity swap, all ~148 authz call sites and the data-proxy STS minting act as
the target with no other changes. isAdmin() reads the swapped account, so the
admin correctly loses admin powers while impersonating.

- Gate is isAdmin(realSession); a forged cookie from a non-admin is inert, so
  encryption is defense-in-depth, not the boundary.
- Only individual accounts (which have an Ory identity) are assumable.
- Start: admin-only button on a user's profile → startImpersonation action.
- Exit: always-on amber banner in the (app) layout → stopImpersonation action.
- Cleared on /logout too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
source-cooperative Error Error Jul 20, 2026 6:56am

Request Review

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 4m 36s —— View job


Review of #445 — view the app as another user

  • Gather context (diff, changed files)
  • Read impersonation.ts / impersonation.test.ts
  • Read session resolver (getPageSession) and api/utils.ts
  • Read admin actions, ViewAsButton, ImpersonationBanner, profile page, layout, logout route
  • Check cookie encryption service usage
  • Synthesize findings and post review

❌ Changes requested — see findings below.

  • src/components/features/auth/VerificationBanner.tsx:27-49 — impersonation corrupts the target account's email address. applyImpersonation (by design, per the PR description) swaps session.account to the target but keeps session.orySession as the real admin's Ory session. VerificationBanner mixes the two: it reads isEmailVerifiedInDb(session.account) (target) and, if that's false, checks isEmailVerifiedInOry(session.orySession) (admin!) — and if the admin's email is Ory-verified (true for essentially every admin), it calls accountsTable.update({...session.account, emails: oryAddressesToAccountEmails(session.orySession)}). That writes to DynamoDB keyed on session.account.account_id (the target's id) with the admin's own email address(es). In other words: an admin viewing-as any user whose local DB record hasn't yet synced emails.verified will silently overwrite that user's stored email with the admin's email on the very first page load. This is a real data-corruption / privacy bug (wrong PII written into another user's account), not just a display glitch.

    Suggested fix — skip the banner (and its DB write) entirely while impersonating, since orySession/account are from two different identities in that state:

    export async function VerificationBanner() {
      const session = await getPageSession();
      if (
        !session?.account ||
        !isIndividualAccount(session.account) ||
        session.impersonator
      ) {
        return null;
      }
      ...
  • src/lib/actions/admin.ts:104-113 (startImpersonation) — the "can't chain" gate checks the wrong session. The docstring claims getPageSession() here "resolves the REAL admin (the cookie isn't set yet)," but that's only true when no impersonation cookie exists yet. If an admin is already impersonating user A and navigates to another individual's profile without exiting first, getPageSession() returns the session already swapped to A (since applyImpersonation runs unconditionally inside it), and isAdmin(session) then checks A's admin flag, not the real admin's. In practice this fails closed (most impersonation targets aren't admins, so chaining is blocked), but it's blocked for the wrong reason — if the current target happens to itself be an admin account, chaining to a third user would be permitted based on the target's flag rather than a fresh check of the real admin. Not an escalation path (the real driver was already verified as admin when the first cookie was set), but the code doesn't actually implement the security property its own comment describes. Consider resolving the real underlying session explicitly (e.g., bypass applyImpersonation here, or check isAdmin on a session resolved before any cookie swap) rather than relying on getPageSession()'s output.

Everything else — the isAdmin(realSession) gate as the actual security boundary, AES-GCM cookie encryption, disabled/individual-only checks on both start and every re-resolution, cookie clearing on /logout and Exit, and the Bearer-auth path never touching getPageSession() — checks out and matches the PR's stated design. Test coverage in impersonation.test.ts for the four core branches is solid, though it doesn't cover a disabled target.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Proposed Feature] Ability to view application as other user

1 participant