diff --git a/apps/api/.env.example b/apps/api/.env.example index b0afbd769..122d9d069 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -16,6 +16,10 @@ ADMIN_SECRET=your-secure-admin-secret-here # Use a different secret than ADMIN_SECRET to reduce blast radius. METRICS_DASHBOARD_SECRET=your-secure-metrics-dashboard-secret-here +# Kill switch for vortex_admin "act as another profile" sessions. Off unless explicitly +# "true". Turning it off also invalidates sessions that are already in flight. +IMPERSONATION_ENABLED=false + # Supabase Configuration SUPABASE_URL=https://your-project-id.supabase.co SUPABASE_ANON_KEY=your-anon-key-here diff --git a/apps/api/package.json b/apps/api/package.json index c3888e4d4..332aa12e9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -90,6 +90,7 @@ "scripts": { "build": "bun run swc src -d dist --strip-leading-paths", "dev": "NODE_ENV=development bun --watch src/index.ts", + "grant:vortex-admin": "bun scripts/grant-vortex-admin.ts", "migrate": "bun -r @swc-node/register src/database/migrator.ts", "migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all", "migrate:revert-last": "bun -r @swc-node/register src/database/migrator.ts revert", diff --git a/apps/api/scripts/grant-vortex-admin.ts b/apps/api/scripts/grant-vortex-admin.ts new file mode 100644 index 000000000..981a8ba93 --- /dev/null +++ b/apps/api/scripts/grant-vortex-admin.ts @@ -0,0 +1,37 @@ +/** + * Out-of-band operator tool: grants the vortex_admin capability role to a profile by + * email. Not exposed over HTTP — vortex_admin can act as any customer, including moving + * their money, so it must never be gated by the shared ADMIN_SECRET alone. + * + * Usage: + * bun run grant:vortex-admin + */ +import sequelize from "../src/config/database"; +import ProfileRole from "../src/models/profileRole.model"; +import User from "../src/models/user.model"; + +const email = process.argv[2]; +if (!email) { + throw new Error("Usage: bun run grant:vortex-admin "); +} + +try { + await sequelize.authenticate(); + + const user = await User.findOne({ where: { email } }); + if (!user) { + throw new Error(`No profile found with email: ${email}`); + } + + const [, created] = await ProfileRole.findOrCreate({ + defaults: { role: "vortex_admin", userId: user.id }, + where: { role: "vortex_admin", userId: user.id } + }); + + console.log(created ? `Granted vortex_admin to ${email} (${user.id}).` : `${email} (${user.id}) already has vortex_admin.`); +} catch (error) { + console.error(error instanceof Error ? error.message : "Failed to grant vortex_admin"); + process.exitCode = 1; +} finally { + await sequelize.close(); +} diff --git a/apps/api/src/api/controllers/admin-console/accounts.controller.ts b/apps/api/src/api/controllers/admin-console/accounts.controller.ts new file mode 100644 index 000000000..8d5f09b3d --- /dev/null +++ b/apps/api/src/api/controllers/admin-console/accounts.controller.ts @@ -0,0 +1,219 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { Op } from "sequelize"; +import logger from "../../../config/logger"; +import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase from "../../../models/kycCase.model"; +import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { isSessionActive } from "../../services/impersonation.service"; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function clampLimit(value: unknown): number { + const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN; + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT; + return Math.min(parsed, MAX_LIMIT); +} + +function parseCursor(value: unknown): number { + const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +} + +function emptyVerificationSummary(): Record { + return { + [VerificationStatus.Approved]: 0, + [VerificationStatus.InReview]: 0, + [VerificationStatus.Pending]: 0, + [VerificationStatus.Rejected]: 0, + [VerificationStatus.Started]: 0 + }; +} + +/** + * GET /v1/admin-console/accounts + * Paginated, search-filtered account list. Deliberately a cheap read — unlike + * onboarding.controller.ts's getOnboardingStatus, it never triggers provider status + * refreshes. + */ +export async function listAccounts(req: Request, res: Response): Promise { + try { + const search = typeof req.query.search === "string" ? req.query.search.trim() : ""; + const limit = clampLimit(req.query.limit); + const offset = parseCursor(req.query.cursor); + + const { rows: profiles, count: total } = await User.findAndCountAll({ + attributes: ["id", "email", "createdAt"], + limit: limit + 1, + offset, + order: [["createdAt", "DESC"]], + where: search ? { email: { [Op.iLike]: `%${search}%` } } : {} + }); + + const hasMore = profiles.length > limit; + const pageProfiles = hasMore ? profiles.slice(0, limit) : profiles; + const profileIds = pageProfiles.map(profile => profile.id); + + const [entities, activeAssignments] = await Promise.all([ + profileIds.length ? CustomerEntity.findAll({ where: { profileId: profileIds } }) : [], + profileIds.length + ? ProfilePartnerAssignment.findAll({ + where: { + [Op.or]: [{ expiresAt: null }, { expiresAt: { [Op.gt]: new Date() } }], + isActive: true, + userId: profileIds + } + }) + : [] + ]); + + const entityIds = entities.map(entity => entity.id); + const providerCustomers = entityIds.length + ? await ProviderCustomer.findAll({ attributes: ["customerEntityId", "status"], where: { customerEntityId: entityIds } }) + : []; + const entityProfileById = new Map(entities.map(entity => [entity.id, entity.profileId])); + + res.status(httpStatus.OK).json({ + accounts: pageProfiles.map(profile => { + const profileEntities = entities.filter(entity => entity.profileId === profile.id); + const verificationSummary = emptyVerificationSummary(); + for (const customer of providerCustomers) { + if (entityProfileById.get(customer.customerEntityId) === profile.id) { + verificationSummary[customer.status] += 1; + } + } + + return { + activePartnerName: activeAssignments.find(assignment => assignment.userId === profile.id)?.partnerName ?? null, + createdAt: profile.createdAt, + email: profile.email, + entities: profileEntities.map(entity => ({ id: entity.id, status: entity.status, type: entity.type })), + id: profile.id, + verificationSummary + }; + }), + limit, + nextCursor: hasMore ? String(offset + limit) : null, + total + }); + } catch (error) { + logger.error("Error listing admin-console accounts:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to list accounts", status: httpStatus.INTERNAL_SERVER_ERROR } + }); + } +} + +/** + * GET /v1/admin-console/accounts/:profileId + * Full account detail: entities, nested provider customers + KYC cases (mirrors the + * nesting in onboarding.controller.ts), and recent impersonation sessions targeting + * this profile. + */ +export async function getAccount(req: Request<{ profileId: string }>, res: Response): Promise { + try { + const { profileId } = req.params; + if (!UUID_PATTERN.test(profileId)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { code: "INVALID_PROFILE_ID", message: "profileId must be a valid UUID", status: httpStatus.BAD_REQUEST } + }); + return; + } + + const profile = await User.findByPk(profileId); + if (!profile) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "USER_NOT_FOUND", message: "Profile was not found", status: httpStatus.NOT_FOUND } + }); + return; + } + + const entities = await CustomerEntity.findAll({ where: { profileId } }); + const entityIds = entities.map(entity => entity.id); + + const [providerCustomers, kycCases, impersonationSessions] = await Promise.all([ + entityIds.length + ? ProviderCustomer.findAll({ order: [["updatedAt", "DESC"]], where: { customerEntityId: entityIds } }) + : [], + entityIds.length ? KycCase.findAll({ where: { customerEntityId: entityIds } }) : [], + AdminImpersonationSession.findAll({ + include: [{ as: "actor", attributes: ["id", "email"], model: User }], + limit: 20, + order: [["createdAt", "DESC"]], + where: { targetProfileId: profileId } + }) + ]); + + const kycCaseByProviderCustomer = new Map(); + for (const kycCase of kycCases) { + if (kycCase.providerCustomerId) { + kycCaseByProviderCustomer.set(kycCase.providerCustomerId, kycCase); + } + } + + res.status(httpStatus.OK).json({ + activeEntityId: profile.activeCustomerEntityId, + createdAt: profile.createdAt, + email: profile.email, + entities: entities.map(entity => ({ + country: entity.country, + id: entity.id, + providerCustomers: providerCustomers + .filter(customer => customer.customerEntityId === entity.id) + .map(customer => { + const kycCase = kycCaseByProviderCustomer.get(customer.id) ?? null; + return { + companyName: customer.companyName, + country: customer.country, + createdAt: customer.createdAt, + customerType: customer.customerType, + id: customer.id, + kycCase: kycCase + ? { + approvedAt: kycCase.approvedAt, + failureReasons: kycCase.failureReasons, + id: kycCase.id, + level: kycCase.level, + rejectedAt: kycCase.rejectedAt, + status: kycCase.status, + statusExternal: kycCase.statusExternal, + submittedAt: kycCase.submittedAt, + type: kycCase.type + } + : null, + provider: customer.provider, + rail: customer.rail, + status: customer.status, + statusExternal: customer.statusExternal, + updatedAt: customer.updatedAt + }; + }), + status: entity.status, + type: entity.type + })), + id: profile.id, + impersonationSessions: impersonationSessions.map(session => { + const actor = (session as AdminImpersonationSession & { actor?: User }).actor; + return { + active: isSessionActive(session), + actor: actor ? { email: actor.email, id: actor.id } : { email: null, id: session.actorProfileId }, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + id: session.id, + revokedAt: session.revokedAt, + revokedReason: session.revokedReason + }; + }) + }); + } catch (error) { + logger.error("Error reading admin-console account detail:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to read account", status: httpStatus.INTERNAL_SERVER_ERROR } + }); + } +} diff --git a/apps/api/src/api/controllers/admin-console/impersonation.controller.ts b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts new file mode 100644 index 000000000..6d9aa3ab0 --- /dev/null +++ b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts @@ -0,0 +1,217 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../../config/logger"; +import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; +import User from "../../../models/user.model"; +import { impersonationNotAllowedResponse } from "../../middlewares/bearerPrincipal"; +import { hasVortexAdminRole, vortexAdminRequiredResponse } from "../../middlewares/vortexAdminAuth"; +import { buildApiClientRequestMetadata, observeApiClientEvent } from "../../observability/apiClientEvent.service"; +import { getRequestDurationMs } from "../../observability/requestContext"; +import { + createSession, + ImpersonationActorError, + ImpersonationDisabledError, + ImpersonationTargetError, + isSessionActive, + listSessions, + revokeSession +} from "../../services/impersonation.service"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * POST /v1/admin-console/impersonation + * Mints an impersonation session for the calling vortex_admin. `req.userId` is that operator: + * `requireVortexAdmin` has already run `rejectImpersonation` (so no impersonation context can + * be in play) and confirmed the role. The raw token is returned exactly once. + */ +export async function createImpersonationSession(req: Request, res: Response): Promise { + const actorProfileId = req.userId as string; + const { targetProfileId } = req.body ?? {}; + + if (typeof targetProfileId !== "string" || !UUID_PATTERN.test(targetProfileId)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_IMPERSONATION_INPUT", + message: "targetProfileId must be a valid UUID", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + try { + const { token, session, target } = await createSession({ + actorProfileId, + targetProfileId + }); + + observeApiClientEvent({ + durationMs: getRequestDurationMs(req), + httpStatus: httpStatus.CREATED, + metadata: { ...buildApiClientRequestMetadata(req, {}), actorProfileId, targetProfileId }, + operation: "admin_impersonation_start", + requestId: req.requestId, + status: "success", + userId: actorProfileId + }); + + res.status(httpStatus.CREATED).json({ + expiresAt: session.expiresAt, + sessionId: session.id, + target: { email: target.email, id: target.id }, + token + }); + } catch (error) { + if (error instanceof ImpersonationActorError) { + vortexAdminRequiredResponse(res); + return; + } + if (error instanceof ImpersonationDisabledError) { + observeApiClientEvent({ + durationMs: getRequestDurationMs(req), + errorType: "service_unavailable", + httpStatus: httpStatus.SERVICE_UNAVAILABLE, + metadata: { ...buildApiClientRequestMetadata(req, {}), actorProfileId, targetProfileId }, + operation: "admin_impersonation_start", + requestId: req.requestId, + status: "failure", + userId: actorProfileId + }); + res.status(httpStatus.SERVICE_UNAVAILABLE).json({ + error: { code: "IMPERSONATION_DISABLED", message: error.message, status: httpStatus.SERVICE_UNAVAILABLE } + }); + return; + } + if (error instanceof ImpersonationTargetError) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { code: "IMPERSONATION_TARGET_INVALID", message: error.message, status: httpStatus.BAD_REQUEST } + }); + return; + } + + logger.error("Error creating impersonation session:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create impersonation session", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +/** + * GET /v1/admin-console/impersonation + * Active + recent sessions, audit-view style. + */ +export async function listImpersonationSessions(req: Request, res: Response): Promise { + try { + const parsedLimit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const limit = Number.isInteger(parsedLimit) && (parsedLimit as number) > 0 ? parsedLimit : undefined; + const sessions = await listSessions({ limit }); + + res.status(httpStatus.OK).json({ + sessions: sessions.map(session => { + const withParties = session as AdminImpersonationSession & { actor?: User; target?: User }; + return { + active: isSessionActive(session), + actor: withParties.actor + ? { email: withParties.actor.email, id: withParties.actor.id } + : { email: null, id: session.actorProfileId }, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + id: session.id, + revokedAt: session.revokedAt, + revokedReason: session.revokedReason, + target: withParties.target + ? { email: withParties.target.email, id: withParties.target.id } + : { email: null, id: session.targetProfileId } + }; + }) + }); + } catch (error) { + logger.error("Error listing impersonation sessions:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to list impersonation sessions", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +/** + * DELETE /v1/admin-console/impersonation/:sessionId + * Ends a session. A non-impersonated vortex_admin may revoke any session. An impersonated + * caller may revoke ONLY its own active session (`req.impersonation.sessionId`) — the + * dashboard's "Exit impersonation" action — and cannot reach or revoke any other session. + */ +export async function deleteImpersonationSession(req: Request<{ sessionId: string }>, res: Response): Promise { + try { + const { sessionId } = req.params; + if (!UUID_PATTERN.test(sessionId)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_IMPERSONATION_SESSION_ID", + message: "sessionId must be a valid UUID", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + const isSelfRevoke = req.impersonation?.sessionId === sessionId; + + if (!isSelfRevoke) { + if (req.impersonation) { + impersonationNotAllowedResponse(res); + return; + } + if (!req.userId || !(await hasVortexAdminRole(req.userId))) { + vortexAdminRequiredResponse(res); + return; + } + } + + const session = await AdminImpersonationSession.findByPk(sessionId); + const revoked = session ? await revokeSession(sessionId, isSelfRevoke ? "ended_by_target" : "revoked_by_admin") : false; + + if (!revoked || !session) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "IMPERSONATION_SESSION_NOT_FOUND", + message: "Impersonation session was not found or already ended", + status: httpStatus.NOT_FOUND + } + }); + return; + } + + observeApiClientEvent({ + durationMs: getRequestDurationMs(req), + httpStatus: httpStatus.NO_CONTENT, + metadata: { + ...buildApiClientRequestMetadata(req, {}), + actorProfileId: session.actorProfileId, + targetProfileId: session.targetProfileId + }, + operation: "admin_impersonation_end", + requestId: req.requestId, + status: "success", + userId: req.userId ?? null + }); + + res.status(httpStatus.NO_CONTENT).send(); + } catch (error) { + logger.error("Error ending impersonation session:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to end impersonation session", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/controllers/admin/profileRoles.controller.test.ts b/apps/api/src/api/controllers/admin/profileRoles.controller.test.ts index a5c65131c..f3c0c3e13 100644 --- a/apps/api/src/api/controllers/admin/profileRoles.controller.test.ts +++ b/apps/api/src/api/controllers/admin/profileRoles.controller.test.ts @@ -1,9 +1,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import express from "express"; +import { config } from "../../../config/vars"; +import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; import ProfileRole from "../../../models/profileRole.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestUser } from "../../../test-utils/factories"; import profileRolesRoutes from "../../routes/v1/admin/profile-roles.route"; +import { createSession, resolveSession } from "../../services/impersonation.service"; const BASE_PATH = "/v1/admin/profile-roles"; const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type": "application/json" }; @@ -70,6 +73,55 @@ describe("profile roles admin routes", () => { expect(revokedAgain.status).toBe(404); }); + it("rejects granting vortex_admin via HTTP but still allows discount_manager", async () => { + const user = await createTestUser(); + + const blocked = await post({ role: "vortex_admin", userId: user.id }); + expect(blocked.status).toBe(403); + const body = (await blocked.json()) as { error: { code: string } }; + expect(body.error.code).toBe("ROLE_NOT_HTTP_GRANTABLE"); + expect(await ProfileRole.count({ where: { role: "vortex_admin", userId: user.id } })).toBe(0); + + const allowed = await post({ role: "discount_manager", userId: user.id }); + expect(allowed.status).toBe(201); + }); + + it("still allows revoking vortex_admin even though it cannot be granted via HTTP", async () => { + const user = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: user.id }); + + const revoked = await fetch(`${baseUrl}/${user.id}/vortex_admin`, { headers: ADMIN_HEADERS, method: "DELETE" }); + expect(revoked.status).toBe(204); + expect(await ProfileRole.count({ where: { userId: user.id } })).toBe(0); + }); + + it("revokes every live impersonation session when vortex_admin is removed", async () => { + const originalImpersonationEnabled = config.impersonationEnabled; + config.impersonationEnabled = true; + try { + const admin = await createTestUser(); + const firstTarget = await createTestUser(); + const secondTarget = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: admin.id }); + const first = await createSession({ actorProfileId: admin.id, targetProfileId: firstTarget.id }); + const second = await createSession({ actorProfileId: admin.id, targetProfileId: secondTarget.id }); + + const response = await fetch(`${baseUrl}/${admin.id}/vortex_admin`, { + headers: ADMIN_HEADERS, + method: "DELETE" + }); + + expect(response.status).toBe(204); + const sessions = await AdminImpersonationSession.findAll({ where: { actorProfileId: admin.id } }); + expect(sessions.every(session => session.revokedAt !== null)).toBe(true); + expect(sessions.every(session => session.revokedReason === "vortex_admin_role_revoked")).toBe(true); + expect(await resolveSession(first.token)).toBeNull(); + expect(await resolveSession(second.token)).toBeNull(); + } finally { + config.impersonationEnabled = originalImpersonationEnabled; + } + }); + it("addresses the profile by email as well as by id", async () => { const user = await createTestUser({ email: "manager@example.com" }); diff --git a/apps/api/src/api/controllers/admin/profileRoles.controller.ts b/apps/api/src/api/controllers/admin/profileRoles.controller.ts index 7602ee90a..9fbc82447 100644 --- a/apps/api/src/api/controllers/admin/profileRoles.controller.ts +++ b/apps/api/src/api/controllers/admin/profileRoles.controller.ts @@ -1,7 +1,13 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; +import sequelize from "../../../config/database"; import logger from "../../../config/logger"; -import ProfileRole, { PROFILE_ROLE_NAMES, type ProfileRoleName } from "../../../models/profileRole.model"; +import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; +import ProfileRole, { + HTTP_GRANTABLE_PROFILE_ROLES, + PROFILE_ROLE_NAMES, + type ProfileRoleName +} from "../../../models/profileRole.model"; import User from "../../../models/user.model"; function isProfileRoleName(role: unknown): role is ProfileRoleName { @@ -31,6 +37,17 @@ export async function addProfileRole(req: Request, res: Response): Promise return; } + if (!HTTP_GRANTABLE_PROFILE_ROLES.includes(role)) { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "ROLE_NOT_HTTP_GRANTABLE", + message: `${role} must be granted out-of-band (see scripts/grant-vortex-admin.ts), not via this endpoint`, + status: httpStatus.FORBIDDEN + } + }); + return; + } + const user = await findProfile(identifier); if (!user) { res.status(httpStatus.NOT_FOUND).json({ @@ -85,7 +102,21 @@ export async function removeProfileRole(req: Request<{ userIdOrEmail: string; ro } const user = await findProfile(userIdOrEmail); - const deleted = user ? await ProfileRole.destroy({ where: { role, userId: user.id } }) : 0; + const deleted = user + ? await sequelize.transaction(async transaction => { + // Share the actor-row lock used by session creation, so role removal cannot race + // with a new token being minted after the revocation sweep. + await User.findByPk(user.id, { attributes: ["id"], lock: transaction.LOCK.UPDATE, transaction }); + const deleted = await ProfileRole.destroy({ transaction, where: { role, userId: user.id } }); + if (deleted && role === "vortex_admin") { + await AdminImpersonationSession.update( + { revokedAt: new Date(), revokedReason: "vortex_admin_role_revoked" }, + { transaction, where: { actorProfileId: user.id, revokedAt: null } } + ); + } + return deleted; + }) + : 0; if (!deleted) { res.status(httpStatus.NOT_FOUND).json({ error: { diff --git a/apps/api/src/api/controllers/quote.controller.test.ts b/apps/api/src/api/controllers/quote.controller.test.ts new file mode 100644 index 000000000..a1b9965ea --- /dev/null +++ b/apps/api/src/api/controllers/quote.controller.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { buildQuoteRequestMetadata } from "./quote.controller"; + +describe("buildQuoteRequestMetadata", () => { + it("attributes successful quote events to the impersonation session", () => { + const metadata = buildQuoteRequestMetadata( + { + body: { inputAmount: "100", inputCurrency: "USDC", outputCurrency: "BRL", rampType: "SELL" }, + impersonation: { + actorProfileId: "actor-1", + expiresAt: new Date("2026-08-07T12:00:00.000Z"), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-1" + }, + method: "POST", + path: "/v1/quotes" + }, + "quote_create" + ); + + expect(metadata).toEqual({ + impersonationSessionId: "session-1", + impersonatorProfileId: "actor-1", + requestBodyInputAmount: "100", + requestBodyInputCurrency: "USDC", + requestBodyOutputCurrency: "BRL", + requestBodyRampType: "SELL", + requestMethod: "POST", + requestPath: "/v1/quotes" + }); + }); +}); diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index 6206af397..b96c78eea 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -65,6 +65,7 @@ export const createQuote = async ( apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]), durationMs: getRequestDurationMs(req), httpStatus: httpStatus.CREATED, + metadata: buildQuoteRequestMetadata(req, "quote_create"), network, operation: "quote_create", partnerId: req.credential?.partnerId || null, @@ -128,6 +129,7 @@ export const createBestQuote = async ( apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]), durationMs: getRequestDurationMs(req), httpStatus: httpStatus.CREATED, + metadata: buildQuoteRequestMetadata(req, "quote_create_best"), network: quote.network, operation: "quote_create_best", partnerId: req.credential?.partnerId || null, @@ -177,6 +179,7 @@ export const getQuote = async ( observeApiClientEvent({ durationMs: getRequestDurationMs(req), httpStatus: httpStatus.OK, + metadata: buildQuoteRequestMetadata(req, "quote_get"), network: quote.network, operation: "quote_get", paymentMethod: quote.paymentMethod, @@ -205,6 +208,7 @@ interface ObservedQuoteRequest { query?: unknown; requestId?: string; requestStartedAt?: number; + impersonation?: Request["impersonation"]; userId?: string; } @@ -237,7 +241,7 @@ function observeQuoteFailure( }); } -function buildQuoteRequestMetadata(req: ObservedQuoteRequest, operation: QuoteOperation): Record { +export function buildQuoteRequestMetadata(req: ObservedQuoteRequest, operation: QuoteOperation): Record { if (operation === "quote_get") { return buildApiClientRequestMetadata(req, { paramKeys: ["id"] }); } diff --git a/apps/api/src/api/controllers/ramp.controller.test.ts b/apps/api/src/api/controllers/ramp.controller.test.ts index f78d4cfa9..2de2f3829 100644 --- a/apps/api/src/api/controllers/ramp.controller.test.ts +++ b/apps/api/src/api/controllers/ramp.controller.test.ts @@ -3,7 +3,37 @@ import { describe, expect, it } from "bun:test"; import httpStatus from "http-status"; import { APIError } from "../errors/api-error"; import { classifyApiClientError } from "../observability/errorClassifier"; -import { formatProviderContext, mapProviderFailure } from "./ramp.controller"; +import { buildRampRequestMetadata, formatProviderContext, mapProviderFailure } from "./ramp.controller"; + +describe("buildRampRequestMetadata", () => { + it("attributes successful money-movement events to the impersonation session", () => { + const metadata = buildRampRequestMetadata( + { + body: { additionalData: { taxId: "sensitive" }, quoteId: "quote-1", signingAccounts: ["account-1"] }, + impersonation: { + actorProfileId: "actor-1", + expiresAt: new Date("2026-08-07T12:00:00.000Z"), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-1" + }, + method: "POST", + path: "/v1/ramp/register" + }, + "ramp_register" + ); + + expect(metadata).toEqual({ + hasRequestBodyAdditionalData: true, + impersonationSessionId: "session-1", + impersonatorProfileId: "actor-1", + requestBodyQuoteId: "quote-1", + requestBodySigningAccountsCount: 1, + requestMethod: "POST", + requestPath: "/v1/ramp/register" + }); + }); +}); describe("mapProviderFailure", () => { it("maps a 4xx Avenia rejection (e.g. blocked user) to a 422 with a sanitized public message", () => { diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index c8d2ffea4..69d118b66 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -410,6 +410,7 @@ interface ObservedRampRequest { requestId?: string; requestStartedAt?: number; credential?: Request["credential"]; + impersonation?: Request["impersonation"]; userId?: string; } @@ -423,6 +424,7 @@ function observeRampSuccess( ...context, durationMs: getRequestDurationMs(req), httpStatus: status, + metadata: buildRampRequestMetadata(req, operation), operation, partnerId: req.credential?.partnerId || null, partnerName: req.authenticatedPartner?.name || null, @@ -455,7 +457,7 @@ function observeRampFailure( }); } -function buildRampRequestMetadata(req: ObservedRampRequest, operation: RampObservedOperation): Record { +export function buildRampRequestMetadata(req: ObservedRampRequest, operation: RampObservedOperation): Record { if (operation === "ramp_register") { return buildApiClientRequestMetadata(req, { bodyKeys: ["quoteId", "signingAccounts", "additionalData"] }); } diff --git a/apps/api/src/api/middlewares/bearerPrincipal.test.ts b/apps/api/src/api/middlewares/bearerPrincipal.test.ts new file mode 100644 index 000000000..c08d7c2d5 --- /dev/null +++ b/apps/api/src/api/middlewares/bearerPrincipal.test.ts @@ -0,0 +1,139 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { config } from "../../config/vars"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { SupabaseAuthService } from "../services/auth"; +import { createSession, revokeSession } from "../services/impersonation.service"; +import { rejectImpersonation, resolveBearerPrincipal } from "./bearerPrincipal"; + +function response(): Response & { json: ReturnType; status: ReturnType } { + const res = {} as Response & { json: ReturnType; status: ReturnType }; + res.status = mock(() => res); + res.json = mock(() => res); + return res; +} + +describe("resolveBearerPrincipal", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + mock.restore(); + }); + + afterAll(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + it("resolves a live impersonation token to the target, not the actor", async () => { + const actor = await createTestUser(); + const target = await createTestUser({ email: "target@example.com" }); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const principal = await resolveBearerPrincipal(token); + + expect(principal).toEqual({ + impersonation: { + actorProfileId: actor.id, + expiresAt: session.expiresAt, + sessionId: session.id, + targetEmail: target.email, + targetProfileId: target.id + }, + userEmail: target.email, + userId: target.id, + valid: true + }); + if (principal.valid) { + expect(principal.userId).not.toBe(actor.id); + } + }); + + it("resolves a Supabase token unchanged, with impersonation undefined", async () => { + const verify = spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ + email: "user@example.com", + user_id: "user-1", + valid: true + }); + + const principal = await resolveBearerPrincipal("some-supabase-token"); + + expect(principal).toEqual({ userEmail: "user@example.com", userId: "user-1", valid: true }); + if (principal.valid) { + expect(principal.impersonation).toBeUndefined(); + } + expect(verify).toHaveBeenCalledTimes(1); + }); + + it("returns invalid for an expired impersonation token", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + await session.update({ expiresAt: new Date(Date.now() - 1000) }); + + expect(await resolveBearerPrincipal(token)).toEqual({ valid: false }); + }); + + it("returns invalid for a revoked impersonation token", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + await revokeSession(session.id, "manual revoke"); + + expect(await resolveBearerPrincipal(token)).toEqual({ valid: false }); + }); + + it("returns invalid for an unknown impersonation token", async () => { + expect(await resolveBearerPrincipal("vtx_imp_unknown-token")).toEqual({ valid: false }); + }); +}); + +describe("rejectImpersonation", () => { + it("calls next() when the request carries no impersonation context", () => { + const req = {} as Request; + const res = response(); + const next = mock(() => undefined) as NextFunction; + + rejectImpersonation(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("responds 403 IMPERSONATION_NOT_ALLOWED and does not call next() when impersonation is set", () => { + const req = { + impersonation: { + actorProfileId: "actor-1", + expiresAt: new Date(), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-1" + } + } as Request; + const res = response(); + const next = mock(() => undefined) as NextFunction; + + rejectImpersonation(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(httpStatus.FORBIDDEN); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: "IMPERSONATION_NOT_ALLOWED", + message: "This action is not available while acting as another account.", + status: httpStatus.FORBIDDEN + } + }); + }); +}); diff --git a/apps/api/src/api/middlewares/bearerPrincipal.ts b/apps/api/src/api/middlewares/bearerPrincipal.ts new file mode 100644 index 000000000..be80834f4 --- /dev/null +++ b/apps/api/src/api/middlewares/bearerPrincipal.ts @@ -0,0 +1,64 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { SupabaseAuthService } from "../services/auth"; +import { type ImpersonationContext, isImpersonationToken, resolveSession } from "../services/impersonation.service"; + +export type { ImpersonationContext }; + +/** + * The principal a bearer token resolves to. An impersonation token resolves to the + * *target* profile — everything downstream (`getEffectiveUserId`, `ownershipAuth`, + * controllers) then scopes to the target with no further changes. + */ +export type BearerPrincipal = + | { valid: true; userId: string; userEmail?: string; impersonation?: ImpersonationContext } + | { valid: false }; + +/** + * Single entry point for turning a bearer token into a principal. Routes on the + * `vtx_imp_` prefix so ordinary Supabase tokens keep exactly their current path and cost. + */ +export async function resolveBearerPrincipal(token: string): Promise { + if (isImpersonationToken(token)) { + const impersonation = await resolveSession(token); + if (!impersonation) { + return { valid: false }; + } + return { + impersonation, + userEmail: impersonation.targetEmail, + userId: impersonation.targetProfileId, + valid: true + }; + } + + const result = await SupabaseAuthService.verifyToken(token); + if (!result.valid || !result.user_id) { + return { valid: false }; + } + return { userEmail: result.email, userId: result.user_id, valid: true }; +} + +/** + * Refuses routes that an impersonated caller must never reach: minting API credentials + * (which would outlive the session and become a permanent backdoor) and the admin console + * itself (no privilege re-escalation, no impersonation chaining). + */ +export function rejectImpersonation(req: Request, res: Response, next: NextFunction): void { + if (req.impersonation) { + impersonationNotAllowedResponse(res); + return; + } + next(); +} + +/** Shared with the routes that gate on impersonation inline instead of via the middleware. */ +export function impersonationNotAllowedResponse(res: Response): void { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "IMPERSONATION_NOT_ALLOWED", + message: "This action is not available while acting as another account.", + status: httpStatus.FORBIDDEN + } + }); +} diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index 11aa3e8f4..9ffd54852 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -6,8 +6,8 @@ import { observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; -import { SupabaseAuthService } from "../services/auth"; import { getKeyType, isValidSecretKeyFormat, validatePublicApiKey, validateSecretApiKey } from "./apiKeyAuth.helpers"; +import { resolveBearerPrincipal } from "./bearerPrincipal"; export { assertQuoteOwnership, assertRampOwnership } from "./ownershipAuth"; @@ -88,7 +88,7 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } if (authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7); - const result = await SupabaseAuthService.verifyToken(token); + const result = await resolveBearerPrincipal(token); if (!result.valid) { recordDualAuthFailure(req, 401, "auth_invalid_api_key"); return res.status(401).json({ @@ -100,8 +100,9 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } }); } - req.userId = result.user_id; - req.userEmail = result.email; + req.userId = result.userId; + req.userEmail = result.userEmail; + req.impersonation = result.impersonation; return next(); } diff --git a/apps/api/src/api/middlewares/maintenanceGuard.test.ts b/apps/api/src/api/middlewares/maintenanceGuard.test.ts index eb6e8bdaf..c0e9ec0c8 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.test.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -31,7 +31,7 @@ const observedEvents: ApiClientEventInput[] = []; const controllerCalls: string[] = []; mock.module("../observability/apiClientEvent.service", () => ({ - buildApiClientRequestMetadata: mock(() => ({})), + buildApiClientRequestMetadata: mock(apiClientEventServiceReal.buildApiClientRequestMetadata), getSafeApiKeyPrefix: mock((apiKey: string | null | undefined) => apiKey?.slice(0, 16) || null), observeApiClientEvent: mock((event: ApiClientEventInput) => { observedEvents.push(event); @@ -170,6 +170,7 @@ describe("rejectDuringActiveMaintenance", () => { quoteId: "quote-1", rampType: "BUY" }, + impersonation: { actorProfileId: "actor-1", sessionId: "session-1" }, requestId: "request-1", requestStartedAt: Date.now() - 50 } as Request, @@ -204,11 +205,13 @@ describe("rejectDuringActiveMaintenance", () => { apiKeyPrefix: "pk_live_", errorType: "service_unavailable", httpStatus: 503, - metadata: { + metadata: expect.objectContaining({ + impersonationSessionId: "session-1", + impersonatorProfileId: "actor-1", maintenance_end: end, maintenance_start: start, maintenance_title: "Database upgrade" - }, + }), operation: "quote_create", paymentMethod: "pix", quoteId: "quote-1", diff --git a/apps/api/src/api/middlewares/maintenanceGuard.ts b/apps/api/src/api/middlewares/maintenanceGuard.ts index 8ffbb85da..f0760ae82 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -1,7 +1,7 @@ import type { NextFunction, Request, RequestHandler, Response } from "express"; import httpStatus from "http-status"; import { APIError } from "../errors/api-error"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { classifyApiClientError, getErrorMessage } from "../observability/errorClassifier"; import { getRequestDurationMs } from "../observability/requestContext"; import type { ApiClientOperation } from "../observability/types"; @@ -82,6 +82,7 @@ function observeMaintenanceDenial( errorType: classifyApiClientError(error, httpStatus.SERVICE_UNAVAILABLE), httpStatus: httpStatus.SERVICE_UNAVAILABLE, metadata: { + ...buildApiClientRequestMetadata(req), maintenance_end: maintenanceDetails.end_datetime, maintenance_start: maintenanceDetails.start_datetime, maintenance_title: maintenanceDetails.title diff --git a/apps/api/src/api/middlewares/ownershipAuth.impersonation.test.ts b/apps/api/src/api/middlewares/ownershipAuth.impersonation.test.ts new file mode 100644 index 000000000..0b05dbf92 --- /dev/null +++ b/apps/api/src/api/middlewares/ownershipAuth.impersonation.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import RampState from "../../models/rampState.model"; +import { assertRampOwnership } from "./ownershipAuth"; + +// Impersonation only substitutes the principal at the bearer-token seam (req.userId becomes +// the target's profile id); ownership checks never see `req.impersonation` itself. These tests +// confirm the target's rights apply, and only the target's. +describe("assertRampOwnership under impersonation", () => { + const originalRampFindByPk = RampState.findByPk; + + afterEach(() => { + RampState.findByPk = originalRampFindByPk; + }); + + const impersonation = { + actorProfileId: "operator-1", + expiresAt: new Date(Date.now() + 60_000), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-user" + }; + + it("allows an impersonated request to access a ramp owned by the target", async () => { + RampState.findByPk = mock(async () => ({ + quoteId: "quote-1", + userId: "target-user" + })) as typeof RampState.findByPk; + + await expect( + assertRampOwnership({ impersonation, userId: "target-user" } as never, "ramp-1") + ).resolves.toBeUndefined(); + }); + + it("denies an impersonated request access to a ramp owned by an unrelated third profile", async () => { + RampState.findByPk = mock(async () => ({ + quoteId: "quote-1", + userId: "unrelated-third-profile" + })) as typeof RampState.findByPk; + + await expect( + assertRampOwnership({ impersonation, userId: "target-user" } as never, "ramp-1") + ).rejects.toThrow("Authenticated user does not own this ramp"); + }); +}); diff --git a/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts b/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts new file mode 100644 index 000000000..697948552 --- /dev/null +++ b/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts @@ -0,0 +1,114 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import { config } from "../../config/vars"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { SupabaseAuthService } from "../services/auth"; +import { createSession } from "../services/impersonation.service"; +import { optionalAuth, requireAuth } from "./supabaseAuth"; + +function request(authorization?: string): Request { + return { + headers: authorization === undefined ? {} : { authorization }, + path: "/v1/quote" + } as Request; +} + +function response(): Response & { json: ReturnType; status: ReturnType } { + const res = {} as Response & { json: ReturnType; status: ReturnType }; + res.json = mock(() => res); + res.status = mock(() => res); + return res; +} + +describe("Supabase auth middleware under impersonation", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + mock.restore(); + }); + + afterAll(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + it("requireAuth sets req.userId to the target and attaches req.impersonation", async () => { + const actor = await createTestUser({ email: "operator@example.com" }); + const target = await createTestUser({ email: "customer@example.com" }); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const req = request(`Bearer ${token}`); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.userId).toBe(target.id); + expect(req.impersonation).toEqual({ + actorProfileId: actor.id, + expiresAt: session.expiresAt, + sessionId: session.id, + targetEmail: target.email, + targetProfileId: target.id + }); + }); + + it("optionalAuth sets req.userId to the target and attaches req.impersonation", async () => { + const actor = await createTestUser({ email: "operator2@example.com" }); + const target = await createTestUser({ email: "customer2@example.com" }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const req = request(`Bearer ${token}`); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await optionalAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.userId).toBe(target.id); + expect(req.impersonation?.targetProfileId).toBe(target.id); + }); + + it("sets req.userEmail to the target's email, never the operator's", async () => { + const actor = await createTestUser({ email: "operator3@example.com" }); + const target = await createTestUser({ email: "customer3@example.com" }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const req = request(`Bearer ${token}`); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await requireAuth(req, res, next); + + expect(req.userEmail).toBe(target.email); + expect(req.userEmail).not.toBe(actor.email); + }); + + it("leaves req.impersonation undefined for a plain Supabase-authenticated request", async () => { + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ + email: "user@example.com", + user_id: "user-1", + valid: true + }); + + const req = request("Bearer plain-supabase-token"); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await requireAuth(req, res, next); + + expect(req.userId).toBe("user-1"); + expect(req.impersonation).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index 5b629546f..3db7269d3 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -1,6 +1,8 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; -import { AccessTokenVerificationError, SupabaseAuthService } from "../services/auth"; +import { AccessTokenVerificationError } from "../services/auth"; +import type { ImpersonationContext } from "../services/impersonation.service"; +import { resolveBearerPrincipal } from "./bearerPrincipal"; declare global { // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. @@ -8,6 +10,8 @@ declare global { interface Request { userId?: string; userEmail?: string; + /** Set only when the caller presented an impersonation token; `userId` is the target. */ + impersonation?: ImpersonationContext; } } } @@ -26,7 +30,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio } const token = authHeader.substring(7); - const result = await SupabaseAuthService.verifyToken(token); + const result = await resolveBearerPrincipal(token); if (!result.valid) { return res.status(401).json({ @@ -34,8 +38,9 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio }); } - req.userId = result.user_id; - req.userEmail = result.email; + req.userId = result.userId; + req.userEmail = result.userEmail; + req.impersonation = result.impersonation; next(); } catch (error) { const unavailable = error instanceof AccessTokenVerificationError && error.transient; @@ -60,12 +65,13 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti } try { - const result = await SupabaseAuthService.verifyToken(authHeader.substring(7)); + const result = await resolveBearerPrincipal(authHeader.substring(7)); if (!result.valid) { return res.status(401).json({ error: "Invalid or expired token" }); } - req.userId = result.user_id; - req.userEmail = result.email; + req.userId = result.userId; + req.userEmail = result.userEmail; + req.impersonation = result.impersonation; next(); } catch (error) { const unavailable = error instanceof AccessTokenVerificationError && error.transient; diff --git a/apps/api/src/api/middlewares/vortexAdminAuth.test.ts b/apps/api/src/api/middlewares/vortexAdminAuth.test.ts new file mode 100644 index 000000000..3dd092385 --- /dev/null +++ b/apps/api/src/api/middlewares/vortexAdminAuth.test.ts @@ -0,0 +1,78 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import express, { Request, Response } from "express"; +import { config } from "../../config/vars"; +import ProfileRole from "../../models/profileRole.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { SupabaseAuthService } from "../services/auth"; +import { createSession } from "../services/impersonation.service"; +import { requireVortexAdmin } from "./vortexAdminAuth"; + +describe("requireVortexAdmin", () => { + let server: ReturnType; + let baseUrl: string; + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use("/protected", requireVortexAdmin, (_req: Request, res: Response) => { + res.status(200).json({ ok: true }); + }); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not bind test server"); + } + baseUrl = `http://127.0.0.1:${address.port}/protected`; + }); + + afterAll(() => { + server?.close(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = originalImpersonationEnabled; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + it("rejects a profile without the vortex_admin role", async () => { + const user = await createTestUser(); + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ email: user.email, user_id: user.id, valid: true }); + + const response = await fetch(baseUrl, { headers: { Authorization: "Bearer whatever" } }); + expect(response.status).toBe(403); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("VORTEX_ADMIN_REQUIRED"); + }); + + it("passes a profile that holds the vortex_admin role", async () => { + const user = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: user.id }); + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ email: user.email, user_id: user.id, valid: true }); + + const response = await fetch(baseUrl, { headers: { Authorization: "Bearer whatever" } }); + expect(response.status).toBe(200); + }); + + it("rejects an impersonated caller even when the target holds the role", async () => { + config.impersonationEnabled = true; + const admin = await createTestUser(); + const target = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: admin.id }); + await ProfileRole.create({ role: "vortex_admin", userId: target.id }); + + const { token } = await createSession({ actorProfileId: admin.id, targetProfileId: target.id }); + + const response = await fetch(baseUrl, { headers: { Authorization: `Bearer ${token}` } }); + expect(response.status).toBe(403); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + }); +}); diff --git a/apps/api/src/api/middlewares/vortexAdminAuth.ts b/apps/api/src/api/middlewares/vortexAdminAuth.ts new file mode 100644 index 000000000..e521d3d52 --- /dev/null +++ b/apps/api/src/api/middlewares/vortexAdminAuth.ts @@ -0,0 +1,36 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import ProfileRole from "../../models/profileRole.model"; +import { rejectImpersonation } from "./bearerPrincipal"; +import { requireAuth } from "./supabaseAuth"; + +/** True when the profile holds the vortex_admin capability role. */ +export async function hasVortexAdminRole(userId: string): Promise { + return (await ProfileRole.findOne({ where: { role: "vortex_admin", userId } })) !== null; +} + +/** Shared with the routes that gate on the role inline instead of via `requireVortexAdmin`. */ +export function vortexAdminRequiredResponse(res: Response): void { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "VORTEX_ADMIN_REQUIRED", + message: "The vortex_admin role is required for this action.", + status: httpStatus.FORBIDDEN + } + }); +} + +async function checkVortexAdminRole(req: Request, res: Response, next: NextFunction): Promise { + if (!req.userId || !(await hasVortexAdminRole(req.userId))) { + vortexAdminRequiredResponse(res); + return; + } + + next(); +} + +/** + * Full guard for the /v1/admin-console surface: Supabase auth, then no impersonation + * chaining (no privilege re-escalation), then the vortex_admin capability role. + */ +export const requireVortexAdmin = [requireAuth, rejectImpersonation, checkVortexAdminRole]; diff --git a/apps/api/src/api/observability/apiClientEvent.service.test.ts b/apps/api/src/api/observability/apiClientEvent.service.test.ts index 1d45d779d..c8a69fa4c 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.test.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.test.ts @@ -120,6 +120,24 @@ describe("buildApiClientRequestMetadata", () => { }); }); + it("stamps impersonation metadata when the request carries an impersonation context", () => { + const metadata = buildApiClientRequestMetadata({ + impersonation: { actorProfileId: "actor-1", sessionId: "session-1" }, + method: "GET", + path: "/v1/ramp/status" + }); + + expect(metadata.impersonationSessionId).toBe("session-1"); + expect(metadata.impersonatorProfileId).toBe("actor-1"); + }); + + it("omits impersonation metadata keys entirely for a non-impersonated request", () => { + const metadata = buildApiClientRequestMetadata({ method: "GET", path: "/v1/ramp/status" }); + + expect("impersonationSessionId" in metadata).toBe(false); + expect("impersonatorProfileId" in metadata).toBe(false); + }); + it("records only counts or presence flags for allowlisted sensitive payload fields", () => { const metadata = buildApiClientRequestMetadata( { @@ -171,4 +189,23 @@ describe("recordApiClientEventSafe", () => { await expect(recordApiClientEventSafe({ operation: "quote_create", status: "failure" })).resolves.toBeUndefined(); }); + + it("persists impersonation metadata through sanitizeMetadata", async () => { + let created: Record | undefined; + ApiClientEvent.create = mock(async (attributes: Record) => { + created = attributes; + return attributes as never; + }) as typeof ApiClientEvent.create; + + const metadata = buildApiClientRequestMetadata({ + impersonation: { actorProfileId: "actor-1", sessionId: "session-1" }, + method: "GET", + path: "/v1/ramp/status" + }); + + await recordApiClientEventSafe({ metadata, operation: "ramp_status", status: "success", userId: "target-1" }); + + expect((created?.metadata as Record).impersonationSessionId).toBe("session-1"); + expect((created?.metadata as Record).impersonatorProfileId).toBe("actor-1"); + }); }); diff --git a/apps/api/src/api/observability/apiClientEvent.service.ts b/apps/api/src/api/observability/apiClientEvent.service.ts index 32be8c975..40508dbae 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -33,6 +33,7 @@ interface ApiClientRequestLike { params?: unknown; path?: string; query?: unknown; + impersonation?: { sessionId: string; actorProfileId: string }; } interface RequestMetadataOptions { @@ -91,6 +92,13 @@ export function buildApiClientRequestMetadata( requestPath: buildTemplatedRequestPath(req.path, req.params) }; + // Every event raised during an impersonated request stays attributable to the operator, + // even though `userId` on the event is the target's. + if (req.impersonation) { + metadata.impersonationSessionId = req.impersonation.sessionId; + metadata.impersonatorProfileId = req.impersonation.actorProfileId; + } + addSelectedValues(metadata, "requestBody", req.body, options.bodyKeys); addSelectedValues(metadata, "requestParam", req.params, options.paramKeys); addSelectedValues(metadata, "requestQuery", req.query, options.queryKeys); diff --git a/apps/api/src/api/observability/types.ts b/apps/api/src/api/observability/types.ts index 60ac1ab6a..268b7b2e5 100644 --- a/apps/api/src/api/observability/types.ts +++ b/apps/api/src/api/observability/types.ts @@ -10,7 +10,9 @@ export type ApiClientOperation = | "ramp_update" | "ramp_start" | "ramp_status" - | "ramp_errors"; + | "ramp_errors" + | "admin_impersonation_start" + | "admin_impersonation_end"; export type ApiClientEventStatus = "success" | "failure"; diff --git a/apps/api/src/api/routes/v1/admin-console/accounts.route.ts b/apps/api/src/api/routes/v1/admin-console/accounts.route.ts new file mode 100644 index 000000000..0685d7fdb --- /dev/null +++ b/apps/api/src/api/routes/v1/admin-console/accounts.route.ts @@ -0,0 +1,21 @@ +import { Router } from "express"; +import { getAccount, listAccounts } from "../../../controllers/admin-console/accounts.controller"; +import { requireVortexAdmin } from "../../../middlewares/vortexAdminAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(requireVortexAdmin); + +/** + * GET /v1/admin-console/accounts + * Paginated account list. ?search= matches email (case-insensitive, partial); ?cursor=/?limit= paginate. + */ +router.get("/", listAccounts); + +/** + * GET /v1/admin-console/accounts/:profileId + * Full account detail: entities, provider customers, KYC cases, recent impersonation sessions. + */ +router.get("/:profileId", getAccount); + +export default router; diff --git a/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts b/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts new file mode 100644 index 000000000..c5a77201a --- /dev/null +++ b/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts @@ -0,0 +1,261 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import express from "express"; +import { config } from "../../../../config/vars"; +import AdminImpersonationSession from "../../../../models/adminImpersonationSession.model"; +import ProfileRole from "../../../../models/profileRole.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../../test-utils/db"; +import { createTestAlfredpayCustomer, createTestUser } from "../../../../test-utils/factories"; +import { SupabaseAuthService } from "../../../services/auth"; +import { createSession } from "../../../services/impersonation.service"; +import accountsRoutes from "./accounts.route"; +import impersonationRoutes from "./impersonation.route"; + +describe("admin-console routes", () => { + let server: ReturnType; + let baseUrl: string; + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use("/v1/admin-console/accounts", accountsRoutes); + app.use("/v1/admin-console/impersonation", impersonationRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not bind test server"); + } + baseUrl = `http://127.0.0.1:${address.port}/v1/admin-console`; + }); + + afterAll(() => { + server?.close(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = originalImpersonationEnabled; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + async function createAdmin() { + const admin = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: admin.id }); + return admin; + } + + function authAs(user: { id: string; email: string }) { + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ email: user.email, user_id: user.id, valid: true }); + return { Authorization: "Bearer whatever" }; + } + + describe("GET /accounts", () => { + it("lists a profile with its entities and verification summary", async () => { + const admin = await createAdmin(); + const target = await createTestUser(); + await createTestAlfredpayCustomer(target.id); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/accounts?search=${encodeURIComponent(target.email)}`, { headers }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + accounts: { id: string; entities: { id: string }[]; verificationSummary: Record }[]; + }; + const account = body.accounts.find(a => a.id === target.id); + expect(account).toBeDefined(); + expect(account?.entities.length).toBe(1); + expect(account?.verificationSummary.approved).toBe(1); + }); + + it("returns full detail for a single profile", async () => { + const admin = await createAdmin(); + const target = await createTestUser(); + await createTestAlfredpayCustomer(target.id); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/accounts/${target.id}`, { headers }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + id: string; + entities: { providerCustomers: { provider: string }[] }[]; + impersonationSessions: unknown[]; + }; + expect(body.id).toBe(target.id); + expect(body.entities.length).toBe(1); + expect(body.entities[0].providerCustomers[0].provider).toBe("alfredpay"); + expect(body.impersonationSessions).toEqual([]); + }); + + it("returns 404 for an unknown profile", async () => { + const admin = await createAdmin(); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/accounts/${crypto.randomUUID()}`, { headers }); + expect(response.status).toBe(404); + }); + + it("returns 400 for a malformed profile id instead of leaking a database error", async () => { + const admin = await createAdmin(); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/accounts/not-a-uuid`, { headers }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("INVALID_PROFILE_ID"); + }); + }); + + describe("POST /impersonation", () => { + it("returns a token exactly once on the happy path", async () => { + config.impersonationEnabled = true; + const admin = await createAdmin(); + const target = await createTestUser(); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/impersonation`, { + body: JSON.stringify({ targetProfileId: target.id }), + headers: { ...headers, "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(201); + const body = (await response.json()) as { token: string; sessionId: string; expiresAt: string; target: { id: string } }; + expect(typeof body.token).toBe("string"); + expect(body.token.length).toBeGreaterThan(0); + expect(body.target.id).toBe(target.id); + + const session = await AdminImpersonationSession.findByPk(body.sessionId); + expect(session).not.toBeNull(); + // The raw token is never persisted — only its hash. + expect(session?.tokenHash).not.toBe(body.token); + }); + + it("maps the impersonation kill switch to 503", async () => { + config.impersonationEnabled = false; + const admin = await createAdmin(); + const target = await createTestUser(); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/impersonation`, { + body: JSON.stringify({ targetProfileId: target.id }), + headers: { ...headers, "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(503); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_DISABLED"); + }); + + it("returns 400 for a malformed target id", async () => { + config.impersonationEnabled = true; + const admin = await createAdmin(); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/impersonation`, { + body: JSON.stringify({ targetProfileId: "not-a-uuid" }), + headers: { ...headers, "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("INVALID_IMPERSONATION_INPUT"); + }); + + it("uses the default list limit for a negative query value", async () => { + config.impersonationEnabled = true; + const admin = await createAdmin(); + const target = await createTestUser(); + await createSession({ actorProfileId: admin.id, targetProfileId: target.id }); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/impersonation?limit=-1`, { headers }); + expect(response.status).toBe(200); + const body = (await response.json()) as { sessions: unknown[] }; + expect(body.sessions).toHaveLength(1); + }); + }); + + describe("DELETE /impersonation/:sessionId while impersonating", () => { + it("allows an impersonated caller to end its own session", async () => { + config.impersonationEnabled = true; + const admin = await createAdmin(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: admin.id, targetProfileId: target.id }); + + const response = await fetch(`${baseUrl}/impersonation/${session.id}`, { + headers: { Authorization: `Bearer ${token}` }, + method: "DELETE" + }); + + expect(response.status).toBe(204); + const reloaded = await AdminImpersonationSession.findByPk(session.id); + expect(reloaded?.revokedAt).not.toBeNull(); + }); + + it("refuses an impersonated caller ending a different session", async () => { + config.impersonationEnabled = true; + const admin = await createAdmin(); + const targetA = await createTestUser(); + const targetB = await createTestUser(); + const { token } = await createSession({ actorProfileId: admin.id, targetProfileId: targetA.id }); + const { session: otherSession } = await createSession({ + actorProfileId: admin.id, + targetProfileId: targetB.id + }); + + const response = await fetch(`${baseUrl}/impersonation/${otherSession.id}`, { + headers: { Authorization: `Bearer ${token}` }, + method: "DELETE" + }); + + expect(response.status).toBe(403); + const reloaded = await AdminImpersonationSession.findByPk(otherSession.id); + expect(reloaded?.revokedAt).toBeNull(); + }); + + it("refuses an impersonated caller from reaching GET /accounts or POST /impersonation", async () => { + config.impersonationEnabled = true; + const admin = await createAdmin(); + const target = await createTestUser(); + const { token } = await createSession({ actorProfileId: admin.id, targetProfileId: target.id }); + + const accountsResponse = await fetch(`${baseUrl}/accounts`, { headers: { Authorization: `Bearer ${token}` } }); + expect(accountsResponse.status).toBe(403); + + const postResponse = await fetch(`${baseUrl}/impersonation`, { + body: JSON.stringify({ targetProfileId: target.id }), + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + method: "POST" + }); + expect(postResponse.status).toBe(403); + }); + + it("still allows a non-impersonated vortex_admin to revoke any session", async () => { + config.impersonationEnabled = true; + const admin = await createAdmin(); + const target = await createTestUser(); + const { session } = await createSession({ actorProfileId: admin.id, targetProfileId: target.id }); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/impersonation/${session.id}`, { headers, method: "DELETE" }); + expect(response.status).toBe(204); + }); + + it("returns 400 for a malformed session id", async () => { + const admin = await createAdmin(); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/impersonation/not-a-uuid`, { headers, method: "DELETE" }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("INVALID_IMPERSONATION_SESSION_ID"); + }); + }); +}); diff --git a/apps/api/src/api/routes/v1/admin-console/impersonation.route.ts b/apps/api/src/api/routes/v1/admin-console/impersonation.route.ts new file mode 100644 index 000000000..15f2ef8d7 --- /dev/null +++ b/apps/api/src/api/routes/v1/admin-console/impersonation.route.ts @@ -0,0 +1,33 @@ +import { Router } from "express"; +import { + createImpersonationSession, + deleteImpersonationSession, + listImpersonationSessions +} from "../../../controllers/admin-console/impersonation.controller"; +import { requireAuth } from "../../../middlewares/supabaseAuth"; +import { requireVortexAdmin } from "../../../middlewares/vortexAdminAuth"; + +const router: Router = Router({ mergeParams: true }); + +/** + * POST /v1/admin-console/impersonation + * Starts an impersonation session. Body: { targetProfileId }. + */ +router.post("/", requireVortexAdmin, createImpersonationSession); + +/** + * GET /v1/admin-console/impersonation + * Active + recent impersonation sessions (audit view). + */ +router.get("/", requireVortexAdmin, listImpersonationSessions); + +/** + * DELETE /v1/admin-console/impersonation/:sessionId + * Ends a session. Not behind `requireVortexAdmin`: an impersonated caller must be able to + * end its OWN session (the dashboard's "Exit impersonation" action) without holding + * vortex_admin itself. Authorization for every other case is enforced inside the + * controller, which still requires vortex_admin to revoke anyone else's session. + */ +router.delete("/:sessionId", requireAuth, deleteImpersonationSession); + +export default router; diff --git a/apps/api/src/api/routes/v1/api-credentials.route.test.ts b/apps/api/src/api/routes/v1/api-credentials.route.test.ts new file mode 100644 index 000000000..1031fde17 --- /dev/null +++ b/apps/api/src/api/routes/v1/api-credentials.route.test.ts @@ -0,0 +1,69 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import express from "express"; +import { config } from "../../../config/vars"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { SupabaseAuthService } from "../../services/auth"; +import { createSession } from "../../services/impersonation.service"; +import apiCredentialsRoutes from "./api-credentials.route"; + +const BASE_PATH = "/v1/api-credentials"; + +describe("rejectImpersonation wiring on /v1/api-credentials", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use(BASE_PATH, apiCredentialsRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not bind test server"); + } + baseUrl = `http://127.0.0.1:${address.port}${BASE_PATH}`; + }); + + afterAll(() => { + server?.close(); + config.impersonationEnabled = originalImpersonationEnabled; + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + afterEach(() => { + mock.restore(); + }); + + it("refuses an impersonated caller with 403 IMPERSONATION_NOT_ALLOWED", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const res = await fetch(baseUrl, { headers: { Authorization: `Bearer ${token}` } }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + }); + + it("allows a plain authenticated (non-impersonated) caller through", async () => { + const user = await createTestUser(); + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ + email: user.email, + user_id: user.id, + valid: true + }); + + const res = await fetch(baseUrl, { headers: { Authorization: "Bearer plain-supabase-token" } }); + + expect(res.status).toBe(200); + }); +}); diff --git a/apps/api/src/api/routes/v1/api-credentials.route.ts b/apps/api/src/api/routes/v1/api-credentials.route.ts index 280d36e04..a04e669d0 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.ts @@ -1,9 +1,12 @@ import { Request, Response, Router } from "express"; import { createUserApiKey, listUserApiKeys, revokeUserApiKey } from "../../controllers/userApiKeys.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); router.use(requireAuth); +// A credential minted while acting as someone else would outlive the session. +router.use(rejectImpersonation); router.post("/", createUserApiKey as unknown as (req: Request, res: Response) => void); router.get("/", listUserApiKeys as unknown as (req: Request, res: Response) => void); router.delete("/:credentialId", revokeUserApiKey as unknown as (req: Request<{ credentialId: string }>, res: Response) => void); diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index a012174ac..5dc325ee8 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -7,6 +7,8 @@ import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import partnerPricingConfigsRoutes from "./admin/partner-pricing-configs.route"; import profilePartnerAssignmentsRoutes from "./admin/profile-partner-assignments.route"; import profileRolesRoutes from "./admin/profile-roles.route"; +import adminConsoleAccountsRoutes from "./admin-console/accounts.route"; +import adminConsoleImpersonationRoutes from "./admin-console/impersonation.route"; import alfredpayRoutes from "./alfredpay.route"; import apiCredentialsRoutes from "./api-credentials.route"; import authRoutes from "./auth.route"; @@ -241,8 +243,10 @@ router.use("/admin/profile-partner-assignments", profilePartnerAssignmentsRoutes router.use("/admin/partner-pricing-configs", partnerPricingConfigsRoutes); /** - * Admin routes for profile capability roles (e.g. discount_manager); profiles are - * addressed by id or email (unique key) + * Admin routes for profile capability roles; profiles are addressed by id or email + * (unique key). POST only grants HTTP-grantable roles (discount_manager) — vortex_admin + * must be granted out-of-band (see scripts/grant-vortex-admin.ts) since ADMIN_SECRET + * alone must never be sufficient to confer it. DELETE can still revoke any role. * POST /v1/admin/profile-roles * DELETE /v1/admin/profile-roles/:userIdOrEmail/:role */ @@ -255,6 +259,22 @@ router.use("/admin/managed-profiles", managedProfilesRoutes); */ router.use("/admin/api-client-events", apiClientEventsRoutes); +/** + * Vortex-admin console (Supabase-authenticated + vortex_admin role). Deliberately not + * under /v1/admin/*, which never accepts Supabase auth as a fallback + * (see docs/security-spec/01-auth/admin-auth.md). + * GET /v1/admin-console/accounts + * GET /v1/admin-console/accounts/:profileId + */ +router.use("/admin-console/accounts", adminConsoleAccountsRoutes); + +/** + * POST /v1/admin-console/impersonation + * GET /v1/admin-console/impersonation + * DELETE /v1/admin-console/impersonation/:sessionId + */ +router.use("/admin-console/impersonation", adminConsoleImpersonationRoutes); + router.get("/ip", (request: Request, response: Response) => { response.send(request.ip); }); diff --git a/apps/api/src/api/services/impersonation.service.test.ts b/apps/api/src/api/services/impersonation.service.test.ts new file mode 100644 index 000000000..f7cce1fc3 --- /dev/null +++ b/apps/api/src/api/services/impersonation.service.test.ts @@ -0,0 +1,274 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import crypto from "crypto"; +import { config } from "../../config/vars"; +import AdminImpersonationSession from "../../models/adminImpersonationSession.model"; +import ProfileRole from "../../models/profileRole.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { + createSession, + IMPERSONATION_TOKEN_PREFIX, + ImpersonationDisabledError, + ImpersonationTargetError, + listSessions, + resolveSession, + revokeSession +} from "./impersonation.service"; + +describe("impersonation.service", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + afterAll(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + async function createAdmin() { + const actor = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + return actor; + } + + it("persists only the SHA-256 hash of the token, never the raw value", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const expectedHash = crypto.createHash("sha256").update(token).digest("hex"); + expect(session.tokenHash).toBe(expectedHash); + expect(session.tokenHash).not.toBe(token); + + const reloaded = await AdminImpersonationSession.findByPk(session.id); + expect(reloaded?.tokenHash).toBe(expectedHash); + }); + + it("resolves a live token to the target's principal context", async () => { + const actor = await createAdmin(); + const target = await createTestUser({ email: "target@example.com" }); + + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const resolved = await resolveSession(token); + expect(resolved).toEqual({ + actorProfileId: actor.id, + expiresAt: session.expiresAt, + sessionId: session.id, + targetEmail: target.email, + targetProfileId: target.id + }); + }); + + it("returns null for an expired session", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + await session.update({ expiresAt: new Date(Date.now() - 1000) }); + + expect(await resolveSession(token)).toBeNull(); + }); + + it("returns null for a revoked session", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + await revokeSession(session.id, "manual revoke"); + + expect(await resolveSession(token)).toBeNull(); + }); + + it("returns null for an unknown token", async () => { + expect(await resolveSession(`${IMPERSONATION_TOKEN_PREFIX}unknown-token-value`)).toBeNull(); + }); + + it("stops resolving a live token when the actor role is removed out-of-band", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + await ProfileRole.destroy({ where: { role: "vortex_admin", userId: actor.id } }); + + expect(await resolveSession(token)).toBeNull(); + }); + + it("returns null for a non-vtx_imp_ string without hitting the database", async () => { + const findOne = spyOn(AdminImpersonationSession, "findOne"); + + expect(await resolveSession("some-supabase-token")).toBeNull(); + expect(findOne).not.toHaveBeenCalled(); + }); + + it("revokes the prior session with 'superseded' when a second session starts for the same actor and target", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + + const first = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + const second = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const reloadedFirst = await AdminImpersonationSession.findByPk(first.session.id); + expect(reloadedFirst?.revokedAt).not.toBeNull(); + expect(reloadedFirst?.revokedReason).toBe("superseded"); + + const reloadedSecond = await AdminImpersonationSession.findByPk(second.session.id); + expect(reloadedSecond?.revokedAt).toBeNull(); + }); + + it("serializes concurrent starts so exactly one session remains live", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + + await Promise.all([ + createSession({ actorProfileId: actor.id, targetProfileId: target.id }), + createSession({ actorProfileId: actor.id, targetProfileId: target.id }) + ]); + + const sessions = await AdminImpersonationSession.findAll({ + order: [["createdAt", "ASC"]], + where: { actorProfileId: actor.id, targetProfileId: target.id } + }); + expect(sessions).toHaveLength(2); + expect(sessions.filter(session => session.revokedAt === null)).toHaveLength(1); + expect(sessions.filter(session => session.revokedReason === "superseded")).toHaveLength(1); + }); + + it("rejects session creation when the actor no longer has the admin role", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + + await expect(createSession({ actorProfileId: actor.id, targetProfileId: target.id })).rejects.toThrow( + "Actor no longer has the vortex_admin role" + ); + }); + + it("rejects an actor impersonating themselves", async () => { + const actor = await createAdmin(); + + await expect(createSession({ actorProfileId: actor.id, targetProfileId: actor.id })).rejects.toBeInstanceOf( + ImpersonationTargetError + ); + }); + + it("rejects a non-existent target", async () => { + const actor = await createAdmin(); + + await expect( + createSession({ actorProfileId: actor.id, targetProfileId: crypto.randomUUID() }) + ).rejects.toBeInstanceOf(ImpersonationTargetError); + }); + + it("kill switch: disables new sessions and revokes resolution of already-live tokens", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + config.impersonationEnabled = false; + + await expect(createSession({ actorProfileId: actor.id, targetProfileId: target.id })).rejects.toBeInstanceOf( + ImpersonationDisabledError + ); + // The previously-minted token must stop resolving the instant the flag flips, not just + // block new sessions from being minted. + expect(await resolveSession(token)).toBeNull(); + }); + + it("writes last_used_at on first use and does not rewrite it within the throttle window", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + expect(session.lastUsedAt).toBeNull(); + + await resolveSession(token); + const afterFirstUse = await AdminImpersonationSession.findByPk(session.id); + expect(afterFirstUse?.lastUsedAt).not.toBeNull(); + + await resolveSession(token); + const afterSecondUse = await AdminImpersonationSession.findByPk(session.id); + expect(afterSecondUse?.lastUsedAt?.getTime()).toBe(afterFirstUse?.lastUsedAt?.getTime()); + }); + + it("does not overwrite the original revoked_at when revoking an already-revoked session", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + const { session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + expect(await revokeSession(session.id, "first reason")).toBe(true); + const firstRevoke = await AdminImpersonationSession.findByPk(session.id); + + expect(await revokeSession(session.id, "second reason")).toBe(false); + const secondRevoke = await AdminImpersonationSession.findByPk(session.id); + + expect(secondRevoke?.revokedAt?.getTime()).toBe(firstRevoke?.revokedAt?.getTime()); + expect(secondRevoke?.revokedReason).toBe("first reason"); + }); + + it("retains impersonation audit rows by restricting target deletion", async () => { + const actor = await createAdmin(); + const target = await createTestUser(); + const { session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + await expect(target.destroy()).rejects.toThrow(); + + expect(await AdminImpersonationSession.findByPk(session.id)).not.toBeNull(); + }); + + it("falls back to the default list limit for a negative value", async () => { + const firstActor = await createAdmin(); + const secondActor = await createAdmin(); + const firstTarget = await createTestUser(); + const secondTarget = await createTestUser(); + await createSession({ actorProfileId: firstActor.id, targetProfileId: firstTarget.id }); + await createSession({ actorProfileId: secondActor.id, targetProfileId: secondTarget.id }); + + expect(await listSessions({ limit: -1 })).toHaveLength(2); + }); + + it("lists active sessions before closed ones even when a closed one was created more recently", async () => { + const liveActor = await createAdmin(); + const liveTarget = await createTestUser(); + const { session: live } = await createSession({ actorProfileId: liveActor.id, targetProfileId: liveTarget.id }); + + // Distinct parties, so this does not supersede the session above. Created second, so it + // outranks `live` on createdAt alone — the ordering must still put the active one first. + const closedActor = await createAdmin(); + const closedTarget = await createTestUser(); + const { session: closed } = await createSession({ actorProfileId: closedActor.id, targetProfileId: closedTarget.id }); + await revokeSession(closed.id, "revoked_by_admin"); + + expect(closed.createdAt.getTime()).toBeGreaterThanOrEqual(live.createdAt.getTime()); + + const listed = await listSessions(); + expect(listed.map(session => session.id)).toEqual([live.id, closed.id]); + }); + + it("lists expired sessions after live ones", async () => { + const liveActor = await createAdmin(); + const liveTarget = await createTestUser(); + const { session: live } = await createSession({ actorProfileId: liveActor.id, targetProfileId: liveTarget.id }); + + const expiredActor = await createAdmin(); + const expiredTarget = await createTestUser(); + const { session: expired } = await createSession({ + actorProfileId: expiredActor.id, + targetProfileId: expiredTarget.id + }); + await expired.update({ expiresAt: new Date(Date.now() - 1000) }); + + const listed = await listSessions(); + expect(listed.map(session => session.id)).toEqual([live.id, expired.id]); + }); +}); diff --git a/apps/api/src/api/services/impersonation.service.ts b/apps/api/src/api/services/impersonation.service.ts new file mode 100644 index 000000000..66a5bd4aa --- /dev/null +++ b/apps/api/src/api/services/impersonation.service.ts @@ -0,0 +1,200 @@ +import crypto from "crypto"; +import { literal } from "sequelize"; +import sequelize from "../../config/database"; +import { config } from "../../config/vars"; +import AdminImpersonationSession from "../../models/adminImpersonationSession.model"; +import ProfileRole from "../../models/profileRole.model"; +import User from "../../models/user.model"; + +/** Opaque token prefix, so ordinary Supabase bearer tokens are routed without a DB hit. */ +export const IMPERSONATION_TOKEN_PREFIX = "vtx_imp_"; + +/** Non-renewable: continuing past this requires a fresh, separately audited admin action. */ +export const IMPERSONATION_TTL_MS = 30 * 60 * 1000; + +/** `last_used_at` is a liveness signal, not an access log — don't write it on every request. */ +const LAST_USED_THROTTLE_MS = 60 * 1000; + +/** + * The impersonated principal, resolved once per request and carried on `req.impersonation`. + * `targetEmail` matters: controllers such as mykobo/alfredpay/monerium key provider + * enrolment off `req.userEmail`, which must be the target's, never the operator's. + */ +export interface ImpersonationContext { + sessionId: string; + actorProfileId: string; + targetProfileId: string; + targetEmail: string; + expiresAt: Date; +} + +export class ImpersonationDisabledError extends Error { + constructor() { + super("Impersonation is disabled"); + } +} + +export class ImpersonationTargetError extends Error { + constructor(message: string) { + super(message); + } +} + +export class ImpersonationActorError extends Error { + constructor() { + super("Actor no longer has the vortex_admin role"); + } +} + +export function isImpersonationToken(token: string): boolean { + return token.startsWith(IMPERSONATION_TOKEN_PREFIX); +} + +function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +/** + * Mints a session and returns the raw token exactly once — only its SHA-256 is persisted, + * so a leaked database row cannot be replayed. + */ +export async function createSession(input: { + actorProfileId: string; + targetProfileId: string; +}): Promise<{ token: string; session: AdminImpersonationSession; target: User }> { + if (!config.impersonationEnabled) { + throw new ImpersonationDisabledError(); + } + + if (input.actorProfileId === input.targetProfileId) { + throw new ImpersonationTargetError("An admin cannot impersonate themselves"); + } + + const token = `${IMPERSONATION_TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`; + const { session, target } = await sequelize.transaction(async transaction => { + // Serialize all session creation for one operator. Without this lock, two concurrent + // requests can both run the revoke step before either inserts, leaving two live tokens. + const actor = await User.findByPk(input.actorProfileId, { + attributes: ["id"], + lock: transaction.LOCK.UPDATE, + transaction + }); + if (!actor) { + throw new ImpersonationTargetError("Actor profile was not found"); + } + + const [target, actorRole] = await Promise.all([ + User.findByPk(input.targetProfileId, { transaction }), + ProfileRole.findOne({ transaction, where: { role: "vortex_admin", userId: input.actorProfileId } }) + ]); + if (!target) { + throw new ImpersonationTargetError("Target profile was not found"); + } + if (!actorRole) { + throw new ImpersonationActorError(); + } + + // One active session per (actor, target): starting a new one closes the old one, so a + // forgotten tab can never hold rights alongside a fresh session. + await AdminImpersonationSession.update( + { revokedAt: new Date(), revokedReason: "superseded" }, + { + transaction, + where: { + actorProfileId: input.actorProfileId, + revokedAt: null, + targetProfileId: input.targetProfileId + } + } + ); + + const session = await AdminImpersonationSession.create( + { + actorProfileId: input.actorProfileId, + expiresAt: new Date(Date.now() + IMPERSONATION_TTL_MS), + targetProfileId: input.targetProfileId, + tokenHash: hashToken(token) + }, + { transaction } + ); + + return { session, target }; + }); + + return { session, target, token }; +} + +/** + * Resolves an opaque impersonation token to its principal. Returns null for anything that + * is not currently live — unknown, expired, revoked, or minted before the kill switch. + */ +export async function resolveSession(token: string): Promise { + if (!config.impersonationEnabled || !isImpersonationToken(token)) { + return null; + } + + const session = await AdminImpersonationSession.findOne({ where: { tokenHash: hashToken(token) } }); + if (!session || session.revokedAt !== null || session.expiresAt.getTime() <= Date.now()) { + return null; + } + + const [target, actorRole] = await Promise.all([ + User.findByPk(session.targetProfileId, { attributes: ["id", "email"] }), + ProfileRole.findOne({ attributes: ["id"], where: { role: "vortex_admin", userId: session.actorProfileId } }) + ]); + if (!target || !actorRole) { + return null; + } + + const now = Date.now(); + if (!session.lastUsedAt || now - session.lastUsedAt.getTime() >= LAST_USED_THROTTLE_MS) { + await session.update({ lastUsedAt: new Date(now) }); + } + + return { + actorProfileId: session.actorProfileId, + expiresAt: session.expiresAt, + sessionId: session.id, + targetEmail: target.email, + targetProfileId: session.targetProfileId + }; +} + +/** Ends a session immediately. Returns false when it does not exist or was already closed. */ +export async function revokeSession(sessionId: string, revokedReason: string): Promise { + const [updated] = await AdminImpersonationSession.update( + { revokedAt: new Date(), revokedReason: revokedReason.slice(0, 100) }, + { where: { id: sessionId, revokedAt: null } } + ); + return updated > 0; +} + +/** Operator-facing audit view: active sessions first, then recently closed ones. */ +export async function listSessions( + input: { actorProfileId?: string; limit?: number } = {} +): Promise { + const requestedLimit = input.limit ?? 50; + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 200) : 50; + + return AdminImpersonationSession.findAll({ + include: [ + { as: "actor", attributes: ["id", "email"], model: User }, + { as: "target", attributes: ["id", "email"], model: User } + ], + limit, + order: [ + // Mirrors isSessionActive() in SQL so live sessions sort above closed ones. + [ + literal(`("AdminImpersonationSession"."revoked_at" IS NULL AND "AdminImpersonationSession"."expires_at" > NOW())`), + "DESC" + ], + ["createdAt", "DESC"] + ], + where: input.actorProfileId ? { actorProfileId: input.actorProfileId } : undefined + }); +} + +/** True when the session is live right now — used to render "active" in the audit view. */ +export function isSessionActive(session: AdminImpersonationSession): boolean { + return session.revokedAt === null && session.expiresAt.getTime() > Date.now(); +} diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 610fba5be..09aa5ad8b 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -129,6 +129,8 @@ interface Config { logs: string; adminSecret: string; metricsDashboardSecret: string; + /** Kill switch for vortex_admin "act as another profile" sessions. */ + impersonationEnabled: boolean; supabase: { url: string; anonKey: string; @@ -228,6 +230,7 @@ export const config: Config = { deploymentEnv: readDeploymentEnv(), env: nodeEnv, flowVariant: readFlowVariant(), + impersonationEnabled: process.env.IMPERSONATION_ENABLED === "true", integrations: { alchemy: { diff --git a/apps/api/src/database/migrations/062-allow-vortex-admin-profile-role.ts b/apps/api/src/database/migrations/062-allow-vortex-admin-profile-role.ts new file mode 100644 index 000000000..b8186fca8 --- /dev/null +++ b/apps/api/src/database/migrations/062-allow-vortex-admin-profile-role.ts @@ -0,0 +1,18 @@ +import { QueryInterface } from "sequelize"; + +// Adds the 'vortex_admin' capability role. It grants access to the /v1/admin-console +// surface, which is the per-operator counterpart to the shared-secret /v1/admin routes. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query('ALTER TABLE "profile_roles" DROP CONSTRAINT "chk_profile_roles_role";'); + await queryInterface.sequelize.query( + `ALTER TABLE "profile_roles" ADD CONSTRAINT "chk_profile_roles_role" CHECK (role IN ('discount_manager', 'vortex_admin'));` + ); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(`DELETE FROM "profile_roles" WHERE role = 'vortex_admin';`); + await queryInterface.sequelize.query('ALTER TABLE "profile_roles" DROP CONSTRAINT "chk_profile_roles_role";'); + await queryInterface.sequelize.query( + `ALTER TABLE "profile_roles" ADD CONSTRAINT "chk_profile_roles_role" CHECK (role IN ('discount_manager'));` + ); +} diff --git a/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts b/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts new file mode 100644 index 000000000..3323c5364 --- /dev/null +++ b/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts @@ -0,0 +1,84 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Short-lived sessions letting a vortex_admin profile act as another profile. The raw +// token is never stored: lookup is by SHA-256 hash, so a session is revocable instantly. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("admin_impersonation_sessions", { + actor_profile_id: { + allowNull: false, + // RESTRICT: an impersonation record must not disappear with the operator who made it. + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + expires_at: { + allowNull: false, + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + last_used_at: { + allowNull: true, + type: DataTypes.DATE + }, + revoked_at: { + allowNull: true, + type: DataTypes.DATE + }, + revoked_reason: { + allowNull: true, + type: DataTypes.STRING(100) + }, + target_profile_id: { + allowNull: false, + // RESTRICT: the target is part of the security audit record and must not erase it. + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + token_hash: { + allowNull: false, + type: DataTypes.CHAR(64) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addIndex("admin_impersonation_sessions", ["token_hash"], { + name: "uq_admin_impersonation_sessions_token_hash", + unique: true + }); + await queryInterface.addIndex("admin_impersonation_sessions", ["target_profile_id"], { + name: "idx_admin_impersonation_sessions_target" + }); + await queryInterface.addIndex("admin_impersonation_sessions", ["actor_profile_id", "created_at"], { + name: "idx_admin_impersonation_sessions_actor_created" + }); + // Enforces one non-revoked session per actor/target even if application locking regresses. + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX "uq_admin_impersonation_sessions_active" + ON "admin_impersonation_sessions" ("actor_profile_id", "target_profile_id") + WHERE "revoked_at" IS NULL;` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "admin_impersonation_sessions" + ADD CONSTRAINT "chk_admin_impersonation_sessions_distinct" CHECK (actor_profile_id <> target_profile_id);` + ); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("admin_impersonation_sessions"); +} diff --git a/apps/api/src/models/adminImpersonationSession.model.ts b/apps/api/src/models/adminImpersonationSession.model.ts new file mode 100644 index 000000000..97787e45e --- /dev/null +++ b/apps/api/src/models/adminImpersonationSession.model.ts @@ -0,0 +1,86 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// A vortex_admin acting as another profile. `tokenHash` is the SHA-256 of the opaque +// bearer token handed to the operator once; the raw value is never persisted. +export interface AdminImpersonationSessionAttributes { + id: string; + actorProfileId: string; + targetProfileId: string; + tokenHash: string; + expiresAt: Date; + revokedAt: Date | null; + revokedReason: string | null; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type AdminImpersonationSessionCreationAttributes = Optional< + AdminImpersonationSessionAttributes, + "id" | "revokedAt" | "revokedReason" | "lastUsedAt" | "createdAt" | "updatedAt" +>; + +class AdminImpersonationSession + extends Model + implements AdminImpersonationSessionAttributes +{ + declare id: string; + declare actorProfileId: string; + declare targetProfileId: string; + declare tokenHash: string; + declare expiresAt: Date; + declare revokedAt: Date | null; + declare revokedReason: string | null; + declare lastUsedAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +AdminImpersonationSession.init( + { + actorProfileId: { + allowNull: false, + field: "actor_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + expiresAt: { allowNull: false, field: "expires_at", type: DataTypes.DATE }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + lastUsedAt: { allowNull: true, field: "last_used_at", type: DataTypes.DATE }, + revokedAt: { allowNull: true, field: "revoked_at", type: DataTypes.DATE }, + revokedReason: { allowNull: true, field: "revoked_reason", type: DataTypes.STRING(100) }, + targetProfileId: { + allowNull: false, + field: "target_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + tokenHash: { allowNull: false, field: "token_hash", type: DataTypes.CHAR(64) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE } + }, + { + indexes: [ + { fields: ["token_hash"], name: "uq_admin_impersonation_sessions_token_hash", unique: true }, + { fields: ["target_profile_id"], name: "idx_admin_impersonation_sessions_target" }, + { fields: ["actor_profile_id", "created_at"], name: "idx_admin_impersonation_sessions_actor_created" }, + { + fields: ["actor_profile_id", "target_profile_id"], + name: "uq_admin_impersonation_sessions_active", + unique: true, + where: { revoked_at: null } + } + ], + modelName: "AdminImpersonationSession", + sequelize, + tableName: "admin_impersonation_sessions", + timestamps: true + } +); + +export default AdminImpersonationSession; diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 8ea79bd55..414198b9c 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -1,4 +1,5 @@ import sequelize from "../config/database"; +import AdminImpersonationSession from "./adminImpersonationSession.model"; import Anchor from "./anchor.model"; import ApiClientEvent from "./apiClientEvent.model"; import ApiCredential from "./apiCredential.model"; @@ -46,6 +47,11 @@ ProfilePartnerAssignment.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasMany(ProfileRole, { as: "roles", foreignKey: "userId" }); ProfileRole.belongsTo(User, { as: "user", foreignKey: "userId" }); +User.hasMany(AdminImpersonationSession, { as: "impersonationsPerformed", foreignKey: "actorProfileId" }); +AdminImpersonationSession.belongsTo(User, { as: "actor", foreignKey: "actorProfileId" }); +User.hasMany(AdminImpersonationSession, { as: "impersonationsReceived", foreignKey: "targetProfileId" }); +AdminImpersonationSession.belongsTo(User, { as: "target", foreignKey: "targetProfileId" }); + User.hasMany(ApiCredential, { as: "apiCredentials", foreignKey: "profileId" }); ApiCredential.belongsTo(User, { as: "profile", foreignKey: "profileId" }); Partner.hasMany(ApiCredential, { as: "apiCredentials", foreignKey: "partnerId" }); @@ -94,6 +100,7 @@ NotificationPreference.belongsTo(User, { as: "profile", foreignKey: "profileId" // Initialize models const models = { + AdminImpersonationSession, Anchor, ApiClientEvent, ApiCredential, diff --git a/apps/api/src/models/profileRole.model.ts b/apps/api/src/models/profileRole.model.ts index fa1a6ced9..3a444c198 100644 --- a/apps/api/src/models/profileRole.model.ts +++ b/apps/api/src/models/profileRole.model.ts @@ -2,10 +2,18 @@ import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; // Admin-managed capability roles per profile. discount_manager: may attach pricing -// discounts to recipient invites (seeded on acceptance). -export type ProfileRoleName = "discount_manager"; +// discounts to recipient invites (seeded on acceptance). vortex_admin: may use the +// /v1/admin-console surface, including impersonating another profile. +export type ProfileRoleName = "discount_manager" | "vortex_admin"; -export const PROFILE_ROLE_NAMES: ProfileRoleName[] = ["discount_manager"]; +export const PROFILE_ROLE_NAMES: ProfileRoleName[] = ["discount_manager", "vortex_admin"]; + +// Roles grantable through POST /v1/admin/profile-roles, which is guarded only by the shared +// ADMIN_SECRET. vortex_admin confers the ability to act as any customer — including moving +// their money — so that secret must never be sufficient to grant it; it is granted +// out-of-band instead (see scripts/grant-vortex-admin.ts). Revocation stays available for +// every role via DELETE, as a safety valve. +export const HTTP_GRANTABLE_PROFILE_ROLES: ProfileRoleName[] = ["discount_manager"]; export interface ProfileRoleAttributes { id: string; diff --git a/apps/dashboard/src/components/admin/AdminAccountsTable.tsx b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx new file mode 100644 index 000000000..7c60c0b46 --- /dev/null +++ b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx @@ -0,0 +1,96 @@ +import { Link } from "@tanstack/react-router"; +import { LogIn, Users } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { AdminAccountSummary } from "@/services/api/admin-console.service"; +import { ImpersonateDialog } from "./ImpersonateDialog"; + +function formatDate(value: string): string { + return new Date(value).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); +} + +function verificationEntries(summary: AdminAccountSummary["verificationSummary"]) { + return Object.entries(summary).filter(([, count]) => count > 0); +} + +export function AdminAccountsTable({ accounts }: { accounts: AdminAccountSummary[] }) { + const [target, setTarget] = useState<{ id: string; email: string } | null>(null); + + return ( + <> + + + + Account + Entities + Verification + Pricing partner + Created + Action + + + + {accounts.map(account => ( + + + + {account.email} + + + +
+ {account.entities.length === 0 ? ( + None + ) : ( + account.entities.map(entity => ( + + {entity.type} · {entity.status} + + )) + )} +
+
+ +
+ {verificationEntries(account.verificationSummary).length === 0 ? ( + None + ) : ( + verificationEntries(account.verificationSummary).map(([status, count]) => ( + + {count} {status.replace("_", " ")} + + )) + )} +
+
+ {account.activePartnerName ?? "—"} + {formatDate(account.createdAt)} + + + +
+ ))} +
+
+ {accounts.length === 0 && ( +
+ + + +

No accounts found

+
+ )} + !open && setTarget(null)} target={target} /> + + ); +} diff --git a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx new file mode 100644 index 000000000..805c12f7c --- /dev/null +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -0,0 +1,77 @@ +import { useNavigate } from "@tanstack/react-router"; +import { LogIn } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useStartImpersonation } from "@/hooks/useAdminConsole"; +import { enterImpersonation } from "@/stores/impersonation.store"; + +/** + * "Log in as" confirmation: swaps the active session to the returned impersonation token + * and lands on Overview. The session itself is audited server-side against the operator. + */ +export function ImpersonateDialog({ + onOpenChange, + target +}: { + onOpenChange: (open: boolean) => void; + target: { id: string; email: string } | null; +}) { + const navigate = useNavigate(); + const startImpersonation = useStartImpersonation(); + + function handleOpenChange(open: boolean) { + onOpenChange(open); + if (!open) { + startImpersonation.reset(); + } + } + + function onConfirm() { + if (!target) return; + startImpersonation.mutate( + { targetProfileId: target.id }, + { + onError: error => { + toast.error("Could not start the impersonation session", { + description: error instanceof Error ? error.message : undefined + }); + }, + onSuccess: response => { + enterImpersonation({ + expiresAt: response.expiresAt, + sessionId: response.sessionId, + targetEmail: response.target.email, + token: response.token + }); + handleOpenChange(false); + navigate({ to: "/overview" }); + } + } + ); + } + + if (!target) return null; + + return ( + + + + Log in as {target.email}? + + You'll act as this customer until the session expires in 30 minutes. This is logged against your account. + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index d6c54e869..6efb96fe1 100644 --- a/apps/dashboard/src/components/layout/AppSidebar.tsx +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -1,5 +1,5 @@ import { Link, useRouterState } from "@tanstack/react-router"; -import { ArrowLeftRight, Calculator, Gauge, KeyRound, Send, Settings, ShieldCheck, Users } from "lucide-react"; +import { ArrowLeftRight, Calculator, Gauge, KeyRound, Send, Settings, ShieldCheck, UserCog, Users } from "lucide-react"; import { Sidebar, SidebarContent, @@ -11,6 +11,8 @@ import { SidebarMenuItem, SidebarRail } from "@/components/ui/sidebar"; +import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; +import { useImpersonationSession } from "@/stores/impersonation.store"; import { VortexLogo } from "./VortexLogo"; const NAV_ITEMS = [ @@ -24,8 +26,15 @@ const NAV_ITEMS = [ { icon: Settings, label: "Settings", to: "/settings" } ] as const; +const ADMIN_NAV_ITEM = { icon: UserCog, label: "Admin", to: "/admin" } as const; + export function AppSidebar() { const pathname = useRouterState({ select: state => state.location.pathname }); + const { data: onboardingStatus } = useOnboardingStatusQuery(); + const isImpersonating = useImpersonationSession() !== null; + const isAdmin = onboardingStatus?.roles.includes("vortex_admin") ?? false; + // An operator acting as a customer must see exactly the customer's navigation. + const navItems = isAdmin && !isImpersonating ? [...NAV_ITEMS, ADMIN_NAV_ITEM] : NAV_ITEMS; return ( @@ -38,7 +47,7 @@ export function AppSidebar() { - {NAV_ITEMS.map(item => ( + {navItems.map(item => ( diff --git a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx new file mode 100644 index 000000000..606c1a79a --- /dev/null +++ b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx @@ -0,0 +1,54 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { exitImpersonation, useImpersonationSession } from "@/stores/impersonation.store"; + +function formatRemaining(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +/** + * Sticky, non-dismissible: an operator forgetting they are impersonating is the failure + * mode this guards against. The timer only renders the remaining duration; storage changes + * are subscribed through `useImpersonationSession`. + */ +export function ImpersonationBanner() { + const session = useImpersonationSession(); + const navigate = useNavigate(); + const expiresAt = session?.expiresAt; + const [now, setNow] = useState(Date.now); + + useEffect(() => { + if (!expiresAt) return; + const tick = () => setNow(Date.now()); + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [expiresAt]); + + if (!session) { + return null; + } + + function handleExit() { + exitImpersonation(); + navigate({ to: "/admin" }); + } + + const remainingMs = new Date(session.expiresAt).getTime() - now; + + return ( +
+ + You are acting as {session.targetEmail} + <> · {formatRemaining(remainingMs)} remaining + + +
+ ); +} diff --git a/apps/dashboard/src/hooks/useAdminConsole.ts b/apps/dashboard/src/hooks/useAdminConsole.ts new file mode 100644 index 000000000..e1ffd7eaf --- /dev/null +++ b/apps/dashboard/src/hooks/useAdminConsole.ts @@ -0,0 +1,41 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AdminConsoleService, + type ListAdminAccountsParams, + type StartImpersonationRequest +} from "@/services/api/admin-console.service"; + +export const ADMIN_ACCOUNTS_QUERY_KEY = "admin-accounts"; +export const ADMIN_ACCOUNT_QUERY_KEY = "admin-account"; +export const ADMIN_IMPERSONATION_SESSIONS_QUERY_KEY = "admin-impersonation-sessions"; + +export function useAdminAccounts(params: ListAdminAccountsParams) { + return useQuery({ + placeholderData: keepPreviousData, + queryFn: ({ signal }) => AdminConsoleService.listAccounts(params, signal), + queryKey: [ADMIN_ACCOUNTS_QUERY_KEY, params] + }); +} + +export function useAdminAccount(profileId: string) { + return useQuery({ + enabled: !!profileId, + queryFn: ({ signal }) => AdminConsoleService.getAccount(profileId, signal), + queryKey: [ADMIN_ACCOUNT_QUERY_KEY, profileId] + }); +} + +export function useAdminImpersonationSessions() { + return useQuery({ + queryFn: ({ signal }) => AdminConsoleService.listImpersonationSessions(signal), + queryKey: [ADMIN_IMPERSONATION_SESSIONS_QUERY_KEY] + }); +} + +export function useStartImpersonation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: StartImpersonationRequest) => AdminConsoleService.startImpersonation(request), + onSuccess: () => queryClient.invalidateQueries({ queryKey: [ADMIN_IMPERSONATION_SESSIONS_QUERY_KEY] }) + }); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index a2d339a04..72566e558 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -22,6 +22,9 @@ import { Route as AppQuoteRouteImport } from './routes/_app/quote' import { Route as AppOverviewRouteImport } from './routes/_app/overview' import { Route as AppLimitsRouteImport } from './routes/_app/limits' import { Route as AppApiKeysRouteImport } from './routes/_app/api-keys' +import { Route as AppAdminRouteImport } from './routes/_app/admin' +import { Route as AppAdminIndexRouteImport } from './routes/_app/admin.index' +import { Route as AppAdminProfileIdRouteImport } from './routes/_app/admin.$profileId' const LoginRoute = LoginRouteImport.update({ id: '/login', @@ -87,10 +90,26 @@ const AppApiKeysRoute = AppApiKeysRouteImport.update({ path: '/api-keys', getParentRoute: () => AppRoute, } as any) +const AppAdminRoute = AppAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => AppRoute, +} as any) +const AppAdminIndexRoute = AppAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppAdminRoute, +} as any) +const AppAdminProfileIdRoute = AppAdminProfileIdRouteImport.update({ + id: '/$profileId', + path: '/$profileId', + getParentRoute: () => AppAdminRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/admin': typeof AppAdminRouteWithChildren '/api-keys': typeof AppApiKeysRoute '/limits': typeof AppLimitsRoute '/overview': typeof AppOverviewRoute @@ -101,6 +120,8 @@ export interface FileRoutesByFullPath { '/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute + '/admin/$profileId': typeof AppAdminProfileIdRoute + '/admin/': typeof AppAdminIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -115,12 +136,15 @@ export interface FileRoutesByTo { '/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute + '/admin/$profileId': typeof AppAdminProfileIdRoute + '/admin': typeof AppAdminIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_app': typeof AppRouteWithChildren '/login': typeof LoginRoute + '/_app/admin': typeof AppAdminRouteWithChildren '/_app/api-keys': typeof AppApiKeysRoute '/_app/limits': typeof AppLimitsRoute '/_app/overview': typeof AppOverviewRoute @@ -131,12 +155,15 @@ export interface FileRoutesById { '/_app/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute + '/_app/admin/$profileId': typeof AppAdminProfileIdRoute + '/_app/admin/': typeof AppAdminIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/login' + | '/admin' | '/api-keys' | '/limits' | '/overview' @@ -147,6 +174,8 @@ export interface FileRouteTypes { | '/transfer' | '/invite/$token' | '/monerium/callback' + | '/admin/$profileId' + | '/admin/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -161,11 +190,14 @@ export interface FileRouteTypes { | '/transfer' | '/invite/$token' | '/monerium/callback' + | '/admin/$profileId' + | '/admin' id: | '__root__' | '/' | '/_app' | '/login' + | '/_app/admin' | '/_app/api-keys' | '/_app/limits' | '/_app/overview' @@ -176,6 +208,8 @@ export interface FileRouteTypes { | '/_app/transfer' | '/invite/$token' | '/monerium/callback' + | '/_app/admin/$profileId' + | '/_app/admin/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -279,10 +313,46 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppApiKeysRouteImport parentRoute: typeof AppRoute } + '/_app/admin': { + id: '/_app/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AppAdminRouteImport + parentRoute: typeof AppRoute + } + '/_app/admin/': { + id: '/_app/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AppAdminIndexRouteImport + parentRoute: typeof AppAdminRoute + } + '/_app/admin/$profileId': { + id: '/_app/admin/$profileId' + path: '/$profileId' + fullPath: '/admin/$profileId' + preLoaderRoute: typeof AppAdminProfileIdRouteImport + parentRoute: typeof AppAdminRoute + } } } +interface AppAdminRouteChildren { + AppAdminProfileIdRoute: typeof AppAdminProfileIdRoute + AppAdminIndexRoute: typeof AppAdminIndexRoute +} + +const AppAdminRouteChildren: AppAdminRouteChildren = { + AppAdminProfileIdRoute: AppAdminProfileIdRoute, + AppAdminIndexRoute: AppAdminIndexRoute, +} + +const AppAdminRouteWithChildren = AppAdminRoute._addFileChildren( + AppAdminRouteChildren, +) + interface AppRouteChildren { + AppAdminRoute: typeof AppAdminRouteWithChildren AppApiKeysRoute: typeof AppApiKeysRoute AppLimitsRoute: typeof AppLimitsRoute AppOverviewRoute: typeof AppOverviewRoute @@ -294,6 +364,7 @@ interface AppRouteChildren { } const AppRouteChildren: AppRouteChildren = { + AppAdminRoute: AppAdminRouteWithChildren, AppApiKeysRoute: AppApiKeysRoute, AppLimitsRoute: AppLimitsRoute, AppOverviewRoute: AppOverviewRoute, diff --git a/apps/dashboard/src/routes/_app.tsx b/apps/dashboard/src/routes/_app.tsx index 72785cf36..71803e632 100644 --- a/apps/dashboard/src/routes/_app.tsx +++ b/apps/dashboard/src/routes/_app.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Navigate, Outlet, useRouterState } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AppSidebar } from "@/components/layout/AppSidebar"; +import { ImpersonationBanner } from "@/components/layout/ImpersonationBanner"; import { Topbar } from "@/components/layout/Topbar"; import { AccountTypeSelector } from "@/components/onboarding/AccountTypeSelector"; import { Button } from "@/components/ui/button"; @@ -49,6 +50,7 @@ function AppLayout() { + {/* Re-key on pathname so each navigation cross-fades the page content in. */} (null); + + if (account.isLoading) { + return ; + } + + if (account.isError || !account.data) { + return ( +
+

Could not load this account

+ +
+ ); + } + + const data = account.data; + + return ( + + +
+

{data.email}

+

Account since {new Date(data.createdAt).toLocaleDateString()}

+
+ +
+ + + + + Customer entities + + + {data.entities.length === 0 ? ( +

No customer entities.

+ ) : ( + data.entities.map(entity => ( +
+
+ {entity.type} + {entity.country && {entity.country}} + {entity.status} + {entity.id === data.activeEntityId && Active} +
+ {entity.providerCustomers.length === 0 ? ( +

No provider accounts.

+ ) : ( + entity.providerCustomers.map(provider => ( +
+
+ + {provider.provider} + {provider.rail ? ` · ${provider.rail}` : ""} + + {provider.status.replace("_", " ")} +
+ {provider.kycCase && ( +
+ + KYC {provider.kycCase.type} + {provider.kycCase.level ? ` · ${provider.kycCase.level}` : ""} + + {provider.kycCase.status} +
+ )} +
+ )) + )} +
+ )) + )} +
+
+
+ + + + + Recent impersonation sessions + + + {data.impersonationSessions.length === 0 ? ( +

No impersonation sessions yet.

+ ) : ( + data.impersonationSessions.map(session => ( +
+ + {session.actor.email ?? session.actor.id} ·{" "} + {new Date(session.createdAt).toLocaleString()} + + {session.active ? "Active" : "Ended"} +
+ )) + )} +
+
+
+ + !open && setImpersonateTarget(null)} target={impersonateTarget} /> +
+ ); +} diff --git a/apps/dashboard/src/routes/_app/admin.index.tsx b/apps/dashboard/src/routes/_app/admin.index.tsx new file mode 100644 index 000000000..8338e0e3a --- /dev/null +++ b/apps/dashboard/src/routes/_app/admin.index.tsx @@ -0,0 +1,133 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { AdminAccountsTable } from "@/components/admin/AdminAccountsTable"; +import { Stagger, StaggerItem } from "@/components/motion/Stagger"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAdminAccounts, useAdminImpersonationSessions } from "@/hooks/useAdminConsole"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; + +const PAGE_LIMIT = 20; + +export const Route = createFileRoute("/_app/admin/")({ + component: AdminAccountsPage +}); + +function AdminAccountsPage() { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + const [cursorStack, setCursorStack] = useState([]); + const cursor = cursorStack.at(-1); + + const accounts = useAdminAccounts({ cursor, limit: PAGE_LIMIT, search: debouncedSearch || undefined }); + const sessions = useAdminImpersonationSessions(); + + return ( + + +

Admin

+

Look up customer accounts and log in as one for support.

+
+ + + + + Accounts + { + // A new search invalidates the current position in the result set. + setSearch(event.target.value); + setCursorStack([]); + }} + placeholder="Search by email…" + value={search} + /> + + + {accounts.isLoading ? ( +
+ + +
+ ) : accounts.isError ? ( +
+

Could not load accounts.

+ +
+ ) : ( + <> + +
+ + +
+ + )} +
+
+
+ + + + + Recent impersonation activity + + + {sessions.isLoading ? ( + + ) : sessions.isError ? ( +
+

Could not load impersonation activity.

+ +
+ ) : !sessions.data || sessions.data.sessions.length === 0 ? ( +

No impersonation sessions yet.

+ ) : ( +
    + {sessions.data.sessions.map(session => ( +
  • + + {session.actor.email ?? session.actor.id} acting as{" "} + {session.target.email ?? session.target.id} + + {new Date(session.createdAt).toLocaleString()} + + + {session.active ? "Active" : "Ended"} +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/routes/_app/admin.tsx b/apps/dashboard/src/routes/_app/admin.tsx new file mode 100644 index 000000000..839a2c695 --- /dev/null +++ b/apps/dashboard/src/routes/_app/admin.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, Navigate, Outlet } from "@tanstack/react-router"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; + +export const Route = createFileRoute("/_app/admin")({ + component: AdminLayout +}); + +/** Role guard shared by the account list and every `/admin/$profileId` detail route. */ +function AdminLayout() { + const onboardingStatus = useOnboardingStatusQuery(); + const isAdmin = onboardingStatus.data?.roles.includes("vortex_admin") ?? false; + + if (onboardingStatus.isLoading) { + return ; + } + if (!isAdmin) { + return ; + } + return ; +} diff --git a/apps/dashboard/src/services/api/admin-console.service.ts b/apps/dashboard/src/services/api/admin-console.service.ts new file mode 100644 index 000000000..fc439f3fb --- /dev/null +++ b/apps/dashboard/src/services/api/admin-console.service.ts @@ -0,0 +1,123 @@ +import { apiClient } from "./api-client"; + +/** Mirrors `VerificationStatus` in the API — raw enum values, used as summary keys. */ +export type AdminVerificationStatus = "pending" | "started" | "in_review" | "approved" | "rejected"; + +export interface AdminCustomerEntity { + id: string; + type: string; + status: string; +} + +/** One row of GET /admin-console/accounts. */ +export interface AdminAccountSummary { + id: string; + email: string; + createdAt: string; + entities: AdminCustomerEntity[]; + /** Provider-customer counts per verification status, across all of the account's entities. */ + verificationSummary: Record; + activePartnerName: string | null; +} + +export interface AdminAccountsPage { + accounts: AdminAccountSummary[]; + limit: number; + nextCursor: string | null; + total: number; +} + +export interface AdminKycCase { + id: string; + type: string; + level: string | null; + status: string; + statusExternal: string | null; + failureReasons: string[] | null; + submittedAt: string | null; + approvedAt: string | null; + rejectedAt: string | null; +} + +export interface AdminProviderCustomer { + id: string; + provider: string; + rail: string | null; + status: AdminVerificationStatus; + statusExternal: string | null; + customerType: string; + companyName: string | null; + country: string | null; + createdAt: string; + updatedAt: string; + kycCase: AdminKycCase | null; +} + +/** Detail nests provider customers under their entity, matching the onboarding endpoint. */ +export interface AdminCustomerEntityDetail extends AdminCustomerEntity { + country: string | null; + providerCustomers: AdminProviderCustomer[]; +} + +export interface AdminSessionParty { + id: string; + email: string | null; +} + +/** Sessions returned by the account-detail endpoint, all targeting that account. */ +export interface AdminImpersonationSessionSummary { + id: string; + actor: AdminSessionParty; + createdAt: string; + expiresAt: string; + revokedAt: string | null; + revokedReason: string | null; + active: boolean; +} + +/** The audit list additionally names the target, since it spans accounts. */ +export interface AdminImpersonationSessionRecord extends AdminImpersonationSessionSummary { + target: AdminSessionParty; +} + +export interface AdminAccountDetail { + id: string; + email: string; + createdAt: string; + activeEntityId: string | null; + entities: AdminCustomerEntityDetail[]; + impersonationSessions: AdminImpersonationSessionSummary[]; +} + +export interface ListAdminAccountsParams extends Record { + search?: string; + cursor?: string; + limit?: number; +} + +export interface StartImpersonationRequest { + targetProfileId: string; +} + +export interface StartImpersonationResponse { + token: string; + sessionId: string; + expiresAt: string; + target: { id: string; email: string }; +} + +export interface ListImpersonationSessionsResponse { + sessions: AdminImpersonationSessionRecord[]; +} + +export const AdminConsoleService = { + endImpersonation: (sessionId: string) => apiClient.delete(`/admin-console/impersonation/${sessionId}`), + getAccount: (profileId: string, signal?: AbortSignal) => + apiClient.get(`/admin-console/accounts/${profileId}`, { signal }), + listAccounts: (params: ListAdminAccountsParams, signal?: AbortSignal) => + apiClient.get("/admin-console/accounts", { params, signal }), + listImpersonationSessions: (signal?: AbortSignal) => + apiClient.get("/admin-console/impersonation", { signal }), + startImpersonation: (request: StartImpersonationRequest) => + apiClient.post("/admin-console/impersonation", request) +}; diff --git a/apps/dashboard/src/services/api/api-client.test.ts b/apps/dashboard/src/services/api/api-client.test.ts new file mode 100644 index 000000000..820749eaf --- /dev/null +++ b/apps/dashboard/src/services/api/api-client.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { AuthService } from "@/services/auth"; +import { apiClient, isApiError } from "./api-client"; + +const originalFetch = globalThis.fetch; +const originalGetImpersonationSession = AuthService.getImpersonationSession; +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); +const values = new Map(); + +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } +}); + +// apiFetch resolves relative URLs against window.location.origin; bun's test runner has no DOM. +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "http://localhost" } } +}); + +beforeEach(() => { + values.clear(); + AuthService.getImpersonationSession = originalGetImpersonationSession; +}); + +after(() => { + globalThis.fetch = originalFetch; + AuthService.getImpersonationSession = originalGetImpersonationSession; + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } + if (originalWindow) { + Object.defineProperty(globalThis, "window", originalWindow); + } else { + Reflect.deleteProperty(globalThis, "window"); + } +}); + +describe("apiFetch while impersonating", () => { + beforeEach(() => { + AuthService.storeTokens({ + accessToken: "operator-access-token", + refreshToken: "operator-refresh-token", + userEmail: "operator@vortex.fi", + userId: "operator-1" + }); + AuthService.storeImpersonationSession({ + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123" + }); + }); + + it("authorizes requests with the impersonation token, not the operator's token", async () => { + let authorization: string | undefined; + globalThis.fetch = (async (_input, init) => { + authorization = (init?.headers as Record).Authorization; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/ping"); + + assert.equal(authorization, "Bearer vtx_imp_abc123"); + }); + + it("uses one impersonation snapshot for authorization and 401 handling", async () => { + const activeSession = AuthService.getImpersonationSession(); + let snapshotReads = 0; + AuthService.getImpersonationSession = (() => { + snapshotReads += 1; + return snapshotReads === 1 ? activeSession : null; + }) as typeof AuthService.getImpersonationSession; + let authorization: string | undefined; + globalThis.fetch = (async (_input, init) => { + authorization = (init?.headers as Record).Authorization; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/ping"); + + assert.equal(snapshotReads, 1); + assert.equal(authorization, "Bearer vtx_imp_abc123"); + }); + + it("does not attempt a token refresh on 401 and clears the impersonation session instead", async () => { + let fetchCalls = 0; + globalThis.fetch = (async (input) => { + fetchCalls += 1; + // A refresh attempt would hit /v1/auth/refresh — assert it never happens. + assert.doesNotMatch(String(input), /\/auth\/refresh/); + return new Response(null, { status: 401 }); + }) as typeof fetch; + + await assert.rejects(() => apiClient.get("/ping"), error => isApiError(error) && error.status === 401); + + assert.equal(fetchCalls, 1); + assert.equal(AuthService.getImpersonationSession(), null); + // The operator's own tokens must stay untouched. + assert.equal(AuthService.getTokens()?.accessToken, "operator-access-token"); + }); +}); + +describe("apiFetch without impersonation", () => { + beforeEach(() => { + AuthService.storeTokens({ + accessToken: "expired-access-token", + refreshToken: "refresh-token", + userEmail: "e2e@vortex.local", + userId: "user-1" + }); + }); + + it("still retries once via token refresh on a 401", async () => { + let refreshCalled = false; + let secondRequestToken: string | undefined; + let call = 0; + + globalThis.fetch = (async (input, init) => { + call += 1; + if (String(input).includes("/auth/refresh")) { + refreshCalled = true; + return new Response( + JSON.stringify({ access_token: "rotated-access-token", refresh_token: "rotated-refresh-token" }), + { headers: { "Content-Type": "application/json" }, status: 200 } + ); + } + if (call === 1) { + return new Response(null, { status: 401 }); + } + secondRequestToken = (init?.headers as Record).Authorization; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/ping"); + + assert.equal(refreshCalled, true); + assert.equal(secondRequestToken, "Bearer rotated-access-token"); + }); +}); diff --git a/apps/dashboard/src/services/api/api-client.ts b/apps/dashboard/src/services/api/api-client.ts index 0bf86bff2..ed550ffcf 100644 --- a/apps/dashboard/src/services/api/api-client.ts +++ b/apps/dashboard/src/services/api/api-client.ts @@ -59,13 +59,25 @@ async function apiFetch( signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) }); + const impersonation = AuthService.getImpersonationSession(); const initialTokens = AuthService.getTokens(); - let response = await doFetch(initialTokens?.accessToken); + // Capture one coherent identity snapshot. Reading impersonation again here could pair the + // operator's token with impersonation-specific 401 handling during a cross-tab transition. + const initialAccessToken = impersonation?.token ?? initialTokens?.accessToken; + let response = await doFetch(initialAccessToken); - if (response.status === 401 && initialTokens?.accessToken) { - const refreshed = await refreshTokenOnce(); - if (refreshed?.accessToken && refreshed.userId === initialTokens.userId) { - response = await doFetch(refreshed.accessToken); + if (response.status === 401) { + if (impersonation) { + // Impersonation tokens are opaque and non-renewable — there is no refresh path. + // Drop back to the operator's own (untouched) session instead of retrying. + AuthService.clearImpersonationSession(); + throw new ApiError(401, {}, "Your impersonation session has expired. You're back in your own session."); + } + if (initialTokens?.accessToken) { + const refreshed = await refreshTokenOnce(); + if (refreshed?.accessToken && refreshed.userId === initialTokens.userId) { + response = await doFetch(refreshed.accessToken); + } } } diff --git a/apps/dashboard/src/services/auth.test.ts b/apps/dashboard/src/services/auth.test.ts index dc25ac34b..5f3433bee 100644 --- a/apps/dashboard/src/services/auth.test.ts +++ b/apps/dashboard/src/services/auth.test.ts @@ -229,3 +229,126 @@ describe("AuthService", () => { }); }); }); + +describe("AuthService impersonation session", () => { + it("stores the complete session in one atomic dashboard key", () => { + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + const impersonationKeys = [...values.keys()].filter((key) => + key.includes("impersonation"), + ); + assert.deepEqual(impersonationKeys, [ + AuthService.IMPERSONATION_STORAGE_KEY, + ]); + }); + + it("rejects malformed or incomplete atomic session records", () => { + values.set(AuthService.IMPERSONATION_STORAGE_KEY, "not-json"); + assert.equal(AuthService.getImpersonationSession(), null); + + values.set( + AuthService.IMPERSONATION_STORAGE_KEY, + JSON.stringify({ sessionId: "session-1", token: "vtx_imp_abc123" }), + ); + assert.equal(AuthService.getImpersonationSession(), null); + }); + + it("reads a complete legacy session and removes legacy keys on the next write", () => { + values.set("vortex_dashboard_impersonation_token", "vtx_imp_legacy"); + values.set("vortex_dashboard_impersonation_session_id", "legacy-session"); + values.set( + "vortex_dashboard_impersonation_expires_at", + "2026-01-01T00:00:00.000Z", + ); + values.set( + "vortex_dashboard_impersonation_target_email", + "legacy@example.com", + ); + + assert.deepEqual(AuthService.getImpersonationSession(), { + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "legacy-session", + targetEmail: "legacy@example.com", + token: "vtx_imp_legacy", + }); + + AuthService.storeImpersonationSession({ + expiresAt: "2026-02-01T00:00:00.000Z", + sessionId: "session-2", + targetEmail: "current@example.com", + token: "vtx_imp_current", + }); + assert.equal(values.has("vortex_dashboard_impersonation_token"), false); + assert.equal( + values.has("vortex_dashboard_impersonation_session_id"), + false, + ); + assert.equal( + values.has("vortex_dashboard_impersonation_expires_at"), + false, + ); + assert.equal( + values.has("vortex_dashboard_impersonation_target_email"), + false, + ); + }); + + it("prefers the impersonation token over the operator's own access token", () => { + assert.equal(AuthService.getEffectiveAccessToken(), "expired-access-token"); + + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + assert.equal(AuthService.getEffectiveAccessToken(), "vtx_imp_abc123"); + assert.deepEqual(AuthService.getImpersonationSession(), { + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + }); + + it("falls back to the operator's own token once the impersonation session is cleared", () => { + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + AuthService.clearImpersonationSession(); + + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(AuthService.getEffectiveAccessToken(), "expired-access-token"); + // The operator's own session must be untouched by entering/exiting impersonation. + assert.deepEqual(AuthService.getTokens(), { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + userEmail: "e2e@vortex.local", + userId: "user-1", + }); + }); + + it("clears both operator and impersonation credentials on sign-out", () => { + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + AuthService.signOut(); + + assert.equal(AuthService.getTokens(), null); + assert.equal(AuthService.getImpersonationSession(), null); + }); +}); diff --git a/apps/dashboard/src/services/auth.ts b/apps/dashboard/src/services/auth.ts index 6a0ca9fe8..cb9fda4e6 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -7,6 +7,14 @@ export interface AuthTokens { userEmail?: string; } +/** An active "log in as" session — opaque, non-renewable, valid 30 minutes. */ +export interface ImpersonationSession { + token: string; + sessionId: string; + expiresAt: string; + targetEmail: string; +} + /** * Session storage + refresh, ported from the widget's AuthService. Keys are * dashboard-scoped so a widget session on the same origin is never reused. @@ -16,6 +24,13 @@ export class AuthService { private static readonly REFRESH_TOKEN_KEY = "vortex_dashboard_refresh_token"; private static readonly USER_ID_KEY = "vortex_dashboard_user_id"; private static readonly USER_EMAIL_KEY = "vortex_dashboard_user_email"; + // One atomic record prevents readers from combining fields from different cross-tab writes. + static readonly IMPERSONATION_STORAGE_KEY = "vortex_dashboard_impersonation_session"; + private static readonly LEGACY_IMPERSONATION_TOKEN_KEY = "vortex_dashboard_impersonation_token"; + private static readonly LEGACY_IMPERSONATION_SESSION_ID_KEY = "vortex_dashboard_impersonation_session_id"; + private static readonly LEGACY_IMPERSONATION_EXPIRES_AT_KEY = "vortex_dashboard_impersonation_expires_at"; + private static readonly LEGACY_IMPERSONATION_TARGET_EMAIL_KEY = "vortex_dashboard_impersonation_target_email"; + private static readonly impersonationListeners = new Set<() => void>(); private static sessionGeneration = 0; private static refreshFlight: { generation: number; @@ -53,6 +68,98 @@ export class AuthService { localStorage.removeItem(this.USER_EMAIL_KEY); } + static storeImpersonationSession(session: ImpersonationSession): void { + const previousSnapshot = this.getImpersonationSessionSnapshot(); + localStorage.setItem( + this.IMPERSONATION_STORAGE_KEY, + JSON.stringify({ + expiresAt: session.expiresAt, + sessionId: session.sessionId, + targetEmail: session.targetEmail, + token: session.token + }) + ); + this.clearLegacyImpersonationKeys(); + this.notifyImpersonationListeners(previousSnapshot); + } + + static getImpersonationSession(): ImpersonationSession | null { + return this.parseImpersonationSessionSnapshot(this.getImpersonationSessionSnapshot()); + } + + /** Stable serialized snapshot for `useSyncExternalStore`. Also reads complete legacy data. */ + static getImpersonationSessionSnapshot(): string | null { + const current = localStorage.getItem(this.IMPERSONATION_STORAGE_KEY); + if (current !== null) { + return current; + } + + const token = localStorage.getItem(this.LEGACY_IMPERSONATION_TOKEN_KEY); + const sessionId = localStorage.getItem(this.LEGACY_IMPERSONATION_SESSION_ID_KEY); + const expiresAt = localStorage.getItem(this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY); + const targetEmail = localStorage.getItem(this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY); + return token && sessionId && expiresAt && targetEmail ? JSON.stringify({ expiresAt, sessionId, targetEmail, token }) : null; + } + + static parseImpersonationSessionSnapshot(snapshot: string | null): ImpersonationSession | null { + if (!snapshot) return null; + try { + const parsed = JSON.parse(snapshot) as Partial; + if ( + typeof parsed.token !== "string" || + typeof parsed.sessionId !== "string" || + typeof parsed.expiresAt !== "string" || + !Number.isFinite(Date.parse(parsed.expiresAt)) || + typeof parsed.targetEmail !== "string" + ) { + return null; + } + return { + expiresAt: parsed.expiresAt, + sessionId: parsed.sessionId, + targetEmail: parsed.targetEmail, + token: parsed.token + }; + } catch { + return null; + } + } + + /** Same-tab writes notify directly; cross-tab writes arrive through the storage event. */ + static subscribeImpersonationSession(listener: () => void): () => void { + this.impersonationListeners.add(listener); + const handleStorage = (event: StorageEvent) => { + if (event.key === null || this.isImpersonationStorageKey(event.key)) { + listener(); + } + }; + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("storage", handleStorage); + } + return () => { + this.impersonationListeners.delete(listener); + if (typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("storage", handleStorage); + } + }; + } + + static clearImpersonationSession(): void { + const previousSnapshot = this.getImpersonationSessionSnapshot(); + localStorage.removeItem(this.IMPERSONATION_STORAGE_KEY); + this.clearLegacyImpersonationKeys(); + this.notifyImpersonationListeners(previousSnapshot); + } + + /** The bearer token requests should use: the impersonation token takes priority when active. */ + static getEffectiveAccessToken(): string | null { + const impersonation = this.getImpersonationSession(); + if (impersonation) { + return impersonation.token; + } + return this.getTokens()?.accessToken ?? null; + } + static isAuthenticated(): boolean { const tokens = this.getTokens(); if (!tokens) { @@ -149,6 +256,31 @@ export class AuthService { } static signOut(): void { + this.clearImpersonationSession(); this.clearTokens(); } + + private static clearLegacyImpersonationKeys(): void { + localStorage.removeItem(this.LEGACY_IMPERSONATION_TOKEN_KEY); + localStorage.removeItem(this.LEGACY_IMPERSONATION_SESSION_ID_KEY); + localStorage.removeItem(this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY); + localStorage.removeItem(this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY); + } + + private static isImpersonationStorageKey(key: string): boolean { + return [ + this.IMPERSONATION_STORAGE_KEY, + this.LEGACY_IMPERSONATION_TOKEN_KEY, + this.LEGACY_IMPERSONATION_SESSION_ID_KEY, + this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY, + this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY + ].includes(key); + } + + private static notifyImpersonationListeners(previousSnapshot: string | null): void { + if (this.getImpersonationSessionSnapshot() === previousSnapshot) return; + for (const listener of this.impersonationListeners) { + listener(); + } + } } diff --git a/apps/dashboard/src/stores/auth.store.ts b/apps/dashboard/src/stores/auth.store.ts index 4184b9e9b..04d860759 100644 --- a/apps/dashboard/src/stores/auth.store.ts +++ b/apps/dashboard/src/stores/auth.store.ts @@ -45,7 +45,7 @@ function userFromTokens(tokens: AuthTokens): AuthUser { return { email, name: displayNameFromEmail(email), userId: tokens.userId }; } -function clearAccountState(): void { +export function clearAccountState(): void { queryClient.clear(); useNotificationsStore.getState().clear(); resetTransferState(); @@ -74,6 +74,7 @@ export const useAuthStore = create()(set => ({ verifyOtp: async (email, code) => { const result = await AuthAPI.verifyOTP(email, code); clearAccountState(); + AuthService.clearImpersonationSession(); AuthService.storeTokens({ accessToken: result.accessToken, refreshToken: result.refreshToken, diff --git a/apps/dashboard/src/stores/impersonation.store.test.ts b/apps/dashboard/src/stores/impersonation.store.test.ts new file mode 100644 index 000000000..4a727f326 --- /dev/null +++ b/apps/dashboard/src/stores/impersonation.store.test.ts @@ -0,0 +1,178 @@ +import { mock } from "bun:test"; +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import type { ImpersonationSession } from "@/services/auth"; + +const originalFetch = globalThis.fetch; +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); +const values = new Map(); +const storageListeners = new Set<(event: { key: string | null }) => void>(); + +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } +}); + +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: { key: string | null }) => void) => { + if (type === "storage") storageListeners.add(listener); + }, + location: { origin: "http://localhost" }, + removeEventListener: (type: string, listener: (event: { key: string | null }) => void) => { + if (type === "storage") storageListeners.delete(listener); + } + } +}); + +let accountStateClears = 0; +mock.module("@/stores/auth.store", () => ({ + clearAccountState: () => { + accountStateClears += 1; + } +})); + +const { AuthService } = await import("@/services/auth"); +const { enterImpersonation, exitImpersonation } = await import("./impersonation.store"); + +function session(overrides: Partial = {}): ImpersonationSession { + return { + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "session-1", + targetEmail: "target@example.com", + token: "vtx_imp_token-1", + ...overrides + }; +} + +function dispatchStorage(key: string | null): void { + for (const listener of storageListeners) listener({ key }); +} + +beforeEach(() => { + AuthService.clearImpersonationSession(); + values.clear(); + accountStateClears = 0; + globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 204 }))) as typeof fetch; +}); + +after(() => { + globalThis.fetch = originalFetch; + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } + if (originalWindow) { + Object.defineProperty(globalThis, "window", originalWindow); + } else { + Reflect.deleteProperty(globalThis, "window"); + } +}); + +describe("impersonation session transitions", () => { + it("persists an entered identity and clears account-scoped state", () => { + const entered = session(); + + enterImpersonation(entered); + + assert.deepEqual(AuthService.getImpersonationSession(), entered); + assert.equal(accountStateClears, 1); + }); + + it("exits locally without waiting for the server revocation", async () => { + let releaseRequest: (() => void) | undefined; + let requestCount = 0; + let lastRequest = ""; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + requestCount += 1; + lastRequest = `${init?.method ?? "GET"} ${input instanceof URL ? input.pathname : String(input)}`; + return new Promise(resolve => { + releaseRequest = () => resolve(new Response(null, { status: 204 })); + }); + }) as typeof fetch; + + enterImpersonation(session()); + exitImpersonation(); + + assert.equal(requestCount, 1); + assert.match(lastRequest, /^DELETE .*\/admin-console\/impersonation\/session-1$/); + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 2); + + releaseRequest?.(); + await Promise.resolve(); + }); + + it("still exits locally when the revocation request fails", async () => { + globalThis.fetch = (() => Promise.reject(new Error("network down"))) as typeof fetch; + + enterImpersonation(session()); + exitImpersonation(); + await Promise.resolve(); + + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 2); + }); + + it("does not call the server when there is no active session", () => { + let called = false; + globalThis.fetch = (() => { + called = true; + return Promise.resolve(new Response(null, { status: 204 })); + }) as typeof fetch; + + exitImpersonation(); + + assert.equal(called, false); + assert.equal(accountStateClears, 0); + }); + + it("clears account state when the API client drops a rejected session", () => { + enterImpersonation(session()); + accountStateClears = 0; + + AuthService.clearImpersonationSession(); + + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 1); + }); + + it("adopts another tab's session and clears the prior account cache", () => { + const fromOtherTab = session({ sessionId: "session-2", token: "vtx_imp_token-2" }); + values.set(AuthService.IMPERSONATION_STORAGE_KEY, JSON.stringify(fromOtherTab)); + + dispatchStorage(AuthService.IMPERSONATION_STORAGE_KEY); + + assert.deepEqual(AuthService.getImpersonationSession(), fromOtherTab); + assert.equal(accountStateClears, 1); + }); + + it("clears the session and account cache when another tab exits", () => { + enterImpersonation(session()); + accountStateClears = 0; + values.delete(AuthService.IMPERSONATION_STORAGE_KEY); + + dispatchStorage(AuthService.IMPERSONATION_STORAGE_KEY); + + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 1); + }); + + it("does not clear account state for an unchanged storage event", () => { + const current = session(); + enterImpersonation(current); + accountStateClears = 0; + + dispatchStorage(AuthService.IMPERSONATION_STORAGE_KEY); + + assert.deepEqual(AuthService.getImpersonationSession(), current); + assert.equal(accountStateClears, 0); + }); +}); diff --git a/apps/dashboard/src/stores/impersonation.store.ts b/apps/dashboard/src/stores/impersonation.store.ts new file mode 100644 index 000000000..dddf1f741 --- /dev/null +++ b/apps/dashboard/src/stores/impersonation.store.ts @@ -0,0 +1,57 @@ +import { useSyncExternalStore } from "react"; +import { AdminConsoleService } from "@/services/api/admin-console.service"; +import { AuthService, type ImpersonationSession } from "@/services/auth"; +import { clearAccountState } from "./auth.store"; + +let currentSnapshot = AuthService.getImpersonationSessionSnapshot(); +const reactListeners = new Set<() => void>(); + +function applyStoredIdentity(): void { + const nextSnapshot = AuthService.getImpersonationSessionSnapshot(); + if (nextSnapshot === currentSnapshot) return; + + currentSnapshot = nextSnapshot; + clearAccountState(); + for (const listener of reactListeners) { + listener(); + } +} + +// One bridge owns cross-tab and same-tab storage notifications for the app lifetime. React +// consumers subscribe to the cached snapshot below, so multiple components never duplicate +// account-state cleanup for one identity transition. +AuthService.subscribeImpersonationSession(applyStoredIdentity); + +function subscribe(listener: () => void): () => void { + reactListeners.add(listener); + return () => reactListeners.delete(listener); +} + +function getSnapshot(): string | null { + return currentSnapshot; +} + +/** `localStorage` is the single source of truth, including changes made in another tab. */ +export function useImpersonationSession(): ImpersonationSession | null { + const snapshot = useSyncExternalStore(subscribe, getSnapshot, () => null); + return AuthService.parseImpersonationSessionSnapshot(snapshot); +} + +/** Entering a new identity synchronously clears every account-scoped client cache. */ +export function enterImpersonation(session: ImpersonationSession): void { + AuthService.storeImpersonationSession(session); +} + +/** + * Exit locally first. The revocation request already captured the session token when this + * function clears storage, and is allowed to finish best-effort without blocking the UI. + */ +export function exitImpersonation(): void { + const session = AuthService.getImpersonationSession(); + if (session) { + void AdminConsoleService.endImpersonation(session.sessionId).catch(() => { + // The server session remains bounded by its non-renewable 30-minute TTL. + }); + } + AuthService.clearImpersonationSession(); +} diff --git a/apps/dashboard/src/types/bun-test.d.ts b/apps/dashboard/src/types/bun-test.d.ts new file mode 100644 index 000000000..a5b16d225 --- /dev/null +++ b/apps/dashboard/src/types/bun-test.d.ts @@ -0,0 +1,10 @@ +/** + * Minimal declaration for the one `bun:test` API used in tests. The dashboard's tsconfig + * deliberately keeps Bun out of the app's ambient types (`types: ["node", "vite/client"]`); + * pulling in `@types/bun` wholesale would also redefine globals such as `fetch`. + */ +declare module "bun:test" { + export const mock: { + module: (specifier: string, factory: () => unknown) => void; + }; +} diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index 4e3230c0f..40fa31af9 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -1,7 +1,7 @@ # Identity, Customer, and Partner Model -Status: current architecture. Last reconciled with migrations 038–060 and the API models -on 2026-08-04. +Status: current architecture. Last reconciled with migrations 038–063 and the API models +on 2026-08-07. This document explains the implemented identity model across authentication, compliance customers, provider accounts, partner pricing, and recipients. Security invariants remain @@ -103,14 +103,30 @@ Current product behavior and acknowledged gaps are in ## Authentication and ownership flow 1. `requirePartnerOrUserAuth()` accepts a valid secret API key or Supabase bearer token. -2. `getEffectiveUserId()` prefers the Supabase user and otherwise uses the user linked to - the validated secret key. + Any presented bearer token — on this path or on the Supabase-only `requireAuth`/ + `optionalAuth` middleware — is first resolved by `resolveBearerPrincipal()` + (`bearerPrincipal.ts`). This is the one place a request's principal can become someone + other than the credential holder: a token prefixed `vtx_imp_` resolves against a live + row in `admin_impersonation_sessions` and, if found, the principal returned is the + **target** profile (its `userId` and `userEmail`), not the `vortex_admin` operator who + holds the token. An ordinary Supabase token resolves unchanged. The operator's own + identity is preserved separately on `req.impersonation` for audit; it does not + participate in ownership resolution. +2. `getEffectiveUserId()` prefers `req.userId` and otherwise uses the user linked to + the validated secret key. It is unmodified by impersonation — by the time it runs, + `req.userId` already reflects step 1's substitution, so every step below scopes to the + target profile exactly as it would for that profile's own session. 3. Ownership middleware scopes quotes, ramps, provider accounts, recipients, and history to that effective user and their customer entities. 4. At ramp registration, the server resolves the provider account for the effective user. Client-supplied provider identifiers are either ignored or accepted only when they match the server-derived identity. +Impersonation is a substitution at step 1, not a parallel authorization path — nothing from +step 2 onward changes. Its session lifecycle, controls, and audit trail are normative in +[`security-spec/01-auth/admin-impersonation.md`](security-spec/01-auth/admin-impersonation.md); +this document only reflects where the seam sits in principal resolution. + Quotes remain available before login where the public API permits rate discovery. An authenticated user may claim an anonymous quote at registration; an already user-owned quote cannot be claimed by another user. @@ -118,10 +134,11 @@ quote cannot be claimed by another user. ## Implementation map - Sequelize models: `apps/api/src/models/{user,customerEntity,providerCustomer,kycCase,partner,partnerPricingConfig,apiCredential,partnerManagedProfile,recipientInvitation,senderRecipient,recipientPayoutReference}.model.ts` -- Principal resolution: `apps/api/src/api/middlewares/{dualAuth,effectiveUser,ownershipAuth}.ts` +- Principal resolution: `apps/api/src/api/middlewares/{bearerPrincipal,dualAuth,effectiveUser,ownershipAuth}.ts` +- Impersonation session lifecycle: `apps/api/src/api/services/impersonation.service.ts` - Provider ownership resolution: `apps/api/src/api/services/avenia-account.ts` and provider controllers/services - Schema history: `apps/api/src/database/migrations/038-*` onward -- Migrations 060-061 production gates: [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) +- Migrations 060–061 production gates: [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) - Security details: `docs/security-spec/01-auth/`, `03-ramp-engine/recipient-transfers.md`, and the provider specs under `05-integrations/` Update this document only when the cross-module shape changes. Provider-specific flows, diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 8b5004ac5..32f9a29b0 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -290,6 +290,58 @@ provider-shaped rather than UI-shaped. `getOrCreateCustomerEntityForProfile`. Whether users will ever be able to *switch* the active entity (individual ↔ company) remains open. +## Admin console (operator surface) + +A separate, operator-facing surface exists alongside the customer-facing dashboard described +above: profiles holding the `vortex_admin` role reach `/v1/admin-console/*` and can act on a +customer's behalf. It is documented here as the product-level counterpart to the customer +surface; its security controls are normative in +[`security-spec/01-auth/admin-impersonation.md`](security-spec/01-auth/admin-impersonation.md). + +**What v1 lets an operator do:** + +- Look up an account: `GET /v1/admin-console/accounts` (list/search) and + `GET /v1/admin-console/accounts/:profileId` (single account). +- Start impersonating a customer: `POST /v1/admin-console/impersonation` with the target + profile id, returning a 30-minute, non-renewable session token. +- See active and recent impersonation sessions: `GET /v1/admin-console/impersonation`. +- End a session immediately: `DELETE /v1/admin-console/impersonation/:sessionId`. + +**Depth is FULL, not scoped.** Once impersonating, the operator acts with the target account's +complete rights, including money movement — there is no read-only or reduced-capability +impersonation mode in v1. An impersonated request cannot mint a durable API credential or +re-enter the admin console (no privilege re-escalation, no chaining), with one narrow exception +so an operator can end its own session. + +**v1 scope is Vortex → main-account only.** There is no parent/child account table. The +main-account → sub-account delegation layer, modelled on Avenia's subaccount API, is explicitly +v2 — not present, not planned for this iteration. + +**Operator surface in this app.** The `/v1/admin-console/*` layer is implemented and covered by +tests, and the frontend that consumes it ships here: `/admin` (searchable, paginated account +table with a "Log in as" action behind a confirmation dialog) and `/admin/$profileId` +(entities, their provider accounts and KYC cases, plus recent sessions against that account). +Both inherit the `/admin` parent route's redirect to `/overview` unless `roles` from +`GET /v1/onboarding/status` contains `vortex_admin`, and the sidebar's Admin item follows the +same gate. While a session is live, +`ImpersonationBanner` is rendered above the topbar on every `_app` route — non-dismissible, +naming the impersonated account and offering "Exit". Because the operator's own Supabase tokens +are kept beside one atomic impersonation-session record rather than replaced, exiting is local +and instant. The record is observed across tabs, and every enter, exit, expiry, or cross-tab +replacement clears account-scoped query, notification, transfer, and wallet state. + +**Verified against a running stack.** Migrations 062 and 063 apply from a clean schema, and the +flow (grant the role, log in, list accounts, impersonate, exit) is covered against a local API +with Supabase auth: the impersonated principal resolves to the target, +`/v1/admin-console/*` and API-credential minting refuse an impersonated caller with 403, the +exit path requests self-revocation, and a revoked token is rejected on its next use. + +Exiting revokes the session server-side on a best-effort basis: the banner clears and the +operator returns to their own session even if that `DELETE` fails, so a failed network call can +never strand them in someone else's account. When it does fail, the server-side row stays live +until the 30-minute TTL expires — the local UI state is not proof the session is closed. The +audit view (`GET /v1/admin-console/impersonation`) is authoritative. + --- Architecture: [`docs/architecture-identity-model.md`](architecture-identity-model.md). diff --git a/docs/security-spec/01-auth/admin-auth.md b/docs/security-spec/01-auth/admin-auth.md index 3f6db8813..5d9368f68 100644 --- a/docs/security-spec/01-auth/admin-auth.md +++ b/docs/security-spec/01-auth/admin-auth.md @@ -2,6 +2,15 @@ ## What This Does +This document is scoped to the shared-secret `/v1/admin/*` surface. A second, independent admin +surface exists at `/v1/admin-console/*`: it is Supabase-authenticated and gated by the +`vortex_admin` profile role rather than a shared secret, is identity-bearing by design, and +includes the ability for an operator to impersonate a customer profile. That surface is +documented separately in [`admin-impersonation.md`](admin-impersonation.md) — everything below +does not apply to it. The two surfaces are independent: an admin-console operator's Supabase +session does not grant `/v1/admin/*` access, and possession of `ADMIN_SECRET` does not by itself +grant `/v1/admin-console/*` access (Invariant 8). + Admin authentication protects internal/operational endpoints that can mutate system state or manage partners. It uses a single shared secret (`ADMIN_SECRET` env var) compared via Bearer token. Read-only access to client observability endpoints uses a separate `METRICS_DASHBOARD_SECRET` so a metrics token compromise does not grant broader admin access. The flow: @@ -26,7 +35,21 @@ the shared credential; individual admin identities are out of scope for this cha 4. **Admin endpoints MUST be limited in scope** — Admin auth grants access to operational endpoints only. It MUST NOT grant the ability to initiate ramps, access user funds, or sign transactions. 5. **Error responses MUST distinguish between missing auth (401) and invalid auth (403)** — This is the current behavior: missing header → 401, invalid token → 403. 6. **The `Authorization` header MUST use the `Bearer` scheme** — Other schemes (Basic, etc.) must be rejected. -7. **Admin auth MUST NOT attach any identity to the request** — Unlike Supabase auth (which sets `userId`) or API key auth (which sets `authenticatedPartner`), admin auth is identity-less. No `req.adminUser` or similar should exist. +7. **Admin auth on `/v1/admin/*` MUST NOT attach any identity to the request** — Unlike Supabase auth (which sets `userId`) or API key auth (which sets `authenticatedPartner`), admin auth on this surface is identity-less. No `req.adminUser` or similar should exist. This invariant is scoped to `/v1/admin/*`: the separate `/v1/admin-console/*` surface is intentionally identity-bearing — it authenticates via Supabase and carries the operator's profile ID — by design; see [`admin-impersonation.md`](admin-impersonation.md). +8. **`vortex_admin` MUST NOT be grantable through `POST /v1/admin/profile-roles`** — that + route is guarded only by `ADMIN_SECRET`, and `vortex_admin` grants access to + `/v1/admin-console/*` including FULL-depth customer impersonation + ([`admin-impersonation.md`](admin-impersonation.md)). If the shared secret could grant that + role, it would be sufficient by itself to gain money-movement rights over any customer, + collapsing the separation this document's "What This Does" section describes. Granting + `vortex_admin` must go through an out-of-band operator process outside this route. + **Enforced**: `profileRole.model.ts` exports + `HTTP_GRANTABLE_PROFILE_ROLES = ["discount_manager"]`; `addProfileRole` + (`profileRoles.controller.ts`) returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` + (verified in `profileRoles.controller.test.ts`). `removeProfileRole` deliberately remains + exempt as a safety valve; removing `vortex_admin` atomically revokes every live + impersonation session owned by that profile. The sanctioned grant path is + `apps/api/scripts/grant-vortex-admin.ts`, run as `bun run grant:vortex-admin `. ## Threat Vectors & Mitigations @@ -36,6 +59,7 @@ the shared credential; individual admin identities are out of scope for this cha | **Timing leak on length mismatch** | A naive comparison returns immediately when lengths differ | `safeCompare` performs a dummy `timingSafeEqual` operation before rejecting a different-length token; equal-length values use `crypto.timingSafeEqual`. | | **ADMIN_SECRET in logs** | Secret accidentally logged via request logging middleware | Auth header should be excluded from request logging; verify no middleware logs full headers | | **Shared secret rotation** | Need to rotate ADMIN_SECRET without downtime | Currently no dual-secret or graceful rotation — changing the env var immediately invalidates all admin sessions | +| **ADMIN_SECRET escalates to customer impersonation** | Holder of `ADMIN_SECRET` calls `POST /v1/admin/profile-roles` to grant themselves (or a colluding profile) `vortex_admin`, then impersonates any customer via `/v1/admin-console/*` | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES`; the route returns `403 ROLE_NOT_HTTP_GRANTABLE` for it (Invariant 8). The only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access. | | **No individual administrative principal** | A privileged change cannot be attributed to, selectively revoked from, or constrained to one operator | **ACCEPTED RISK.** Retain the shared `ADMIN_SECRET` model for now; protect and rotate it operationally. Individual identities and role separation require a later architectural change. | | **Brute force** | Attacker iterates possible ADMIN_SECRET values | Rate limiting on admin endpoints; sufficiently long secret (recommended: 64+ chars) | | **Unauthorized admin endpoint discovery** | Attacker probes for admin routes | Admin routes should not be documented in public API docs; return 401 for unrecognized routes (not 404) | @@ -46,9 +70,10 @@ the shared credential; individual admin identities are out of scope for this cha - [x] `safeCompare()` is the only comparison used for the admin secret — no `===` or `==` anywhere — **PASS** - [x] `safeCompare()` uses `crypto.timingSafeEqual` for equal-length values and performs a dummy constant-time comparison before rejecting a different length. **PASS** - [x] `config.adminSecret` is validated at production startup, and the middleware also fails closed at runtime if absent. **PASS** -- [x] No admin endpoint also accepts Supabase auth or API key auth as a fallback (admin is the only auth layer) — **PASS** +- [x] No `/v1/admin/*` endpoint also accepts Supabase auth or API key auth as a fallback (`adminAuth` is the only auth layer on this surface) — **PASS**. (`/v1/admin-console/*` is a separate, intentionally Supabase-authenticated surface by design — see [`admin-impersonation.md`](admin-impersonation.md) — and is out of scope for this check.) - [x] Admin endpoints are not reachable from the public frontend (verify CORS, route prefix separation) — **PASS (CORS allows all origins to all routes, but auth middleware protects)** - [ ] `ADMIN_SECRET` is at least 32 characters in production — **N/A: Deployment config, not verifiable from code** - [x] No logging middleware captures the full `Authorization` header for admin requests — **PASS** - [x] Error response for invalid admin token does not include the expected token or any hint about the secret — **PASS** - [x] Missing and invalid admin-auth attempts are logged with request IP/path; secret values are not logged. **PASS** +- [x] `vortex_admin` cannot be granted via `POST /v1/admin/profile-roles` — **PASS**: `addProfileRole` returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` (`profileRoles.controller.test.ts`); the sanctioned grant path is `scripts/grant-vortex-admin.ts`. See Invariant 8. diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md new file mode 100644 index 000000000..b87be2f0a --- /dev/null +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -0,0 +1,270 @@ +# Admin Impersonation + +## What This Does + +`vortex_admin` operators can act as a customer's profile through the `/v1/admin-console/*` +surface — the per-operator, Supabase-identity-bearing counterpart to the shared-secret +`/v1/admin/*` surface documented in [`admin-auth.md`](admin-auth.md). v1 scope is Vortex → +main-account only: there is no parent/child account table, and the sub-account layer +modelled on Avenia's subaccount API is deferred to v2. + +Depth is **FULL**: while impersonating, the operator acts with the target profile's complete +rights, including money movement. There is no reduced-scope or read-only impersonation mode in +v1; this is the primary residual risk this document exists to bound (see the risk register, +RISK-018). + +### Routes + +All routes live under `/v1/admin-console/*` (`accounts.route.ts`, `impersonation.route.ts`). + +| Route | Guard | Success | Notable errors | +|---|---|---|---| +| `GET /accounts?search=&cursor=&limit=` | `requireVortexAdmin` | `200` — paginated, email-`search`-filtered account list | — | +| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — entities, provider customers, KYC cases, recent impersonation sessions targeting this profile | `400 INVALID_PROFILE_ID`; `404 USER_NOT_FOUND` | +| `POST /impersonation` `{ targetProfileId }` | `requireVortexAdmin` | `201 { token, sessionId, expiresAt, target: { id, email } }` | `400 INVALID_IMPERSONATION_INPUT` (malformed `targetProfileId`); `400 IMPERSONATION_TARGET_INVALID` (self-target, unknown target — from `ImpersonationTargetError`); `403 VORTEX_ADMIN_REQUIRED` if the role is removed during creation; `503 IMPERSONATION_DISABLED` (kill switch off — the caller is authorized, the capability is off, so this is a capability error, not an auth error) | +| `GET /impersonation?limit=` | `requireVortexAdmin` | `200 { sessions: [...] }` — active-first audit view; a non-positive or malformed limit falls back to the default | — | +| `DELETE /impersonation/:sessionId` | see Invariant 12 | `204` | `400 INVALID_IMPERSONATION_SESSION_ID`; `403 IMPERSONATION_NOT_ALLOWED`; `403 VORTEX_ADMIN_REQUIRED`; `404 IMPERSONATION_SESSION_NOT_FOUND` | + +`requireVortexAdmin` (`vortexAdminAuth.ts`) is the chain `requireAuth → rejectImpersonation → +checkVortexAdminRole`: Supabase auth, then no impersonation chaining, then the `vortex_admin` +capability role (`ProfileRole` with `role = "vortex_admin"`). The authenticated operator remains +in `req.userId` for downstream controllers. `GET /accounts` pagination is offset-based: +`nextCursor` is the next numeric offset serialized as a string; clients should treat it as +opaque rather than compute their own. + +`DELETE /impersonation/:sessionId` is deliberately **not** behind `requireVortexAdmin` — see +Invariant 12 for the exact self-revoke mechanism this enables. + +### Session lifecycle + +1. `POST /v1/admin-console/impersonation` with `{ targetProfileId }` mints a session + (`impersonation.service.ts::createSession`) and returns `{ token, sessionId, expiresAt, + target }`. The token is `vtx_imp_` followed by 32 random bytes (256 bits), base64url-encoded. + Only its SHA-256 hash is persisted to `admin_impersonation_sessions`; the raw value is + returned exactly once and never stored server-side. +2. The operator presents the token as an ordinary `Authorization: Bearer` header on subsequent + requests. `resolveBearerPrincipal()` (`bearerPrincipal.ts`) is the single seam that resolves + any bearer token to a principal: it routes on the `vtx_imp_` prefix before doing any + database work, so ordinary Supabase tokens are unaffected in cost or behavior. +3. For a live impersonation token, `resolveSession()` looks the token up by hash, checks it is + unexpired and unrevoked, and re-checks that the actor still holds `vortex_admin` before it + returns an `ImpersonationContext`. `resolveBearerPrincipal()` + then sets `userId` to the **target's** profile ID and `userEmail` to the **target's** email — + not the operator's. `bearerPrincipal.ts` is the only substitution point: `getEffectiveUserId()` + (`req.userId ?? req.credential?.profileId`), ownership middleware, and every controller + downstream run unmodified against the target. See + [`architecture-identity-model.md`](../../architecture-identity-model.md) for how this seam fits + the rest of principal resolution. +4. `req.impersonation` (`{ sessionId, actorProfileId, targetProfileId, targetEmail, expiresAt }`) + carries the operator's identity alongside the substituted principal, for audit and for + `rejectImpersonation` to gate on. +5. `GET /v1/admin-console/impersonation` lists sessions for audit (active first, then recent); + `DELETE /v1/admin-console/impersonation/:sessionId` revokes one immediately. + +Both `requireAuth`/`optionalAuth` (`supabaseAuth.ts`) and the dual-auth handlers +(`dualAuth.ts`) call `resolveBearerPrincipal()`, so an impersonation token is honored on any +route reachable by a Supabase bearer token — not only a dedicated impersonation-only path. The +`X-API-Key` credential path is a distinct credential type and is not affected. + +### Granting `vortex_admin` + +`vortex_admin` is not grantable through `POST /v1/admin/profile-roles` — that route is guarded +only by the shared `ADMIN_SECRET`, and holding `vortex_admin` is sufficient to impersonate any +customer at FULL depth, so that secret must never be sufficient by itself to grant it. +`HTTP_GRANTABLE_PROFILE_ROLES` (`profileRole.model.ts`) lists only `discount_manager`; +`addProfileRole` returns `403 ROLE_NOT_HTTP_GRANTABLE` for anything else. `removeProfileRole` +deliberately still revokes any role, including `vortex_admin`, as a safety valve; removing that +role atomically revokes every non-revoked session minted by the operator. Token resolution also +checks the role on every use, so an out-of-band role deletion invalidates outstanding tokens. +The sanctioned grant path is out-of-band: `apps/api/scripts/grant-vortex-admin.ts`, run as +`bun run grant:vortex-admin ` from `apps/api`. It is idempotent (`ProfileRole.findOrCreate`) +and requires deployment/database access rather than an HTTP credential — see +[`admin-auth.md`](admin-auth.md) Invariant 8. + +## Security Invariants + +1. **Impersonation tokens MUST be routed by prefix before any credential lookup** — + `isImpersonationToken()` checks the `vtx_imp_` prefix; a non-matching token never triggers an + `admin_impersonation_sessions` query (verified: `resolveSession` short-circuits and + `AdminImpersonationSession.findOne` is not called for non-prefixed input). +2. **Only the token's SHA-256 hash MUST be persisted** — `tokenHash` is a unique-indexed + `CHAR(64)` column; the raw token exists only in the `createSession()` return value at mint + time. A leaked database row cannot be replayed. +3. **Session creation MUST require the kill switch on, a current admin actor, and a real, + distinct target** — `createSession()` throws `ImpersonationDisabledError` when + `config.impersonationEnabled` is false, re-checks the actor's `vortex_admin` role inside the + creation transaction, and throws `ImpersonationTargetError` for a non-existent target profile or + `actorProfileId === targetProfileId`. The actor-≠-target check is additionally enforced by a + database `CHECK` constraint (`chk_admin_impersonation_sessions_distinct`), independent of the + application layer. Sessions carry no operator-supplied justification: attribution rests on the + actor identity and timestamps recorded on the session row and stamped onto every event raised + during the request (Invariant 13). +4. **Sessions MUST be short-lived and non-renewable** — `IMPERSONATION_TTL_MS` is 30 minutes, + fixed at creation (`expiresAt = now + 30m`). No code path extends `expiresAt`; continuing past + it requires a fresh `POST /v1/admin-console/impersonation` call, itself separately audited. +5. **Token resolution MUST re-check liveness and actor authorization on every use, not cache a + prior verdict** — `resolveSession()` re-reads `revokedAt` and `expiresAt` and verifies that the + actor still holds `vortex_admin` on each call. It returns `null` for anything not currently + live (unknown, expired, revoked, role removed, or minted while the kill switch was on but + resolved after it was flipped off). +6. **The kill switch MUST invalidate in-flight sessions, not just block new ones** — `resolveSession()` + returns `null` whenever `config.impersonationEnabled` is false, regardless of a session's own + `revokedAt`/`expiresAt`. Setting `IMPERSONATION_ENABLED=false` makes every outstanding token + stop resolving immediately, with no per-row revocation pass required. +7. **Starting a new session for the same (actor, target) MUST supersede the prior one** — + `createSession()` revokes any existing non-revoked session for that exact `(actorProfileId, + targetProfileId)` pair with `revokedReason: "superseded"` before minting the new token. This + is serialized by a row lock on the actor profile and backed by the partial unique index + `uq_admin_impersonation_sessions_active`, so concurrent starts cannot leave two non-revoked + sessions for the same pair. +8. **Revocation MUST be immediate and idempotent** — `revokeSession()` performs one + `UPDATE ... WHERE id = :id AND revoked_at IS NULL`, returning whether it revoked anything; a + second revoke of the same session is a no-op that preserves the original `revokedAt` and + `revokedReason`. Removing `vortex_admin` shares the actor-profile row lock with session + creation and revokes all of that actor's outstanding sessions in the same transaction. +9. **The substituted principal MUST be the target on every field a controller can observe** — + `resolveBearerPrincipal()` sets both `userId` and `userEmail` to the target's values. This + matters concretely: controllers that key provider enrollment (Mykobo/Alfredpay/Monerium) off + `req.userEmail` must observe the target's email, never the operator's. +10. **`req.impersonation` MUST be set only by `resolveBearerPrincipal()`**, mirroring the + single-writer invariant Supabase auth already holds for `req.userId` + ([`supabase-otp.md`](supabase-otp.md) invariant 3) — no controller or service sets it + directly. +11. **An impersonated request MUST NOT be able to mint durable credentials** — + `rejectImpersonation` is applied ahead of `/v1/api-credentials` (`api-credentials.route.ts`): + a credential minted while acting as someone else would outlive the 30-minute session and + become a standing backdoor into the target's account. +12. **An impersonated request MUST NOT be able to reach the admin console, except to end its own + session** — There is exactly one carve-out, and it is narrow by construction: + `DELETE /v1/admin-console/impersonation/:sessionId` is mounted behind `requireAuth` only, not + the shared `requireVortexAdmin` chain every other admin-console route uses. Inside + `deleteImpersonationSession`, a request is treated as ending its own session only when + `req.impersonation.sessionId === req.params.sessionId` — i.e., the `:sessionId` path + parameter names exactly the session the caller's own bearer token resolved to. That case + skips both the `rejectImpersonation` check and the `vortex_admin` role check and proceeds + straight to revocation. Any other impersonated request to that same route — a different + `sessionId`, including a different session belonging to the same operator — is rejected with + `403 IMPERSONATION_NOT_ALLOWED` before any role check runs. Every other route (`GET + /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, `GET /impersonation`) sits + behind `requireVortexAdmin` = [`requireAuth`, `rejectImpersonation`, role check], so an + impersonated caller is refused at the `rejectImpersonation` step, before role or business + logic runs at all. **Verified**: `admin-console.route.test.ts` — an impersonated caller can + end its own session (`204`), is refused ending a different session (`403 + IMPERSONATION_NOT_ALLOWED`), and is refused `GET /accounts` and `POST /impersonation` + (`403`). +13. **Every `api_client_events` row raised during an impersonated request MUST carry both + identities** — `buildApiClientRequestMetadata()` stamps `metadata.impersonationSessionId` and + `metadata.impersonatorProfileId` whenever `req.impersonation` is set, while the event's own + `userId` field is the effective (target) user. An action is therefore always attributable to + the operator even though it is recorded against the target's account. +14. **`vortex_admin` MUST NOT be grantable through the `ADMIN_SECRET`-guarded + `POST /v1/admin/profile-roles` route** — that shared secret must not, by itself, be sufficient + to gain FULL-depth impersonation rights (i.e., money movement) over any customer; granting + `vortex_admin` requires an out-of-band operator process outside the shared-secret surface. + **Enforced**: `HTTP_GRANTABLE_PROFILE_ROLES = ["discount_manager"]` in `profileRole.model.ts`; + `addProfileRole` checks membership and returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` + (verified in `profileRoles.controller.test.ts`, "rejects granting vortex_admin via HTTP but + still allows discount_manager"). `removeProfileRole` is intentionally exempt from this list — + revocation of any role, including `vortex_admin`, remains available via that route as a safety + valve (verified: "still allows revoking vortex_admin even though it cannot be granted via + HTTP"). See [`admin-auth.md`](admin-auth.md) Invariant 8. +15. **Session audit history MUST NOT disappear when an actor or target profile is deleted** — + both profile foreign keys in migration 063 use `ON DELETE RESTRICT`. Operators must resolve + retention/deletion policy explicitly instead of erasing security history through a profile + cascade. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| Database dump exposes usable tokens | Attacker reads `admin_impersonation_sessions` from a backup or replica | Only a SHA-256 hash is stored; the raw token is never persisted (Invariant 2) | +| Stolen or leaked impersonation token replayed after the operator's intent has ended | Token captured via logs, browser history, or a compromised operator device | 30-minute non-renewable TTL (Invariant 4); instant hash-based revocation via `DELETE /impersonation/:sessionId` (Invariant 8); re-checked liveness on every use (Invariant 5) | +| Impersonation used to mint a permanent backdoor | Operator (or an attacker who obtained an operator's token) mints an API secret key while impersonating, which outlives the session | `rejectImpersonation` on `/v1/api-credentials` (Invariant 11) | +| Privilege re-escalation / impersonation chaining | An impersonated request is used to start a second impersonation session, list sessions, or browse accounts | `requireVortexAdmin`'s `rejectImpersonation` step refuses `GET /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation` outright (Invariant 12) | +| Impersonated caller abuses the self-revoke carve-out to end someone else's session | Operator impersonating profile A presents that token against profile B's `sessionId` | Rejected with `403 IMPERSONATION_NOT_ALLOWED`: the carve-out only matches when the path `:sessionId` equals the caller's own `req.impersonation.sessionId` (Invariant 12) | +| Unattributed money movement | Operator disputes having performed an action while impersonating | Per-operator Supabase identity recorded as `actorProfileId` on the session row (Invariant 3); `impersonationSessionId`/`impersonatorProfileId` on every `api_client_events` row raised during the request (Invariant 13) | +| Self-impersonation used to launder attribution | Operator targets their own profile to blur operator/target identity | Rejected at both the application layer and a database `CHECK` constraint (Invariant 3) | +| Stale sessions surviving an incident response kill switch | Operator response to a suspected compromise is "disable impersonation", but existing tokens keep working | `IMPERSONATION_ENABLED=false` invalidates all live sessions on next resolution, not just new mints (Invariant 6) | +| Removed operator role leaves previously minted tokens usable | An operator is deprovisioned while one or more impersonation sessions remain live | Role removal atomically revokes all non-revoked sessions, and token resolution independently re-checks `vortex_admin` on every use (Invariants 5 and 8) | +| Token brute force / guessing | Attacker attempts to guess a valid `vtx_imp_*` value | 256 bits of randomness in the token; lookup requires an exact SHA-256 hash match | +| Shared-secret surface used to self-grant impersonation rights | An operator (or anyone) with `ADMIN_SECRET` calls `POST /v1/admin/profile-roles` to grant themselves `vortex_admin`, turning a shared secret into money-movement rights over any customer | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` (Invariant 14); the only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access, not an HTTP credential | +| Concurrent session creation races the supersession check | Two near-simultaneous `POST /impersonation` calls for the same (actor, target) both attempt to supersede and mint | Actor-row transaction locking serializes creation; the partial unique index rejects any second non-revoked row if locking regresses (Invariant 7) | +| Profile deletion erases the impersonation audit trail | Deleting a target or operator cascades into session history | Both foreign keys use `ON DELETE RESTRICT`, preserving the audit record until retention is handled explicitly (Invariant 15) | + +## Gaps Identified During This Review + +- FULL-depth impersonation (Invariant 3 does not restrict scope, only identity and target) is + a deliberate v1 design decision, not an oversight, but it remains the primary residual risk: + any compromised operator account or misused session can move a customer's funds. There is no + read-only or reduced-scope impersonation mode. Tracked as an accepted risk in the risk register + (RISK-018), not as an open implementation gap. +- The operator-facing frontend that consumes `/v1/admin-console/*` lives in `apps/dashboard` + (account search UI, and a non-dismissible banner naming the impersonated account while a + session is active). Its behavior is tracked in + [`docs/product-dashboard.md`](../../product-dashboard.md), not here — this document only covers + the API surface. The dashboard additionally hides the Admin nav entry while a session is + active; that is presentation only, and carries no security weight — `rejectImpersonation` + (Invariant 12) is the enforcement boundary and refuses those routes regardless of what the + client renders. +- Client-reported session state is not authoritative. The dashboard stores the complete session + as one atomic record, subscribes to cross-tab changes, and clears account-scoped caches on + every identity transition. Its "Exit" clears the banner immediately even when the best-effort + `DELETE /impersonation/:sessionId` fails, deliberately, so a failed network call + cannot strand an operator in a customer's account. A session may therefore appear closed to the + operator while the row stays live until its TTL expires. `GET /impersonation` is the + authoritative view; the bounded exposure is the same 30-minute TTL as Invariant 4. + +## Audit Checklist + +- [x] `isImpersonationToken()` prefix routing precedes any database lookup — **PASS** + (`impersonation.service.test.ts`: "returns null for a non-`vtx_imp_` string without hitting + the database"). +- [x] Only `tokenHash` (SHA-256) is persisted; the raw token is returned once and not stored — + **PASS**. +- [x] `createSession()` enforces the kill switch, a current `vortex_admin` actor, distinct + actor/target, and an existing target profile — **PASS**. +- [x] A database `CHECK` constraint independently enforces actor ≠ target — + **PASS**. +- [x] Session TTL is fixed at 30 minutes with no renewal path — **PASS**. +- [x] `resolveSession()` rejects unknown, expired, revoked, and deauthorized-actor tokens, and + rejects all tokens the instant `IMPERSONATION_ENABLED` is false, independent of each + session's own state — **PASS**. +- [x] Creating a new session for an existing (actor, target) pair revokes the prior one as + `superseded`; concurrent starts leave exactly one live row, enforced by an actor-row lock and + partial unique index — **PASS** (`impersonation.service.test.ts`). +- [x] `revokeSession()` is a single scoped, idempotent update — **PASS**. +- [x] `resolveBearerPrincipal()` sets `userId`/`userEmail` to the target's values for a resolved + impersonation token, and leaves them and `impersonation` untouched for an ordinary Supabase + token — **PASS**. +- [x] `req.impersonation` is set only within `resolveBearerPrincipal()`, consumed by + `supabaseAuth.ts` and `dualAuth.ts` — **PASS**. +- [x] `rejectImpersonation` blocks `/v1/api-credentials` (credential minting) — **PASS**. +- [x] `requireVortexAdmin` (`requireAuth → rejectImpersonation → role check`) gates `GET + /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation`; an + impersonated caller is refused all four — **PASS** (`admin-console.route.test.ts`, "refuses + an impersonated caller from reaching GET /accounts or POST /impersonation"). +- [x] `DELETE /impersonation/:sessionId` allows an impersonated caller to end only its own session + (`req.impersonation.sessionId === :sessionId`) and rejects any other target with `403 + IMPERSONATION_NOT_ALLOWED`, while a non-impersonated caller still needs `vortex_admin` to + revoke any session — **PASS** (`admin-console.route.test.ts`, all four cases under "DELETE + /impersonation/:sessionId while impersonating"). +- [x] Every `api_client_events` row raised while `req.impersonation` is set carries + `impersonationSessionId` and `impersonatorProfileId` in `metadata`, including successful + quote/ramp operations and maintenance denials — **PASS** (`quote.controller.test.ts`, + `ramp.controller.test.ts`, `maintenanceGuard.test.ts`). +- [x] `vortex_admin` is excluded from grant via `POST /v1/admin/profile-roles` + (`403 ROLE_NOT_HTTP_GRANTABLE`), while revocation of any role including `vortex_admin` + remains available via `DELETE` on that same route — **PASS** + (`profileRoles.controller.test.ts`). +- [x] Removing `vortex_admin` atomically revokes every live session, while `resolveSession()` also + rejects a token after an out-of-band role deletion — **PASS** (`profileRoles.controller.test.ts`, + `impersonation.service.test.ts`). +- [x] Actor and target deletions are `RESTRICT`ed so session audit rows cannot be cascade-deleted — + **PASS** (`impersonation.service.test.ts`). +- [x] An out-of-band, idempotent operator process for granting `vortex_admin` exists and is + documented — **PASS** (`scripts/grant-vortex-admin.ts`, `bun run grant:vortex-admin + `). +- [x] The operator-facing frontend that consumes `/v1/admin-console/*` presents a + non-dismissible banner naming the impersonated account while a session is active — + **PASS** (`apps/dashboard/src/components/layout/ImpersonationBanner.tsx`, rendered from + `routes/_app.tsx`); behavior tracked in `docs/product-dashboard.md`. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 804d709eb..c151696c5 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -43,6 +43,7 @@ documents win. | Supabase OTP Auth | `01-auth/supabase-otp.md` | Email OTP, session lifecycle, token handling | | API Credential Auth | `01-auth/api-keys.md` | Unified pk\_/sk\_ credential record, capability matrix, validation, lifecycle | | Admin Auth | `01-auth/admin-auth.md` | Admin bearer token, endpoint protection | +| Admin Impersonation | `01-auth/admin-impersonation.md` | `vortex_admin` acting as a customer profile via `/v1/admin-console/*`: session lifecycle, principal substitution, revocation, audit trail | | Ephemeral Accounts | `02-signing-keys/ephemeral-accounts.md` | Client-side key generation, multi-chain, storage | | Server-Side Signing | `02-signing-keys/server-side-signing.md` | Funding keys, executor keys, webhook signing | | State Machine | `03-ramp-engine/state-machine.md` | Phase transitions, locking, idempotency, recovery | diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 624658d65..914f56484 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -23,7 +23,7 @@ register and the owning module specification. | ID | Status | Severity | Owner role | Scope and decision | Existing controls | Revisit / exit criteria | |---|---|---:|---|---|---|---| | RISK-001 | Accepted | High | Platform + Finance | Subsidy limits are per component/ramp; there is no atomically reserved principal, partner, corridor, funding-wallet, or rolling-window budget. Current aggregate behavior is preserved. | Quote-bound amounts, per-component caps, fail-closed USD valuation, durable operation claims, funding-wallet balance. | Before materially increasing volume, adding concurrent workers, or widening subsidy-eligible corridors. | -| RISK-002 | Accepted | Medium | Operations | Administrative writes use one shared `ADMIN_SECRET`; there is no individual principal, MFA, role separation, selective revocation, or per-operator attribution. | Independent high-entropy secret, constant-time equal-length comparison, route middleware, rate limiting, operational rotation. | Introduce an identity provider before broadening the admin surface or team access. | +| RISK-002 | Accepted | Medium | Operations | Administrative writes on the shared-secret `/v1/admin/*` surface use one `ADMIN_SECRET`; there is no individual principal, MFA, role separation, selective revocation, or per-operator attribution on that surface. | Independent high-entropy secret, constant-time equal-length comparison, route middleware, rate limiting, operational rotation. `HTTP_GRANTABLE_PROFILE_ROLES` additionally prevents this shared secret from granting `vortex_admin` (`admin-auth.md` Invariant 8), so it cannot bootstrap its way onto the identity-bearing `/v1/admin-console/*` surface. | Introduce an identity provider before broadening the `/v1/admin/*` surface or team access. The Supabase-authenticated, role-gated `/v1/admin-console/*` surface (RISK-018) satisfies this warning for its own bounded scope by using per-operator identity instead of a shared secret; `/v1/admin/*` itself is unchanged and this entry still applies to it. | | RISK-003 | Accepted | Medium | Product + Security | Pending recipient invitations retain the raw bearer token so the sender can re-copy the link. | 192-bit random token, 14-day TTL, hash-only redemption lookup, sender-scoped listing, optional email binding, first-redeemer binding, raw token cleared on acceptance/observed expiry. | Revisit if invitations gain money-movement authority or threat exposure changes. | | RISK-004 | Deferred | High | Product + Payments Architecture | Recipient eligibility is advisory; recipient-directed payout is unsupported. Ramp registration is a sender self-offramp and rejects common recipient-context fields. | Authenticated/entity-scoped recipient APIs; explicit registration rejection prevents accidental reliance on ignored fields. | A separate PR must define the second principal, relationship ownership, hard eligibility gate, and provider-side payout reference resolution before enabling recipient payout. | | RISK-005 | Accepted | Medium | Product + Operations | The product promises the exact quoted amount. A ramp does not downgrade that promise or report a lesser amount as successful when automated delivery cannot complete. | Exact quote-bound targets, balance checks, capped subsidy paths, recoverable/terminal phase states, reconciliation data. | Add a formal deadline and automatic return of in-transit funds without weakening the exact-amount promise. | @@ -38,6 +38,7 @@ register and the owning module specification. | RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | | RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base ramps are not swept by the Base post-process handler, and AssetHub cleanup is a no-op. | Cleanup worker selects terminal ramps; completed Base token cleanup works; AssetHub corridors remain quote-disabled. | Widen safe Base cleanup to failed/timed-out states and implement/remove AssetHub cleanup before enabling AssetHub or relying on automatic failure refunds. | | RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | +| RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer profile at FULL depth — the operator acts with the target's complete rights, including money movement. v1 has no reduced-scope or read-only impersonation mode. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks credential minting and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before scoping impersonation depth down (e.g., a read-only investigate mode) or before v2 sub-account delegation reuses this mechanism; see `01-auth/admin-impersonation.md`. | ## Review cadence