From 904502f34196550fcff8b9fd7d3ffce195768d0f Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sun, 19 Jul 2026 23:56:05 -0700 Subject: [PATCH] feat(admin): view the app as another user (#443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[account_id]/IndividualProfilePage.tsx | 8 +- src/app/(app)/layout.tsx | 4 + src/app/(app)/logout/route.tsx | 9 ++ .../features/admin/ImpersonationBanner.tsx | 38 +++++++ .../features/admin/ViewAsButton.tsx | 20 ++++ .../features/profiles/IndividualProfile.tsx | 14 ++- src/lib/actions/admin.ts | 32 +++++- src/lib/api/utils.ts | 8 +- src/lib/services/impersonation.test.ts | 105 ++++++++++++++++++ src/lib/services/impersonation.ts | 98 ++++++++++++++++ src/types/session.ts | 7 ++ 11 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 src/components/features/admin/ImpersonationBanner.tsx create mode 100644 src/components/features/admin/ViewAsButton.tsx create mode 100644 src/lib/services/impersonation.test.ts create mode 100644 src/lib/services/impersonation.ts diff --git a/src/app/(app)/[account_id]/IndividualProfilePage.tsx b/src/app/(app)/[account_id]/IndividualProfilePage.tsx index 7aa92d76..2b875874 100644 --- a/src/app/(app)/[account_id]/IndividualProfilePage.tsx +++ b/src/app/(app)/[account_id]/IndividualProfilePage.tsx @@ -6,7 +6,7 @@ import { } from "@/lib/clients/database"; import { type IndividualAccount, Actions } from "@/types"; import { getPageSession } from "@/lib/api/utils"; -import { isAuthorized } from "@/lib/api/authz"; +import { isAdmin, isAuthorized } from "@/lib/api/authz"; import { IndividualProfile } from "@/components/features/profiles/IndividualProfile"; interface IndividualProfilePageProps { @@ -51,6 +51,12 @@ export async function IndividualProfilePage({ organizations={organizations} showWelcome={showWelcome} canEdit={isAuthorized(session, account, Actions.PutAccountProfile)} + // Admins (never an already-impersonated session, whose account is the + // non-admin target) can view the app as this user, unless it's their own. + canImpersonate={ + isAdmin(session) && + session?.account?.account_id !== account.account_id + } /> ); } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 521097be..155dc994 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import { Box, Container, Flex } from "@radix-ui/themes"; import { Navigation, Footer } from "@/components"; import { VerificationBanner } from "@/components/features/auth/VerificationBanner"; import { StepUpGuard } from "@/components/features/auth/StepUpGuard"; +import { ImpersonationBanner } from "@/components/features/admin/ImpersonationBanner"; interface AppLayoutProps { children: React.ReactNode; @@ -28,6 +29,9 @@ export default function AppLayout({ children }: AppLayoutProps) { + + + {children} diff --git a/src/app/(app)/logout/route.tsx b/src/app/(app)/logout/route.tsx index fa4f8de5..9cd638e0 100644 --- a/src/app/(app)/logout/route.tsx +++ b/src/app/(app)/logout/route.tsx @@ -1,6 +1,7 @@ import { CONFIG, LOGGER } from "@/lib"; import { NextRequest, NextResponse } from "next/server"; import { PROXY_CREDS_COOKIE_NAME } from "@/lib/services/proxy-credentials-shared"; +import { IMPERSONATION_COOKIE_NAME } from "@/lib/services/impersonation"; export async function GET(request: NextRequest) { const returnTo = new URL(request.url).origin; @@ -46,5 +47,13 @@ export async function GET(request: NextRequest) { secure: true, sameSite: "lax", }); + // Also drop any active impersonation so the next session starts as itself. + res.cookies.set(IMPERSONATION_COOKIE_NAME, "", { + path: "/", + maxAge: 0, + httpOnly: true, + secure: true, + sameSite: "lax", + }); return res; } diff --git a/src/components/features/admin/ImpersonationBanner.tsx b/src/components/features/admin/ImpersonationBanner.tsx new file mode 100644 index 00000000..997d9aa4 --- /dev/null +++ b/src/components/features/admin/ImpersonationBanner.tsx @@ -0,0 +1,38 @@ +import { Box, Button, Callout, Flex } from "@radix-ui/themes"; +import { EyeOpenIcon, ExitIcon } from "@radix-ui/react-icons"; +import { getPageSession } from "@/lib/api/utils"; +import { stopImpersonation } from "@/lib/actions/admin"; + +/** + * Always-on warning shown while an admin is viewing the app as another user. + * Renders nothing for a normal session. The session it reads is already the + * impersonated target (see `applyImpersonation`); `impersonator` carries the + * real admin so we can name who is really driving and offer an exit. + */ +export async function ImpersonationBanner() { + const session = await getPageSession(); + if (!session?.impersonator) return null; + + return ( + + + + + + + + + You are viewing the app as {session.account?.name} + . Actions you take are performed as this user. + + +
+ +
+
+
+
+ ); +} diff --git a/src/components/features/admin/ViewAsButton.tsx b/src/components/features/admin/ViewAsButton.tsx new file mode 100644 index 00000000..488fe7f0 --- /dev/null +++ b/src/components/features/admin/ViewAsButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "@radix-ui/themes"; +import { EyeOpenIcon } from "@radix-ui/react-icons"; +import { startImpersonation } from "@/lib/actions/admin"; + +/** + * Admin control (shown next to a user's profile Edit button) that starts + * viewing the app as that user. Rendering is gated by the caller — only an + * admin viewing someone else's profile sees it. The server action re-checks + * admin, so it is safe even if the button leaks into other renders. + */ +export function ViewAsButton({ targetAccountId }: { targetAccountId: string }) { + return ( +
+ + +
+ ); +} diff --git a/src/components/features/profiles/IndividualProfile.tsx b/src/components/features/profiles/IndividualProfile.tsx index 78ac699d..dafaa516 100644 --- a/src/components/features/profiles/IndividualProfile.tsx +++ b/src/components/features/profiles/IndividualProfile.tsx @@ -17,6 +17,7 @@ import { ProductsList } from "../products/ProductsList"; import { WebsiteLink } from "./WebsiteLink"; import { EmailVerificationStatus } from "./EmailVerificationStatus"; import { AvatarLinkCompact, EditButton } from "@/components/core"; +import { ViewAsButton } from "@/components/features/admin/ViewAsButton"; import { WelcomeCallout } from "./WelcomeCallout"; interface IndividualProfileProps { @@ -27,6 +28,7 @@ interface IndividualProfileProps { organizations: OrganizationalAccount[]; showWelcome?: boolean; canEdit: boolean; + canImpersonate?: boolean; } export function IndividualProfile({ @@ -37,6 +39,7 @@ export function IndividualProfile({ organizations, showWelcome = false, canEdit, + canImpersonate = false, }: IndividualProfileProps) { const primaryEmail = account.emails?.find((email) => email.is_primary); return ( @@ -61,9 +64,14 @@ export function IndividualProfile({ )} - {canEdit && ( - - )} + + {canImpersonate && ( + + )} + {canEdit && ( + + )} + diff --git a/src/lib/actions/admin.ts b/src/lib/actions/admin.ts index 1c5fe6ff..219443cb 100644 --- a/src/lib/actions/admin.ts +++ b/src/lib/actions/admin.ts @@ -1,12 +1,17 @@ "use server"; import { z } from "zod"; +import { redirect } from "next/navigation"; import { LOGGER } from "@/lib/logging"; import { isAdmin } from "../api/authz"; import { getOryIdentityIdByEmail, getPageSession } from "../api/utils"; -import { accountsTable } from "../clients"; +import { accountsTable, isIndividualAccount } from "../clients"; import { FormState } from "@/components/core/DynamicForm"; import { accountUrl } from "@/lib/urls"; +import { + setImpersonationTarget, + clearImpersonationTarget, +} from "@/lib/services/impersonation"; const LookupSchema = z.object({ email: z.string().trim().email("Enter a valid email address"), @@ -86,3 +91,28 @@ export async function lookupUserByEmail( redirectTo: accountUrl(account.account_id), }; } + +/** + * Admin-only: start viewing the app as another (individual) user. Sets the + * impersonation cookie that `getPageSession` honors, then reloads onto the + * target's profile. `getPageSession()` here resolves the REAL admin (the + * cookie isn't set yet), so the `isAdmin` gate can't be bypassed by an + * already-impersonated session. + */ +export async function startImpersonation(formData: FormData): Promise { + const session = await getPageSession(); + if (!isAdmin(session)) return; // button is admin-only; ignore stray posts + + const account_id = String(formData.get("account_id") ?? ""); + const target = await accountsTable.fetchById(account_id); + if (!target || target.disabled || !isIndividualAccount(target)) return; + + await setImpersonationTarget(account_id); + redirect(accountUrl(account_id)); +} + +/** Stop impersonating and return home. Safe for anyone — just clears a cookie. */ +export async function stopImpersonation(): Promise { + await clearImpersonationTarget(); + redirect("/"); +} diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index c0b2ab88..10481a69 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -42,6 +42,7 @@ import md5 from "md5"; import { authenticateWithOidcToken } from "./oidc"; import { CONFIG } from "@/lib/config"; import { LOGGER } from "@/lib/logging"; +import { applyImpersonation } from "@/lib/services/impersonation"; /** * Retrieves the current user session from the request context. @@ -117,13 +118,14 @@ export async function getPageSession(): Promise { ), ); - // Return the user session - return { + // Return the user session — swapped to the target when an admin is viewing + // the app as another user (no-op for everyone else). + return applyImpersonation({ identity_id, orySession, account, memberships: filteredMemberships, - }; + }); } /** diff --git a/src/lib/services/impersonation.test.ts b/src/lib/services/impersonation.test.ts new file mode 100644 index 00000000..fec945a1 --- /dev/null +++ b/src/lib/services/impersonation.test.ts @@ -0,0 +1,105 @@ +/** + * @jest-environment node + */ + +const mockGet = jest.fn(); +jest.mock("next/headers", () => ({ + cookies: jest.fn().mockResolvedValue({ + get: (...args: unknown[]) => mockGet(...args), + set: jest.fn(), + delete: jest.fn(), + }), +})); + +const mockFetchById = jest.fn(); +const mockListByUser = jest.fn(); +jest.mock("@/lib/clients/database", () => ({ + accountsTable: { fetchById: (...a: unknown[]) => mockFetchById(...a) }, + membershipsTable: { listByUser: (...a: unknown[]) => mockListByUser(...a) }, + isIndividualAccount: (acc: { type?: string }) => acc?.type === "individual", +})); + +const mockIsAdmin = jest.fn(); +jest.mock("@/lib/api/authz", () => ({ + isAdmin: (...a: unknown[]) => mockIsAdmin(...a), + isAuthorized: () => true, +})); + +import { applyImpersonation, IMPERSONATION_COOKIE_NAME } from "./impersonation"; +import { encryptJson } from "./encrypted-cookie"; +import type { UserSession } from "@/types"; + +const adminSession = (): UserSession => ({ + identity_id: "admin-ory-id", + orySession: { id: "sess" } as never, + account: { account_id: "admin", name: "Admin", type: "individual" } as never, + memberships: [], +}); + +const target = { + account_id: "alice", + name: "Alice", + type: "individual", + identity_id: "alice-ory-id", + disabled: false, +}; + +async function cookieFor(account_id: string) { + return { value: await encryptJson({ account_id }) }; +} + +describe("applyImpersonation", () => { + beforeEach(() => { + mockGet.mockReset(); + mockFetchById.mockReset(); + mockListByUser.mockReset().mockResolvedValue([]); + mockIsAdmin.mockReset(); + }); + + test("swaps identity, account, and sets impersonator when admin + valid cookie", async () => { + mockIsAdmin.mockReturnValue(true); + mockGet.mockReturnValue(await cookieFor("alice")); + mockFetchById.mockResolvedValue(target); + + const result = await applyImpersonation(adminSession()); + + expect(mockGet).toHaveBeenCalledWith(IMPERSONATION_COOKIE_NAME); + expect(result?.identity_id).toBe("alice-ory-id"); // full-identity swap + expect(result?.account?.account_id).toBe("alice"); + expect(result?.impersonator).toEqual({ account_id: "admin", name: "Admin" }); + }); + + test("returns the real session unchanged for a non-admin (forged cookie is inert)", async () => { + mockIsAdmin.mockReturnValue(false); + mockGet.mockReturnValue(await cookieFor("alice")); + + const real = { ...adminSession(), account: undefined }; + const result = await applyImpersonation(real); + + expect(result).toBe(real); + expect(mockGet).not.toHaveBeenCalled(); // short-circuits before reading cookie + expect(mockFetchById).not.toHaveBeenCalled(); + }); + + test("returns the real session when admin but no cookie", async () => { + mockIsAdmin.mockReturnValue(true); + mockGet.mockReturnValue(undefined); + + const real = adminSession(); + expect(await applyImpersonation(real)).toBe(real); + }); + + test("ignores a cookie pointing at a non-individual (organization) account", async () => { + mockIsAdmin.mockReturnValue(true); + mockGet.mockReturnValue(await cookieFor("acme")); + mockFetchById.mockResolvedValue({ + account_id: "acme", + name: "Acme", + type: "organization", + disabled: false, + }); + + const real = adminSession(); + expect(await applyImpersonation(real)).toBe(real); + }); +}); diff --git a/src/lib/services/impersonation.ts b/src/lib/services/impersonation.ts new file mode 100644 index 00000000..599d8054 --- /dev/null +++ b/src/lib/services/impersonation.ts @@ -0,0 +1,98 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { Actions, type UserSession } from "@/types"; +import { + accountsTable, + membershipsTable, + isIndividualAccount, +} from "@/lib/clients/database"; +import { isAdmin, isAuthorized } from "@/lib/api/authz"; +import { encryptJson, decryptJson } from "@/lib/services/encrypted-cookie"; +import { LOGGER } from "@/lib/logging"; + +/** Encrypted HTTP-only cookie naming the account an admin is viewing as. */ +export const IMPERSONATION_COOKIE_NAME = "sc_impersonate"; + +interface ImpersonationCookie { + account_id: string; +} + +/** + * Admin-only. Set/clear from a server action (action phase); a Server + * Component render cannot write cookies. Not gated here — callers verify the + * real session is an admin first. + */ +export async function setImpersonationTarget(account_id: string): Promise { + const jar = await cookies(); + jar.set( + IMPERSONATION_COOKIE_NAME, + await encryptJson({ account_id } satisfies ImpersonationCookie), + { httpOnly: true, secure: true, sameSite: "lax", path: "/" }, + ); +} + +export async function clearImpersonationTarget(): Promise { + const jar = await cookies(); + jar.delete(IMPERSONATION_COOKIE_NAME); +} + +/** + * If the real session is an admin and an impersonation cookie is present, + * returns a session that fully assumes the target individual account — + * `identity_id`, `account`, and `memberships` all swapped, `impersonator` set + * so the UI can render a banner. Otherwise returns `realSession` unchanged. + * + * The gate is `isAdmin(realSession)`: a forged cookie from a non-admin is + * inert, so the encryption is defense-in-depth, not the security boundary. + * Only individual accounts are assumable — they alone have an Ory identity for + * the data proxy to mint credentials against. + */ +export async function applyImpersonation( + realSession: UserSession | null, +): Promise { + if (!realSession || !isAdmin(realSession)) return realSession; + + const jar = await cookies(); + const token = jar.get(IMPERSONATION_COOKIE_NAME)?.value; + if (!token) return realSession; + + const decoded = await decryptJson(token); + if (!decoded?.account_id) return realSession; + + const target = await accountsTable.fetchById(decoded.account_id); + if (!target || target.disabled || !isIndividualAccount(target)) { + return realSession; + } + + const principal: UserSession = { + orySession: realSession.orySession, + account: target, + identity_id: target.identity_id, + }; + const memberships = ( + await membershipsTable.listByUser(target.account_id) + ).filter((membership) => + isAuthorized(principal, membership, Actions.GetMembership), + ); + + LOGGER.info("Admin viewing app as another user", { + operation: "applyImpersonation", + context: "impersonation", + metadata: { + admin: realSession.account?.account_id, + target: target.account_id, + }, + }); + + return { + identity_id: target.identity_id, + account: target, + memberships, + orySession: realSession.orySession, + impersonator: { + account_id: realSession.account!.account_id, + name: realSession.account!.name, + }, + }; +} diff --git a/src/types/session.ts b/src/types/session.ts index 5606db06..7a70dbd4 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -18,4 +18,11 @@ export interface UserSession { account?: Account; memberships?: Membership[]; orySession?: Session; + /** + * Present only when an admin is viewing the app as another user. Holds the + * real admin's identity so the UI can surface a banner; every other field on + * this session belongs to the impersonated target. See + * `src/lib/services/impersonation.ts`. + */ + impersonator?: { account_id: string; name: string }; }