diff --git a/apps/api/.env.example b/apps/api/.env.example index 053142d34..b314dd4cf 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -24,6 +24,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 46ace3a02..a7b090d57 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -91,6 +91,7 @@ "build": "bun run swc src -d dist --strip-leading-paths", "build:auth-emails": "bun src/scripts/auth-email-templates.ts", "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..b985dc0de --- /dev/null +++ b/apps/api/src/api/controllers/admin-console/accounts.controller.ts @@ -0,0 +1,290 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { literal, Op } from "sequelize"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase from "../../../models/kycCase.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.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 searchPattern = `%${search.replace(/[\\%_]/g, "\\$&")}%`; + const managedIdentityMatch = sequelize.escape(searchPattern); + + const { rows: profiles, count: total } = await User.findAndCountAll({ + attributes: ["id", "email", "kind", "createdAt"], + limit: limit + 1, + offset, + order: [["createdAt", "DESC"]], + where: search + ? { + [Op.or]: [ + { email: { [Op.iLike]: searchPattern } }, + literal(`EXISTS ( + SELECT 1 + FROM managed_profiles AS managed + LEFT JOIN profiles AS manager_profile ON manager_profile.id = managed.manager_profile_id + WHERE managed.profile_id = "User".id + AND ( + managed.contact_email ILIKE ${managedIdentityMatch} + OR managed.external_subject_id ILIKE ${managedIdentityMatch} + OR manager_profile.email ILIKE ${managedIdentityMatch} + ) + )`) + ] + } + : {} + }); + + const hasMore = profiles.length > limit; + const pageProfiles = hasMore ? profiles.slice(0, limit) : profiles; + const profileIds = pageProfiles.map(profile => profile.id); + + const [entities, activeAssignments, managedRelationships] = 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 + } + }) + : [], + profileIds.length ? ManagedProfile.findAll({ where: { profileId: profileIds } }) : [] + ]); + const managerProfileIds = [...new Set(managedRelationships.map(relationship => relationship.managerProfileId))]; + const [managerProfiles, managerConfigs] = await Promise.all([ + managerProfileIds.length ? User.findAll({ attributes: ["id", "email"], where: { id: managerProfileIds } }) : [], + managerProfileIds.length ? ManagedProfileManager.findAll({ where: { profileId: managerProfileIds } }) : [] + ]); + const managedRelationshipByProfileId = new Map( + managedRelationships.map(relationship => [relationship.profileId, relationship]) + ); + const managerProfileById = new Map(managerProfiles.map(manager => [manager.id, manager])); + const managerConfigById = new Map(managerConfigs.map(manager => [manager.profileId, manager])); + + 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 managedRelationship = managedRelationshipByProfileId.get(profile.id); + const managerProfile = managedRelationship ? managerProfileById.get(managedRelationship.managerProfileId) : undefined; + const managerConfig = managedRelationship ? managerConfigById.get(managedRelationship.managerProfileId) : undefined; + 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, + kind: profile.kind, + managedProfile: + managedRelationship && managerProfile && managerConfig + ? { + contactEmail: managedRelationship.contactEmail, + customerType: profileEntities[0]?.type ?? null, + externalSubjectId: managedRelationship.externalSubjectId, + manager: { + email: managerProfile.email, + isActive: managerConfig.isActive, + profileId: managerProfile.id + }, + status: managedRelationship.status + } + : null, + 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, managedRelationship] = await Promise.all([ + CustomerEntity.findAll({ where: { profileId } }), + ManagedProfile.findOne({ where: { profileId } }) + ]); + const entityIds = entities.map(entity => entity.id); + + const [providerCustomers, kycCases, impersonationSessions, managerProfile, managerConfig] = 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 } + }), + managedRelationship ? User.findByPk(managedRelationship.managerProfileId, { attributes: ["id", "email"] }) : null, + managedRelationship ? ManagedProfileManager.findByPk(managedRelationship.managerProfileId) : null + ]); + + 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 + }; + }), + kind: profile.kind, + managedProfile: + managedRelationship && managerProfile && managerConfig + ? { + contactEmail: managedRelationship.contactEmail, + customerType: entities[0]?.type ?? null, + externalSubjectId: managedRelationship.externalSubjectId, + manager: { + email: managerProfile.email, + isActive: managerConfig.isActive, + profileId: managerProfile.id + }, + status: managedRelationship.status + } + : null + }); + } 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/managedProfiles.controller.test.ts b/apps/api/src/api/controllers/managedProfiles.controller.test.ts index f4faacec7..f084c44d0 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.test.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.test.ts @@ -62,6 +62,7 @@ describe("managed profile lifecycle routes", () => { expect((await fetch(baseUrl, { body, headers, method: "POST" })).status).toBe(200); const listed = await fetch(baseUrl, { headers }); expect(await listed.json()).toMatchObject({ + manager: { allowedCorridors: ["BR"], allowedCustomerTypes: null }, managedProfiles: [{ profileId, status: "active" }], pagination: { limit: 50, offset: 0, total: 1 } }); @@ -80,6 +81,19 @@ describe("managed profile lifecycle routes", () => { expect((await fetch(baseUrl, { body, headers, method: "POST" })).status).toBe(409); }); + it("returns manager capabilities when the active manager has no children", async () => { + const { headers, manager } = await createManager(); + + const response = await fetch(baseUrl, { headers }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + manager: { allowedCorridors: ["BR"], allowedCustomerTypes: null, profileId: manager.id }, + managedProfiles: [], + pagination: { limit: 50, offset: 0, total: 0 } + }); + }); + it("returns not found for another manager's child", async () => { const first = await createManager(); const second = await createManager(); diff --git a/apps/api/src/api/controllers/managedProfiles.controller.ts b/apps/api/src/api/controllers/managedProfiles.controller.ts index 1d9284168..9a2081fdc 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.ts @@ -4,6 +4,7 @@ import logger from "../../config/logger"; import { config } from "../../config/vars"; import { CUSTOMER_ENTITY_TYPES } from "../../models/customerEntity.model"; import type { ManagedProfileStatus } from "../../models/managedProfile.model"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; import { getAuthenticatedProfileId } from "../middlewares/effectiveUser"; import { ApiCredentialServiceError, @@ -121,8 +122,17 @@ export async function readManagedProfiles(req: Request, res: Response): Promise< offset, status: status as ManagedProfileStatus | "all" }); + const manager = await ManagedProfileManager.findByPk(managerProfileId(req)); + if (!manager?.isActive) { + throw new ManagedProfileLifecycleError("MANAGED_PROFILE_ACCESS_DENIED", "Managed profile access is denied"); + } res.status(httpStatus.OK).json({ managedProfiles: result.managedProfiles, + manager: { + allowedCorridors: manager.allowedCorridors, + allowedCustomerTypes: manager.allowedCustomerTypes, + profileId: manager.profileId + }, pagination: { limit: result.limit, offset: result.offset, total: result.total } }); } catch (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 e4f327d2d..0cc8bae73 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -66,6 +66,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, @@ -130,6 +131,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, @@ -179,6 +181,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, @@ -207,6 +210,7 @@ interface ObservedQuoteRequest { query?: unknown; requestId?: string; requestStartedAt?: number; + impersonation?: Request["impersonation"]; userId?: string; } @@ -239,7 +243,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..12bb01715 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("includes impersonation attribution in ramp request metadata", () => { + 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/controllers/recipients.controller.ts b/apps/api/src/api/controllers/recipients.controller.ts index 928cd5df2..0fe29a9a2 100644 --- a/apps/api/src/api/controllers/recipients.controller.ts +++ b/apps/api/src/api/controllers/recipients.controller.ts @@ -6,7 +6,7 @@ import { FiatToken, RampDirection } from "@vortexfi/shared"; -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import { Op } from "sequelize"; import sequelize from "../../config/database"; @@ -18,6 +18,7 @@ import ProviderCustomer, { VerificationStatus } from "../../models/providerCusto import RecipientInvitation, { type RecipientInviteeType, type SeededDiscount } from "../../models/recipientInvitation.model"; import RecipientPayoutReference from "../../models/recipientPayoutReference.model"; import SenderRecipient, { type SenderRecipientStatus } from "../../models/senderRecipient.model"; +import { getAuthenticatedProfileId, getEffectiveUserId } from "../middlewares/effectiveUser"; import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; import { emitNotification } from "../services/notifications/notification.service"; import { @@ -35,6 +36,54 @@ import { 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 recipientCorridor( + invitation: RecipientInvitation | null, + relationship?: SenderRecipient +): CorridorCountry | undefined { + if (invitation?.country) return invitation.country.toUpperCase() as CorridorCountry; + if (!relationship?.rail) return undefined; + return (Object.entries(CORRIDOR_CAPABILITIES) as [CorridorCountry, CorridorCapability][]).find( + ([, capability]) => capability.rail === relationship.rail + )?.[0]; +} + +export async function resolveInvitationAuthorizationTarget(req: Request, res: Response) { + const senderCustomerEntityId = req.managedProfileContext?.customerEntityId; + const invitationId = String(req.params.id); + if (!senderCustomerEntityId || !UUID_PATTERN.test(invitationId)) { + sendError(res, httpStatus.NOT_FOUND, "INVITATION_NOT_FOUND", "Invitation not found"); + return; + } + const invitation = await RecipientInvitation.findOne({ + where: { id: invitationId, senderCustomerEntityId } + }); + if (!invitation) { + sendError(res, httpStatus.NOT_FOUND, "INVITATION_NOT_FOUND", "Invitation not found"); + return; + } + res.locals.recipientInvitation = invitation; + return recipientCorridor(invitation); +} + +export async function resolveRecipientAuthorizationTarget(req: Request, res: Response) { + const senderCustomerEntityId = req.managedProfileContext?.customerEntityId; + const relationshipId = String(req.params.id); + if (!senderCustomerEntityId || !UUID_PATTERN.test(relationshipId)) { + sendError(res, httpStatus.NOT_FOUND, "RECIPIENT_NOT_FOUND", "Recipient not found"); + return; + } + const relationship = await SenderRecipient.findOne({ + include: [{ as: "invitation", model: RecipientInvitation }], + where: { id: relationshipId, senderCustomerEntityId } + }); + if (!relationship) { + sendError(res, httpStatus.NOT_FOUND, "RECIPIENT_NOT_FOUND", "Recipient not found"); + return; + } + res.locals.senderRecipient = relationship; + return recipientCorridor(relationship.get("invitation") as RecipientInvitation | null, relationship); +} + function sendError(res: Response, status: number, code: string, message: string): void { res.status(status).json({ error: { code, message, status } }); } @@ -47,6 +96,15 @@ function requireUserId(req: Request, res: Response): string | null { return req.userId; } +function requireEffectiveUserId(req: Request, res: Response): string | null { + const userId = getEffectiveUserId(req); + if (!userId) { + sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); + return null; + } + return userId; +} + interface CreateInviteBody { country?: string; rail?: string; @@ -57,6 +115,11 @@ interface CreateInviteBody { discounts?: { buyBps?: number; sellBps?: number }; } +interface ValidatedCreateInvite { + input: CreateInviteBody & { country: string; payoutCurrency: string; rail: string }; + seededDiscounts: SeededDiscount[]; +} + function isValidBps(value: unknown): value is number { return ( typeof value === "number" && @@ -67,28 +130,35 @@ function isValidBps(value: unknown): value is number { ); } -export async function createInvite(req: Request, res: Response): Promise { - const userId = requireUserId(req, res); - if (!userId) return; - +function validateCreateInviteBody(req: Request, res: Response): ValidatedCreateInvite | null { const { country, rail, payoutCurrency, alias, inviteeEmail, inviteeType, discounts } = (req.body ?? {}) as CreateInviteBody; - if (!country || country.length > 4 || !rail || rail.length > 8 || !payoutCurrency || payoutCurrency.length > 8) { + if ( + typeof country !== "string" || + !country || + country.length > 4 || + typeof rail !== "string" || + !rail || + rail.length > 8 || + typeof payoutCurrency !== "string" || + !payoutCurrency || + payoutCurrency.length > 8 + ) { sendError( res, httpStatus.BAD_REQUEST, "INVALID_INVITE_CORRIDOR", "country (ISO code), rail and payoutCurrency are required" ); - return; + return null; } if (inviteeType !== undefined && inviteeType !== "individual" && inviteeType !== "business") { sendError(res, httpStatus.BAD_REQUEST, "INVALID_INVITEE_TYPE", "inviteeType must be 'individual' or 'business'"); - return; + return null; } if (alias !== undefined && (typeof alias !== "string" || alias.length > 100)) { sendError(res, httpStatus.BAD_REQUEST, "INVALID_ALIAS", "alias must be a string of at most 100 characters"); - return; + return null; } // The dashboard filters by the shared capability matrix; enforce the same rules here so a raw // API call cannot create an invite for an unknown corridor or a combination the corridor's @@ -96,7 +166,7 @@ export async function createInvite(req: Request, res: Response): Promise { const corridor: CorridorCapability | undefined = CORRIDOR_CAPABILITIES[country.toUpperCase() as CorridorCountry]; if (!corridor || corridor.rail !== rail.toLowerCase()) { sendError(res, httpStatus.BAD_REQUEST, "INVALID_INVITE_CORRIDOR", "Unknown corridor"); - return; + return null; } const effectiveInviteeType = (inviteeType ?? "individual") as CorridorCustomerType; if (!corridor.customerTypes.includes(effectiveInviteeType)) { @@ -106,7 +176,7 @@ export async function createInvite(req: Request, res: Response): Promise { "UNSUPPORTED_INVITEE_TYPE", `The ${country.toUpperCase()} corridor cannot onboard ${effectiveInviteeType} recipients` ); - return; + return null; } if ( discounts !== undefined && @@ -121,7 +191,7 @@ export async function createInvite(req: Request, res: Response): Promise { "INVALID_DISCOUNTS", `discounts.buyBps and discounts.sellBps must be integers between 0 and ${config.recipients.inviteMaxDiscountBps}` ); - return; + return null; } // 0 bps means "no discount" — the corridor rail uppercased is exactly its FiatToken value. const seededFiat = corridor.rail.toUpperCase() as FiatToken; @@ -131,14 +201,38 @@ export async function createInvite(req: Request, res: Response): Promise { ]; if (seededDiscounts.length > 0 && !Object.values(FiatToken).includes(seededFiat)) { sendError(res, httpStatus.BAD_REQUEST, "INVALID_DISCOUNTS", "This corridor does not support discounts"); - return; + return null; } + return { + input: { alias, country, discounts, inviteeEmail, inviteeType, payoutCurrency, rail }, + seededDiscounts + }; +} + +export function validateCreateInvite(req: Request, res: Response, next: NextFunction): void { + const validated = validateCreateInviteBody(req, res); + if (!validated) return; + res.locals.createInvite = validated; + next(); +} + +export async function createInvite(req: Request, res: Response): Promise { + const userId = requireEffectiveUserId(req, res); + if (!userId) return; + + const validated = (res.locals.createInvite as ValidatedCreateInvite | undefined) ?? validateCreateInviteBody(req, res); + if (!validated) return; + const { input, seededDiscounts } = validated; + const { alias, country, inviteeEmail, inviteeType, payoutCurrency, rail } = input; + try { // Attaching discounts is a privileged capability — enforce the role server-side so a // raw API call cannot seed pricing the UI would never have offered. if (seededDiscounts.length > 0) { - const role = await ProfileRole.findOne({ where: { role: "discount_manager", userId } }); + const role = await ProfileRole.findOne({ + where: { role: "discount_manager", userId: getAuthenticatedProfileId(req) } + }); if (!role) { sendError(res, httpStatus.FORBIDDEN, "DISCOUNT_ROLE_REQUIRED", "Only discount managers can attach invite discounts"); return; @@ -450,7 +544,7 @@ export async function acceptInvite(req: Request<{ token: string }>, res: Respons } export async function listRecipients(req: Request, res: Response): Promise { - const userId = requireUserId(req, res); + const userId = requireEffectiveUserId(req, res); if (!userId) return; try { @@ -467,8 +561,21 @@ export async function listRecipients(req: Request, res: Response): Promise where: { relationshipStatus: { [Op.ne]: "archived" }, senderCustomerEntityId: senderEntity.id } }); + const managedPolicy = res.locals.managedProfilePolicy as + | { allowedCorridors: readonly CorridorCountry[]; customerType: CorridorCustomerType } + | undefined; + const visibleCorridors = managedPolicy?.allowedCorridors.filter(corridor => + CORRIDOR_CAPABILITIES[corridor].customerTypes.includes(managedPolicy.customerType) + ); + const visibleRelationships = managedPolicy + ? relationships.filter(row => { + const corridor = recipientCorridor(row.get("invitation") as RecipientInvitation | null, row); + return corridor !== undefined && visibleCorridors?.includes(corridor); + }) + : relationships; + const recipients = await Promise.all( - relationships.map(async row => { + visibleRelationships.map(async row => { const invitation = row.get("invitation") as RecipientInvitation | null; const recipient = row.get("recipient") as CustomerEntity | null; const payoutReferences = (row.get("payoutReferences") as RecipientPayoutReference[] | undefined) ?? []; @@ -530,6 +637,7 @@ export async function listRecipients(req: Request, res: Response): Promise { where: { expiresAt: { [Op.lt]: now }, + ...(visibleCorridors ? { country: visibleCorridors } : {}), senderCustomerEntityId: senderEntity.id, status: "pending" } @@ -547,8 +655,15 @@ export async function listRecipients(req: Request, res: Response): Promise } }); + const visibleInvitations = managedPolicy + ? pendingInvitations.filter(invitation => { + const corridor = recipientCorridor(invitation); + return corridor !== undefined && visibleCorridors?.includes(corridor); + }) + : pendingInvitations; + res.status(httpStatus.OK).json({ - pendingInvitations: pendingInvitations.map(invitation => ({ + pendingInvitations: visibleInvitations.map(invitation => ({ alias: invitation.alias, country: invitation.country, createdAt: invitation.createdAt, @@ -580,7 +695,7 @@ interface UpdateRecipientBody { const PATCHABLE_STATUSES: SenderRecipientStatus[] = ["active", "blocked", "archived"]; export async function updateRecipient(req: Request<{ id: string }>, res: Response): Promise { - const userId = requireUserId(req, res); + const userId = requireEffectiveUserId(req, res); if (!userId) return; const { nickname, status } = (req.body ?? {}) as UpdateRecipientBody; @@ -596,9 +711,9 @@ export async function updateRecipient(req: Request<{ id: string }>, res: Respons try { const senderEntity = await getOrCreateCustomerEntityForProfile(userId); - const relationship = await SenderRecipient.findOne({ - where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } - }); + const relationship = + (res.locals.senderRecipient as SenderRecipient | undefined) ?? + (await SenderRecipient.findOne({ where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } })); if (!relationship) { sendError(res, httpStatus.NOT_FOUND, "RECIPIENT_NOT_FOUND", "Recipient not found"); return; @@ -632,7 +747,7 @@ interface ArchiveInvitationBody { // Sender-side list hide only — the invitation keeps its status and the token stays // redeemable, so an archived invite never blocks the recipient's onboarding. export async function archiveInvitation(req: Request<{ id: string }>, res: Response): Promise { - const userId = requireUserId(req, res); + const userId = requireEffectiveUserId(req, res); if (!userId) return; const { archived } = (req.body ?? {}) as ArchiveInvitationBody; @@ -648,9 +763,9 @@ export async function archiveInvitation(req: Request<{ id: string }>, res: Respo try { const senderEntity = await getOrCreateCustomerEntityForProfile(userId); - const invitation = await RecipientInvitation.findOne({ - where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } - }); + const invitation = + (res.locals.recipientInvitation as RecipientInvitation | undefined) ?? + (await RecipientInvitation.findOne({ where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } })); if (!invitation) { sendError(res, httpStatus.NOT_FOUND, "INVITATION_NOT_FOUND", "Invitation not found"); return; @@ -666,14 +781,14 @@ export async function archiveInvitation(req: Request<{ id: string }>, res: Respo } export async function getRecipientEligibility(req: Request<{ id: string }>, res: Response): Promise { - const userId = requireUserId(req, res); + const userId = requireEffectiveUserId(req, res); if (!userId) return; try { const senderEntity = await getOrCreateCustomerEntityForProfile(userId); - const relationship = await SenderRecipient.findOne({ - where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } - }); + const relationship = + (res.locals.senderRecipient as SenderRecipient | undefined) ?? + (await SenderRecipient.findOne({ where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } })); if (!relationship) { sendError(res, httpStatus.NOT_FOUND, "RECIPIENT_NOT_FOUND", "Recipient not found"); return; 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..8cb142911 --- /dev/null +++ b/apps/api/src/api/middlewares/bearerPrincipal.test.ts @@ -0,0 +1,143 @@ +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 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, 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" }); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + 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(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + 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(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + 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 0f48faa7d..2a20a272a 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -6,8 +6,9 @@ import { observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; -import { AccessTokenVerificationError, SupabaseAuthService } from "../services/auth"; +import { AccessTokenVerificationError } from "../services/auth"; import { getKeyType, isValidSecretKeyFormat, validatePublicApiKey, validateSecretApiKey } from "./apiKeyAuth.helpers"; +import { resolveBearerPrincipal } from "./bearerPrincipal"; export { assertQuoteOwnership, assertRampOwnership } from "./ownershipAuth"; @@ -105,9 +106,9 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } if (authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7); - let result: Awaited>; + let result: Awaited>; try { - result = await SupabaseAuthService.verifyToken(token); + result = await resolveBearerPrincipal(token); } catch (error) { if (!(error instanceof AccessTokenVerificationError)) { logger.error("Unexpected Supabase access-token verifier failure", error); @@ -144,8 +145,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/managedProfileAuth.test.ts b/apps/api/src/api/middlewares/managedProfileAuth.test.ts index 9c8b13733..f6b5697a2 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.test.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.test.ts @@ -241,6 +241,24 @@ describe("authorizeManagedProfile", () => { expect(next).toHaveBeenCalledTimes(1); }); + it("applies customer-type narrowing when explicitly required for list reads", async () => { + allowManagedProfile(); + ManagedProfileManager.findByPk = mock(async () => ({ + allowedCorridors: ["BR"], + allowedCustomerTypes: ["business"], + isActive: true + })) as never; + const res = response(); + + await authorizeManagedProfile({ enforceCustomerTypePolicy: true })( + request() as never, + res as never, + mock(() => {}) + ); + + expect(res.statusCode).toBe(403); + }); + it("requires the route customer type to match the immutable child entity type", async () => { allowManagedProfile(); const res = response(); diff --git a/apps/api/src/api/middlewares/managedProfileAuth.ts b/apps/api/src/api/middlewares/managedProfileAuth.ts index 471bbdc21..4530c8d37 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.ts @@ -29,7 +29,8 @@ declare global { type CorridorResolver = | CorridorCountry | (( - req: Request + req: Request, + res: Response ) => CorridorCountry | CorridorCountry[] | undefined | Promise); type CustomerTypeResolver = @@ -39,6 +40,7 @@ type CustomerTypeResolver = interface ManagedProfileAuthOptions { corridor?: CorridorResolver; customerType?: CustomerTypeResolver; + enforceCustomerTypePolicy?: boolean; } export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) { @@ -57,15 +59,6 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) } try { - const resolvedCorridors = typeof options.corridor === "function" ? await options.corridor(req) : options.corridor; - const corridors = Array.isArray(resolvedCorridors) ? resolvedCorridors : resolvedCorridors ? [resolvedCorridors] : []; - if ( - options.corridor !== undefined && - (corridors.length === 0 || corridors.some(corridor => !directManagedCredential.allowedCorridors.includes(corridor))) - ) { - sendAccessDenied(res); - return; - } const customerType = await attachManagedProfileContext(req, res, { actorProfileId: directCredentialProfileId, controllingManagerProfileId: directManagedCredential.controllingManagerProfileId, @@ -73,6 +66,15 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) subjectProfileId: directCredentialProfileId }); if (!customerType) return; + const corridors = await resolveCorridors(req, res, options.corridor); + if (res.headersSent) return; + if ( + options.corridor !== undefined && + (corridors.length === 0 || corridors.some(corridor => !directManagedCredential.allowedCorridors.includes(corridor))) + ) { + sendAccessDenied(res); + return; + } if ( !(await authorizeCustomerType( req, @@ -85,6 +87,10 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) ) { return; } + res.locals.managedProfilePolicy = { + allowedCorridors: directManagedCredential.allowedCorridors, + customerType + }; next(); } catch (error) { next(error); @@ -124,16 +130,7 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) User.findByPk(subjectProfileId, { attributes: ["activeCustomerEntityId", "kind"] }) ]); - const resolvedCorridors = typeof options.corridor === "function" ? await options.corridor(req) : options.corridor; - const corridors = Array.isArray(resolvedCorridors) ? resolvedCorridors : resolvedCorridors ? [resolvedCorridors] : []; - if ( - !manager?.isActive || - !relationship || - subject?.kind !== "managed" || - !subject.activeCustomerEntityId || - (options.corridor !== undefined && - (corridors.length === 0 || corridors.some(corridor => !manager.allowedCorridors.includes(corridor)))) - ) { + if (!manager?.isActive || !relationship || subject?.kind !== "managed" || !subject.activeCustomerEntityId) { sendAccessDenied(res); return; } @@ -145,7 +142,17 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) subjectProfileId }); if (!customerType) return; + const corridors = await resolveCorridors(req, res, options.corridor); + if (res.headersSent) return; + if ( + options.corridor !== undefined && + (corridors.length === 0 || corridors.some(corridor => !manager.allowedCorridors.includes(corridor))) + ) { + sendAccessDenied(res); + return; + } if (!(await authorizeCustomerType(req, res, options, corridors, customerType, manager.allowedCustomerTypes))) return; + res.locals.managedProfilePolicy = { allowedCorridors: manager.allowedCorridors, customerType }; next(); } catch (error) { next(error); @@ -153,6 +160,15 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) }; } +async function resolveCorridors( + req: Request, + res: Response, + resolver: CorridorResolver | undefined +): Promise { + const resolved = typeof resolver === "function" ? await resolver(req, res) : resolver; + return Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; +} + async function attachManagedProfileContext( req: Request, res: Response, @@ -198,7 +214,7 @@ async function authorizeCustomerType( return false; } if ( - ((options.corridor !== undefined || options.customerType !== undefined) && + ((options.corridor !== undefined || options.customerType !== undefined || options.enforceCustomerTypePolicy) && allowedCustomerTypes !== null && allowedCustomerTypes !== undefined && !allowedCustomerTypes.includes(customerType)) || 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..9c53f36bd --- /dev/null +++ b/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts @@ -0,0 +1,118 @@ +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 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 { 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" }); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + 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" }); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + 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" }); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + 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 5c5e2c9f9..4a987d081 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.test.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.test.ts @@ -122,6 +122,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( { @@ -173,4 +191,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 987f12fbb..a655cfdd7 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -36,6 +36,7 @@ interface ApiClientRequestLike { params?: unknown; path?: string; query?: unknown; + impersonation?: { sessionId: string; actorProfileId: string }; } interface RequestMetadataOptions { @@ -94,6 +95,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..27a59901d --- /dev/null +++ b/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts @@ -0,0 +1,322 @@ +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 ManagedProfileManager from "../../../../models/managedProfileManager.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 { createManagedProfile } from "../../../services/managed-profile-lifecycle.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("identifies a managed profile and its authenticated manager", async () => { + const admin = await createAdmin(); + const manager = await createTestUser(); + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: manager.id }); + const { managedProfile } = await createManagedProfile({ + contactEmail: "managed-child@example.com", + creationSource: "vortex", + customerType: "business", + externalSubjectId: "customer_%42", + managerProfileId: manager.id + }); + const headers = authAs(admin); + + const listResponse = await fetch(`${baseUrl}/accounts?search=managed-child`, { headers }); + expect(listResponse.status).toBe(200); + const listBody = (await listResponse.json()) as { accounts: Array & { id: string }> }; + expect(listBody.accounts.find(account => account.id === managedProfile.profileId)).toMatchObject({ + email: null, + kind: "managed", + managedProfile: { + contactEmail: "managed-child@example.com", + customerType: "business", + externalSubjectId: "customer_%42", + manager: { email: manager.email, isActive: true, profileId: manager.id }, + status: "active" + } + }); + + const managerSearchResponse = await fetch(`${baseUrl}/accounts?search=${encodeURIComponent(manager.email)}`, { headers }); + expect(managerSearchResponse.status).toBe(200); + const managerSearchBody = (await managerSearchResponse.json()) as { accounts: Array<{ id: string }> }; + expect(managerSearchBody.accounts.map(account => account.id)).toContain(managedProfile.profileId); + + const paginatedSearchResponse = await fetch( + `${baseUrl}/accounts?search=${encodeURIComponent(manager.email)}&limit=1`, + { headers } + ); + expect(paginatedSearchResponse.status).toBe(200); + expect(await paginatedSearchResponse.json()).toMatchObject({ nextCursor: "1", total: 2 }); + + const literalSearchResponse = await fetch(`${baseUrl}/accounts?search=${encodeURIComponent("_%")}`, { headers }); + expect(literalSearchResponse.status).toBe(200); + const literalSearchBody = (await literalSearchResponse.json()) as { accounts: Array<{ id: string }> }; + expect(literalSearchBody.accounts.map(account => account.id)).toEqual([managedProfile.profileId]); + + const detailResponse = await fetch(`${baseUrl}/accounts/${managedProfile.profileId}`, { headers }); + expect(detailResponse.status).toBe(200); + expect(await detailResponse.json()).toMatchObject({ + email: null, + id: managedProfile.profileId, + kind: "managed", + managedProfile: { + contactEmail: "managed-child@example.com", + customerType: "business", + manager: { email: manager.email, isActive: true, profileId: manager.id } + } + }); + }); + + 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/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 6e952e9a9..9b127d92d 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import multer from "multer"; import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateAlfredpayCustomerType, validateResultCountry } from "../../middlewares/alfredpay.middleware"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; import { @@ -26,6 +27,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + rejectImpersonation, AlfredpayController.createIndividualCustomer ); router.get( @@ -33,6 +35,7 @@ router.get( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + rejectImpersonation, AlfredpayController.getKycRedirectLink ); router.post( @@ -40,6 +43,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: getManagedProfileAlfredpayCustomerType }), + rejectImpersonation, AlfredpayController.kycRedirectOpened ); router.post( @@ -47,6 +51,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: getManagedProfileAlfredpayCustomerType }), + rejectImpersonation, AlfredpayController.kycRedirectFinished ); router.get( @@ -61,6 +66,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: getManagedProfileAlfredpayCustomerType }), + rejectImpersonation, AlfredpayController.retryKyc ); router.post( @@ -68,6 +74,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + rejectImpersonation, AlfredpayController.createBusinessCustomer ); router.get( @@ -75,6 +82,7 @@ router.get( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + rejectImpersonation, AlfredpayController.getKybRedirectLink ); @@ -84,6 +92,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + rejectImpersonation, validateKycSubmission, AlfredpayController.submitKycInformation ); @@ -93,6 +102,7 @@ router.post( // Authenticate the relationship and immutable entity type before buffering. The country // corridor can only be authorized after multer exposes the multipart body. authorizeManagedProfile({ customerType: "individual" }), + rejectImpersonation, upload.single("file"), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), @@ -103,6 +113,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + rejectImpersonation, AlfredpayController.sendKycSubmission ); @@ -112,6 +123,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + rejectImpersonation, validateKybSubmission, AlfredpayController.submitKybInformation ); @@ -120,6 +132,7 @@ router.post( requirePartnerOrUserAuth(), // See submitKycFile: identity/type are pre-buffer checks; country policy is post-parse. authorizeManagedProfile({ customerType: "business" }), + rejectImpersonation, upload.single("file"), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), @@ -137,6 +150,7 @@ router.post( requirePartnerOrUserAuth(), // See submitKycFile: identity/type are pre-buffer checks; country policy is post-parse. authorizeManagedProfile({ customerType: "business" }), + rejectImpersonation, upload.single("file"), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), @@ -147,6 +161,7 @@ router.post( requirePartnerOrUserAuth(), validateResultCountry, authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + rejectImpersonation, AlfredpayController.sendKybSubmission ); 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..f026606e0 --- /dev/null +++ b/apps/api/src/api/routes/v1/api-credentials.route.test.ts @@ -0,0 +1,131 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import express from "express"; +import { config } from "../../../config/vars"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +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 apiCredentialsRoutes from "./api-credentials.route"; +import managedProfilesRoutes from "./managed-profiles.route"; + +const BASE_PATH = "/v1/api-credentials"; + +describe("rejectImpersonation wiring on credential routes", () => { + 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); + app.use("/v1/managed-profiles", managedProfilesRoutes); + 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}`; + }); + + 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(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const res = await fetch(`${baseUrl}${BASE_PATH}`, { + headers: { Authorization: `Bearer ${token}` }, + method: "POST" + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + }); + + it("refuses managed-profile credential creation while impersonating", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const res = await fetch(`${baseUrl}/v1/managed-profiles/${crypto.randomUUID()}/api-credentials`, { + headers: { Authorization: `Bearer ${token}` }, + method: "POST" + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + }); + + it("refuses managed lifecycle mutations and credential revocation while impersonating", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + const profileId = crypto.randomUUID(); + const credentialId = crypto.randomUUID(); + const requests = [ + { method: "POST", url: "/v1/managed-profiles" }, + { method: "DELETE", url: `/v1/managed-profiles/${profileId}` }, + { method: "DELETE", url: `/v1/managed-profiles/${profileId}/api-credentials/${credentialId}` }, + { method: "DELETE", url: `${BASE_PATH}/${credentialId}` } + ]; + + for (const request of requests) { + const res = await fetch(`${baseUrl}${request.url}`, { + headers: { Authorization: `Bearer ${token}` }, + method: request.method + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + } + }); + + it("keeps credential and managed-profile list reads available while impersonating", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: target.id }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + const headers = { Authorization: `Bearer ${token}` }; + + expect((await fetch(`${baseUrl}${BASE_PATH}`, { headers })).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/managed-profiles`, { headers })).status).toBe(200); + }); + + 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}${BASE_PATH}`, { 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..52ee3e711 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.ts @@ -1,11 +1,17 @@ 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); -router.post("/", createUserApiKey as unknown as (req: Request, res: Response) => void); +// Impersonation cannot create a lasting credential or disable the target's integrations. +router.post("/", rejectImpersonation, 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); +router.delete( + "/:credentialId", + rejectImpersonation, + revokeUserApiKey as unknown as (req: Request<{ credentialId: string }>, res: Response) => void +); export default router; diff --git a/apps/api/src/api/routes/v1/brla-kyc-import.route.ts b/apps/api/src/api/routes/v1/brla-kyc-import.route.ts index adc78883f..f44ab6ac9 100644 --- a/apps/api/src/api/routes/v1/brla-kyc-import.route.ts +++ b/apps/api/src/api/routes/v1/brla-kyc-import.route.ts @@ -1,6 +1,7 @@ import bodyParser from "body-parser"; import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requirePartnerOrUserAuth, requireProfileBoundPrincipal } from "../../middlewares/dualAuth"; import { authorizeManagedProfile, rejectDirectManagedCredential } from "../../middlewares/managedProfileAuth"; import { validateAveniaKycTokenImport } from "../../middlewares/validators"; @@ -13,6 +14,7 @@ router.post( requireProfileBoundPrincipal, rejectDirectManagedCredential, authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), + rejectImpersonation, bodyParser.json({ limit: "16kb" }), validateAveniaKycTokenImport, brlaController.importKycToken as unknown as RequestHandler diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index a456e6e9d..8e05bd369 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -1,5 +1,6 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; import { @@ -44,6 +45,7 @@ router.get( "/getSelfieLivenessUrl", requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), + rejectImpersonation, brlaController.getSelfieLivenessUrl as unknown as RequestHandler ); @@ -52,18 +54,20 @@ router.get("/validatePixKey", optionalPartnerOrUserAuth(), brlaController.valida router .route("/createSubaccount") .post( - validateSubaccountCreation, requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), + rejectImpersonation, + validateSubaccountCreation, brlaController.createSubaccount as unknown as RequestHandler ); router .route("/getUploadUrls") .post( - validateStartKyc2, requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), + rejectImpersonation, + validateStartKyc2, brlaController.getUploadUrls ); @@ -72,19 +76,26 @@ router .post( requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), + rejectImpersonation, brlaController.newKyc ); router .route("/kyb/new-level-1/web-sdk") - .post(requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), brlaController.initiateKybLevel1); + .post( + requirePartnerOrUserAuth(), + authorizeManagedProfile({ corridor: "BR" }), + rejectImpersonation, + brlaController.initiateKybLevel1 + ); router .route("/kyb/documents") .post( - validateAveniaKybDocument, requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), + rejectImpersonation, + validateAveniaKybDocument, brlaController.createKybDocument as unknown as RequestHandler ); @@ -95,18 +106,20 @@ router router .route("/kyb/ubos") .post( - validateAveniaKybUbo, requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), + rejectImpersonation, + validateAveniaKybUbo, brlaController.createKybUbo as unknown as RequestHandler ); router .route("/kyb/new-level-1/api") .post( - validateAveniaKybLevel1, requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), + rejectImpersonation, + validateAveniaKybLevel1, brlaController.submitKybLevel1Api as unknown as RequestHandler ); @@ -116,6 +129,11 @@ router router .route("/kyc/record-attempt") - .post(requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), brlaController.recordInitialKycAttempt); + .post( + requirePartnerOrUserAuth(), + authorizeManagedProfile({ corridor: "BR" }), + rejectImpersonation, + brlaController.recordInitialKycAttempt + ); export default router; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 199966386..351c046fb 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -9,6 +9,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"; @@ -250,8 +252,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 */ @@ -271,6 +275,22 @@ router.use("/admin/managed-profiles", adminManagedProfilesRoutes); */ 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/routes/v1/managed-profiles.route.ts b/apps/api/src/api/routes/v1/managed-profiles.route.ts index f7b1b9db1..322216581 100644 --- a/apps/api/src/api/routes/v1/managed-profiles.route.ts +++ b/apps/api/src/api/routes/v1/managed-profiles.route.ts @@ -8,6 +8,7 @@ import { removeManagedProfile, removeManagedProfileApiCredential } from "../../controllers/managedProfiles.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { rejectDirectManagedCredential } from "../../middlewares/managedProfileAuth"; @@ -15,12 +16,12 @@ const router = Router(); router.use(requirePartnerOrUserAuth()); router.use(rejectDirectManagedCredential); -router.post("/", postManagedProfile); +router.post("/", rejectImpersonation, postManagedProfile); router.get("/", readManagedProfiles); -router.post("/:profileId/api-credentials", postManagedProfileApiCredential); +router.post("/:profileId/api-credentials", rejectImpersonation, postManagedProfileApiCredential); router.get("/:profileId/api-credentials", readManagedProfileApiCredentials); -router.delete("/:profileId/api-credentials/:credentialId", removeManagedProfileApiCredential); +router.delete("/:profileId/api-credentials/:credentialId", rejectImpersonation, removeManagedProfileApiCredential); router.get("/:profileId", readManagedProfile); -router.delete("/:profileId", removeManagedProfile); +router.delete("/:profileId", rejectImpersonation, removeManagedProfile); export default router; diff --git a/apps/api/src/api/routes/v1/monerium.route.ts b/apps/api/src/api/routes/v1/monerium.route.ts index bbdc6b86f..91dc87322 100644 --- a/apps/api/src/api/routes/v1/monerium.route.ts +++ b/apps/api/src/api/routes/v1/monerium.route.ts @@ -1,12 +1,13 @@ import { Router } from "express"; import * as moneriumController from "../../controllers/monerium.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); router.use(requireAuth); -router.post("/oauth/start", moneriumController.start); -router.post("/oauth/complete", moneriumController.complete); +router.post("/oauth/start", rejectImpersonation, moneriumController.start); +router.post("/oauth/complete", rejectImpersonation, moneriumController.complete); router.get("/status", moneriumController.status); export default router; diff --git a/apps/api/src/api/routes/v1/mykobo.route.ts b/apps/api/src/api/routes/v1/mykobo.route.ts index d5fa77e7e..f14c0d621 100644 --- a/apps/api/src/api/routes/v1/mykobo.route.ts +++ b/apps/api/src/api/routes/v1/mykobo.route.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import multer from "multer"; import * as mykoboController from "../../controllers/mykobo.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); @@ -13,6 +14,6 @@ const profileUpload = upload.fields([ ]); router.route("/profiles").get(requireAuth, mykoboController.getProfileController); -router.route("/profiles").post(requireAuth, profileUpload, mykoboController.createProfileController); +router.route("/profiles").post(requireAuth, rejectImpersonation, profileUpload, mykoboController.createProfileController); export default router; diff --git a/apps/api/src/api/routes/v1/provider-verification.route.test.ts b/apps/api/src/api/routes/v1/provider-verification.route.test.ts new file mode 100644 index 000000000..13720a7bb --- /dev/null +++ b/apps/api/src/api/routes/v1/provider-verification.route.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import express 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 { handler as errorHandler } from "../../middlewares/error"; +import { createSession } from "../../services/impersonation.service"; +import alfredpayRoutes from "./alfredpay.route"; +import brlaKycImportRoutes from "./brla-kyc-import.route"; +import brlaRoutes from "./brla.route"; +import moneriumRoutes from "./monerium.route"; +import mykoboRoutes from "./mykobo.route"; +import onboardingRoutes from "./onboarding.route"; + +describe("provider verification routes while acting as another profile", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use("/v1/brla/kyc/import-token", brlaKycImportRoutes); + app.use(express.json()); + app.use("/v1/alfredpay", alfredpayRoutes); + app.use("/v1/brla", brlaRoutes); + app.use("/v1/monerium", moneriumRoutes); + app.use("/v1/mykobo", mykoboRoutes); + app.use("/v1/onboarding", onboardingRoutes); + app.use(errorHandler); + 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`; + }); + + afterAll(() => { + server?.close(); + config.impersonationEnabled = originalImpersonationEnabled; + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + async function impersonationHeaders(): Promise> { + const actor = await createTestUser(); + const target = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + } + + it("rejects KYC/KYB actions for every dashboard provider before controller execution", async () => { + const headers = await impersonationHeaders(); + const countryBody = JSON.stringify({ country: "MX" }); + const requests = [ + { body: countryBody, method: "POST", path: "/alfredpay/createIndividualCustomer" }, + { method: "GET", path: "/alfredpay/getKycRedirectLink?country=MX" }, + { body: countryBody, method: "POST", path: "/alfredpay/kycRedirectOpened" }, + { body: countryBody, method: "POST", path: "/alfredpay/kycRedirectFinished" }, + { body: countryBody, method: "POST", path: "/alfredpay/retryKyc" }, + { body: countryBody, method: "POST", path: "/alfredpay/createBusinessCustomer" }, + { method: "GET", path: "/alfredpay/getKybRedirectLink?country=MX" }, + { body: countryBody, method: "POST", path: "/alfredpay/submitKycInformation" }, + { method: "POST", path: "/alfredpay/submitKycFile" }, + { body: countryBody, method: "POST", path: "/alfredpay/sendKycSubmission" }, + { body: countryBody, method: "POST", path: "/alfredpay/submitKybInformation" }, + { method: "POST", path: "/alfredpay/submitKybFile" }, + { method: "POST", path: "/alfredpay/submitKybRelatedPersonFile" }, + { body: countryBody, method: "POST", path: "/alfredpay/sendKybSubmission" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/createSubaccount" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/getUploadUrls" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/newKyc" }, + { method: "GET", path: "/brla/getSelfieLivenessUrl" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/kyb/new-level-1/web-sdk" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/kyb/documents" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/kyb/ubos" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/kyb/new-level-1/api" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/kyc/record-attempt" }, + { body: JSON.stringify({}), method: "POST", path: "/brla/kyc/import-token" }, + { body: JSON.stringify({}), method: "POST", path: "/monerium/oauth/start" }, + { body: JSON.stringify({}), method: "POST", path: "/monerium/oauth/complete" }, + { body: JSON.stringify({}), method: "POST", path: "/mykobo/profiles" } + ]; + + for (const request of requests) { + const response = await fetch(`${baseUrl}${request.path}`, { body: request.body, headers, method: request.method }); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ error: { code: "IMPERSONATION_NOT_ALLOWED" } }); + } + }); + + it("keeps aggregate KYC/KYB status readable while impersonating", async () => { + const headers = await impersonationHeaders(); + + const response = await fetch(`${baseUrl}/onboarding/status`, { headers }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ entities: [] }); + }); +}); diff --git a/apps/api/src/api/routes/v1/ramp.route.test.ts b/apps/api/src/api/routes/v1/ramp.route.test.ts new file mode 100644 index 000000000..3cc41641a --- /dev/null +++ b/apps/api/src/api/routes/v1/ramp.route.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import express 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 { handler as errorHandler } from "../../middlewares/error"; +import { createSession } from "../../services/impersonation.service"; +import quoteRoutes from "./quote.route"; +import rampRoutes from "./ramp.route"; + +describe("ramp routes under impersonation", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use("/v1/quotes", quoteRoutes); + app.use("/v1/ramp", rampRoutes); + app.use(errorHandler); + 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`; + }); + + afterAll(() => { + server?.close(); + config.impersonationEnabled = originalImpersonationEnabled; + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + async function impersonationHeaders(): Promise> { + const actor = await createTestUser(); + const target = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + } + + it("rejects ramp registration, update, and start while impersonating", async () => { + const headers = await impersonationHeaders(); + + for (const path of ["register", "update", "start"]) { + const response = await fetch(`${baseUrl}/ramp/${path}`, { + body: JSON.stringify({}), + headers, + method: "POST" + }); + + expect(response.status).toBe(403); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + } + }); + + it("still allows quote requests to reach normal validation while impersonating", async () => { + const headers = await impersonationHeaders(); + + const response = await fetch(`${baseUrl}/quotes`, { + body: JSON.stringify({}), + headers, + method: "POST" + }); + + expect(response.status).toBe(400); + }); + + it("still allows an impersonated caller to inspect ramp history", async () => { + const headers = await impersonationHeaders(); + + const response = await fetch(`${baseUrl}/ramp/history`, { headers }); + + expect(response.status).toBe(200); + }); +}); diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index d3a621277..107e8d691 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -1,5 +1,6 @@ import { RequestHandler, Router } from "express"; import * as rampController from "../../controllers/ramp.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { rejectDuringActiveMaintenance } from "../../middlewares/maintenanceGuard"; import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; @@ -37,6 +38,7 @@ router.post( "/register", rejectDuringActiveMaintenance("ramp_register"), requirePartnerOrUserAuth(), + rejectImpersonation, authorizeManagedProfile({ corridor: getManagedProfileQuoteCorridor }), rampController.registerRamp as unknown as RequestHandler ); @@ -70,6 +72,7 @@ router.post( "/update", rejectDuringActiveMaintenance("ramp_update"), optionalPartnerOrUserAuth(), + rejectImpersonation, authorizeManagedProfile({ corridor: getManagedProfileRampCorridor }), rampController.updateRamp as unknown as RequestHandler ); @@ -102,6 +105,7 @@ router.post( "/start", rejectDuringActiveMaintenance("ramp_start"), optionalPartnerOrUserAuth(), + rejectImpersonation, authorizeManagedProfile({ corridor: getManagedProfileRampCorridor }), rampController.startRamp as unknown as RequestHandler ); diff --git a/apps/api/src/api/routes/v1/recipients.route.test.ts b/apps/api/src/api/routes/v1/recipients.route.test.ts index b731e0569..250ed6e07 100644 --- a/apps/api/src/api/routes/v1/recipients.route.test.ts +++ b/apps/api/src/api/routes/v1/recipients.route.test.ts @@ -18,12 +18,11 @@ describe("recipient routes", () => { afterAll(() => server.close()); - it("rejects managed profile selection before recipient authentication", async () => { + it("requires recipient authentication before managed profile selection", async () => { const response = await fetch(baseUrl, { headers: { "X-Managed-Profile-Id": "22222222-2222-4222-8222-222222222222" } }); - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_UNSUPPORTED" } }); + expect(response.status).toBe(401); }); }); diff --git a/apps/api/src/api/routes/v1/recipients.route.ts b/apps/api/src/api/routes/v1/recipients.route.ts index 981d9e5c0..e9fbf8d17 100644 --- a/apps/api/src/api/routes/v1/recipients.route.ts +++ b/apps/api/src/api/routes/v1/recipients.route.ts @@ -6,57 +6,94 @@ import { getRecipientEligibility, listRecipients, previewInvite, - updateRecipient + resolveInvitationAuthorizationTarget, + resolveRecipientAuthorizationTarget, + updateRecipient, + validateCreateInvite } from "../../controllers/recipients.controller"; -import { rejectManagedProfileSelection } from "../../middlewares/managedProfileAuth"; +import { authorizeManagedProfile, rejectManagedProfileSelection } from "../../middlewares/managedProfileAuth"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); -router.use(rejectManagedProfileSelection, requireAuth); - /** * POST /v1/recipients/invite * Create a recipient invite for the authenticated sender; returns the raw link token once. */ -router.post("/invite", createInvite as unknown as (req: Request, res: Response) => void); +router.post( + "/invite", + requireAuth, + validateCreateInvite, + authorizeManagedProfile({ corridor: req => req.body.country.toUpperCase() }), + createInvite as unknown as (req: Request, res: Response) => void +); /** * GET /v1/recipients/invite/:token * Read-only preview (corridor + invitee type) for the confirm-before-accept screen; * runs the acceptance gate checks but consumes nothing. */ -router.get("/invite/:token", previewInvite as unknown as (req: Request<{ token: string }>, res: Response) => void); +router.get( + "/invite/:token", + rejectManagedProfileSelection, + requireAuth, + previewInvite as unknown as (req: Request<{ token: string }>, res: Response) => void +); /** * POST /v1/recipients/invite/:token/accept * Recipient (authenticated) redeems the link token; creates the sender↔recipient relationship. */ -router.post("/invite/:token/accept", acceptInvite as unknown as (req: Request<{ token: string }>, res: Response) => void); +router.post( + "/invite/:token/accept", + rejectManagedProfileSelection, + requireAuth, + acceptInvite as unknown as (req: Request<{ token: string }>, res: Response) => void +); /** * GET /v1/recipients * List the sender's recipients (relationship + onboarding status) and pending invitations. */ -router.get("/", listRecipients as unknown as (req: Request, res: Response) => void); +router.get( + "/", + requireAuth, + authorizeManagedProfile({ enforceCustomerTypePolicy: true }), + listRecipients as unknown as (req: Request, res: Response) => void +); /** * PATCH /v1/recipients/invitations/:id * Archive/unarchive a pending invitation — a sender-side list hide, not a revocation: * the token stays redeemable. */ -router.patch("/invitations/:id", archiveInvitation as unknown as (req: Request<{ id: string }>, res: Response) => void); +router.patch( + "/invitations/:id", + requireAuth, + authorizeManagedProfile({ corridor: resolveInvitationAuthorizationTarget }), + archiveInvitation as unknown as (req: Request<{ id: string }>, res: Response) => void +); /** * PATCH /v1/recipients/:id * Update nickname or relationship status (active | blocked | archived). */ -router.patch("/:id", updateRecipient as unknown as (req: Request<{ id: string }>, res: Response) => void); +router.patch( + "/:id", + requireAuth, + authorizeManagedProfile({ corridor: resolveRecipientAuthorizationTarget }), + updateRecipient as unknown as (req: Request<{ id: string }>, res: Response) => void +); /** * GET /v1/recipients/:id/eligibility * Transfer gate: { canCreateTransfer, blockingReasonCode? }. */ -router.get("/:id/eligibility", getRecipientEligibility as unknown as (req: Request<{ id: string }>, res: Response) => void); +router.get( + "/:id/eligibility", + requireAuth, + authorizeManagedProfile({ corridor: resolveRecipientAuthorizationTarget }), + getRecipientEligibility as unknown as (req: Request<{ id: string }>, res: Response) => void +); export default router; 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..ead2b8bdc --- /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 & { email: string } }> { + 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 || target.kind !== "authenticated" || !target.email) { + throw new ImpersonationTargetError("Target must be an authenticated profile"); + } + 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: target as User & { email: string } }; + }); + + 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?.email || !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 60e5b950f..c85ca01da 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -165,6 +165,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; @@ -291,6 +293,7 @@ export const config: Config = { networkFeeMarginBps: readEvmDestinationNetworkFeeMarginBps() }, flowVariant: readFlowVariant(), + impersonationEnabled: process.env.IMPERSONATION_ENABLED === "true", integrations: { alchemy: { diff --git a/apps/api/src/database/migrations/067-allow-vortex-admin-profile-role.ts b/apps/api/src/database/migrations/067-allow-vortex-admin-profile-role.ts new file mode 100644 index 000000000..b8186fca8 --- /dev/null +++ b/apps/api/src/database/migrations/067-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/068-create-admin-impersonation-sessions.ts b/apps/api/src/database/migrations/068-create-admin-impersonation-sessions.ts new file mode 100644 index 000000000..3323c5364 --- /dev/null +++ b/apps/api/src/database/migrations/068-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/database/migrator.test.ts b/apps/api/src/database/migrator.test.ts index 1df8c4e2d..c70a31260 100644 --- a/apps/api/src/database/migrator.test.ts +++ b/apps/api/src/database/migrator.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { QueryTypes } from "sequelize"; import sequelize from "../config/database"; import { setupTestDatabase } from "../test-utils/db"; -import { getPendingMigrations, revertLastMigration, revertMigration, runMigrations } from "./migrator"; +import { getExecutedMigrations, getPendingMigrations, revertLastMigration, revertMigration, runMigrations } from "./migrator"; // Old-name/new-name pairs of the migrations renumbered to clear the duplicate-055 prefix. // Must stay in sync with MIGRATION_RENAMES in migrator.ts. @@ -125,8 +125,9 @@ describe("migration metadata reconciliation", () => { }); it("reconciles legacy TypeScript metadata before reverting the last migration", async () => { - const jsName = "066-add-kyc-verification-state.js"; - const tsName = "066-add-kyc-verification-state.ts"; + const jsName = (await getExecutedMigrations()).at(-1); + if (!jsName) throw new Error("Expected at least one executed migration"); + const tsName = jsName.replace(/\.js$/, ".ts"); await sequelize.query(`UPDATE "SequelizeMeta" SET name = :tsName WHERE name = :jsName`, { replacements: { jsName, tsName } }); 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 350f9465a..6c97bf074 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"; @@ -52,6 +53,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" }); @@ -107,6 +113,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..8fed4409f 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 broad access to act as any customer, 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/api/src/test-utils/factories.ts b/apps/api/src/test-utils/factories.ts index 00d2f744e..8b61a109f 100644 --- a/apps/api/src/test-utils/factories.ts +++ b/apps/api/src/test-utils/factories.ts @@ -32,12 +32,14 @@ function nextSeq(): number { return ++sequence; } -export async function createTestUser(overrides: Partial<{ id: string; email: string }> = {}): Promise { +export async function createTestUser( + overrides: Partial<{ id: string; email: string }> = {} +): Promise { const seq = nextSeq(); return User.create({ email: overrides.email ?? `test-user-${seq}@example.com`, id: overrides.id ?? crypto.randomUUID() - }); + }) as Promise; } type TestPartnerOverrides = Partial< diff --git a/apps/api/src/tests/recipients.integration.test.ts b/apps/api/src/tests/recipients.integration.test.ts index 807ab4840..670f4c4a0 100644 --- a/apps/api/src/tests/recipients.integration.test.ts +++ b/apps/api/src/tests/recipients.integration.test.ts @@ -1,9 +1,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { EvmToken, FiatToken, RampDirection } from "@vortexfi/shared"; +import { provisionManagedProfile } from "../api/services/managed-profile-provisioning.service"; import { findPartnerWithPricing } from "../api/services/partners/partner-pricing.service"; import { config } from "../config/vars"; -import Notification from "../models/notification.model"; import CustomerEntity from "../models/customerEntity.model"; +import ManagedProfile from "../models/managedProfile.model"; +import ManagedProfileManager from "../models/managedProfileManager.model"; +import Notification from "../models/notification.model"; import Partner from "../models/partner.model"; import PartnerPricingConfig from "../models/partnerPricingConfig.model"; import ProfilePartnerAssignment from "../models/profilePartnerAssignment.model"; @@ -40,6 +43,10 @@ function authHeaders(token: string): Record { return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; } +function managedAuthHeaders(token: string, profileId: string): Record { + return { ...authHeaders(token), "X-Managed-Profile-Id": profileId }; +} + async function createAuthedUser(email: string): Promise<{ user: User; token: string }> { const user = await createTestUser({ email }); return { token: testUserToken(user.id, email), user }; @@ -60,6 +67,27 @@ async function createApprovedSender(email: string): Promise<{ user: User; token: return { entity, token, user }; } +async function createManagedSender(suffix = "") { + const manager = await createAuthedUser(`manager${suffix}@example.com`); + await ManagedProfileManager.create({ allowedCorridors: ["MX"], isActive: true, profileId: manager.user.id }); + const child = await provisionManagedProfile({ + contactEmail: `managed-sender${suffix}@example.com`, + creationSource: "manager", + customerType: "individual", + externalSubjectId: `managed-sender${suffix}`, + managerProfileId: manager.user.id + }); + await ProviderCustomer.create({ + country: "MX", + customerEntityId: child.customerEntityId, + customerType: "individual", + provider: "alfredpay", + rail: "mxn", + status: VerificationStatus.Approved + }); + return { child, manager }; +} + const MX_CORRIDOR = { country: "MX", payoutCurrency: "mxn", rail: "mxn" }; async function createInvite( @@ -160,6 +188,57 @@ describe("POST /v1/recipients/invite", () => { expect(status).toBe(403); expect((body.error as { code: string }).code).toBe("NO_APPROVED_CORRIDOR"); }); + + it("authorizes a managed sender corridor while preserving malformed-input errors", async () => { + const { child, manager } = await createManagedSender(); + const headers = managedAuthHeaders(manager.token, child.profileId); + + const malformed = await api.request("/v1/recipients/invite", { + body: JSON.stringify({ payoutCurrency: "brl", rail: "brl" }), + headers, + method: "POST" + }); + expect(malformed.status).toBe(400); + expect(((await malformed.json()) as { error: { code: string } }).error.code).toBe("INVALID_INVITE_CORRIDOR"); + + const denied = await api.request("/v1/recipients/invite", { + body: JSON.stringify({ country: "BR", payoutCurrency: "brl", rail: "brl" }), + headers, + method: "POST" + }); + expect(denied.status).toBe(403); + + const allowed = await api.request("/v1/recipients/invite", { + body: JSON.stringify(MX_CORRIDOR), + headers, + method: "POST" + }); + expect(allowed.status).toBe(201); + const invitation = await RecipientInvitation.findByPk(((await allowed.json()) as { id: string }).id); + expect(invitation?.createdByProfileId).toBe(child.profileId); + expect(invitation?.senderCustomerEntityId).toBe(child.customerEntityId); + }); + + it("uses the authenticated manager's discount role for managed sender invites", async () => { + const { child, manager } = await createManagedSender(); + await ProfileRole.create({ role: "discount_manager", userId: child.profileId }); + const headers = managedAuthHeaders(manager.token, child.profileId); + + const childRoleOnly = await api.request("/v1/recipients/invite", { + body: JSON.stringify({ ...MX_CORRIDOR, discounts: { buyBps: 10 } }), + headers, + method: "POST" + }); + expect(childRoleOnly.status).toBe(403); + + await ProfileRole.create({ role: "discount_manager", userId: manager.user.id }); + const actorRole = await api.request("/v1/recipients/invite", { + body: JSON.stringify({ ...MX_CORRIDOR, discounts: { buyBps: 10 } }), + headers, + method: "POST" + }); + expect(actorRole.status).toBe(201); + }); }); describe("POST /v1/recipients/invite/:token/accept", () => { @@ -578,6 +657,128 @@ describe("GET /v1/recipients", () => { const afterBody = (await afterBusiness.json()) as { recipients: Array<{ onboardingStatus: string }> }; expect(afterBody.recipients[0].onboardingStatus).toBe("approved"); }); + + it("scopes delegated sender list, archive, relationship updates, and eligibility to the managed child", async () => { + const { child, manager } = await createManagedSender(); + const recipient = await createAuthedUser("recipient@example.com"); + const headers = managedAuthHeaders(manager.token, child.profileId); + const created = await api.request("/v1/recipients/invite", { + body: JSON.stringify(MX_CORRIDOR), + headers, + method: "POST" + }); + const invite = (await created.json()) as { id: string; token: string }; + + const archived = await api.request(`/v1/recipients/invitations/${invite.id}`, { + body: JSON.stringify({ archived: true }), + headers, + method: "PATCH" + }); + expect(archived.status).toBe(200); + await api.request(`/v1/recipients/invitations/${invite.id}`, { + body: JSON.stringify({ archived: false }), + headers, + method: "PATCH" + }); + + const accepted = await acceptInvite(recipient.token, invite.token); + const relationshipId = accepted.body.id as string; + const updated = await api.request(`/v1/recipients/${relationshipId}`, { + body: JSON.stringify({ nickname: "Managed recipient" }), + headers, + method: "PATCH" + }); + expect(updated.status).toBe(200); + + const eligibility = await api.request(`/v1/recipients/${relationshipId}/eligibility`, { headers }); + expect(eligibility.status).toBe(200); + expect(((await eligibility.json()) as { blockingReasonCode: string }).blockingReasonCode).toBe( + "recipient_onboarding_pending" + ); + + const list = await api.request("/v1/recipients", { headers }); + const body = (await list.json()) as { recipients: Array<{ id: string; nickname: string }> }; + expect(body.recipients).toEqual([expect.objectContaining({ id: relationshipId, nickname: "Managed recipient" })]); + }); + + it("revalidates current corridor policy for delegated recipient reads and mutations", async () => { + const { child, manager } = await createManagedSender(); + const recipient = await createAuthedUser("recipient@example.com"); + const headers = managedAuthHeaders(manager.token, child.profileId); + const created = await api.request("/v1/recipients/invite", { + body: JSON.stringify(MX_CORRIDOR), + headers, + method: "POST" + }); + const invite = (await created.json()) as { id: string; token: string }; + const accepted = await acceptInvite(recipient.token, invite.token); + const relationshipId = accepted.body.id as string; + + await ManagedProfileManager.update({ allowedCorridors: [] }, { where: { profileId: manager.user.id } }); + + const list = await api.request("/v1/recipients", { headers }); + expect(list.status).toBe(200); + expect(await list.json()).toMatchObject({ pendingInvitations: [], recipients: [] }); + + const archive = await api.request(`/v1/recipients/invitations/${invite.id}`, { + body: JSON.stringify({ archived: true }), + headers, + method: "PATCH" + }); + const update = await api.request(`/v1/recipients/${relationshipId}`, { + body: JSON.stringify({ nickname: "Denied" }), + headers, + method: "PATCH" + }); + const eligibility = await api.request(`/v1/recipients/${relationshipId}/eligibility`, { headers }); + + expect(archive.status).toBe(403); + expect(update.status).toBe(403); + expect(eligibility.status).toBe(403); + expect((await SenderRecipient.findByPk(relationshipId))?.nickname).toBeNull(); + expect((await RecipientInvitation.findByPk(invite.id))?.archivedAt).toBeNull(); + }); + + it("revalidates customer-type policy and the active manager-child relationship", async () => { + const { child, manager } = await createManagedSender(); + const headers = managedAuthHeaders(manager.token, child.profileId); + + await ManagedProfileManager.update( + { allowedCustomerTypes: ["business"] }, + { where: { profileId: manager.user.id } } + ); + expect((await api.request("/v1/recipients", { headers })).status).toBe(403); + + await ManagedProfileManager.update({ allowedCustomerTypes: null }, { where: { profileId: manager.user.id } }); + await ManagedProfile.update( + { deletedAt: new Date(), status: "deleted" }, + { where: { managerProfileId: manager.user.id, profileId: child.profileId } } + ); + expect((await api.request("/v1/recipients", { headers })).status).toBe(403); + }); + + it("returns recipient 404 before corridor policy for another managed child's target", async () => { + const first = await createManagedSender("-first"); + const second = await createManagedSender("-second"); + const recipient = await createAuthedUser("recipient@example.com"); + const secondHeaders = managedAuthHeaders(second.manager.token, second.child.profileId); + const created = await api.request("/v1/recipients/invite", { + body: JSON.stringify(MX_CORRIDOR), + headers: secondHeaders, + method: "POST" + }); + const invite = (await created.json()) as { token: string }; + const accepted = await acceptInvite(recipient.token, invite.token); + + await ManagedProfileManager.update({ allowedCorridors: [] }, { where: { profileId: first.manager.user.id } }); + const response = await api.request(`/v1/recipients/${accepted.body.id}`, { + body: JSON.stringify({ nickname: "Not yours" }), + headers: managedAuthHeaders(first.manager.token, first.child.profileId), + method: "PATCH" + }); + + expect(response.status).toBe(404); + }); }); describe("PATCH /v1/recipients/invitations/:id", () => { @@ -817,6 +1018,25 @@ describe("GET /v1/recipients/invite/:token (preview)", () => { expect(stored?.acceptedByProfileId).toBeNull(); }); + it("rejects managed selection for preview and acceptance before using the invitee identity", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + const headers = managedAuthHeaders(recipient.token, crypto.randomUUID()); + + const preview = await api.request(`/v1/recipients/invite/${invite.body.token}`, { headers }); + const acceptance = await api.request(`/v1/recipients/invite/${invite.body.token}/accept`, { + headers, + method: "POST" + }); + + expect(preview.status).toBe(400); + expect(((await preview.json()) as { error: { code: string } }).error.code).toBe("MANAGED_PROFILE_UNSUPPORTED"); + expect(acceptance.status).toBe(400); + expect(((await acceptance.json()) as { error: { code: string } }).error.code).toBe("MANAGED_PROFILE_UNSUPPORTED"); + expect((await RecipientInvitation.findByPk(invite.body.id as string))?.acceptedByProfileId).toBeNull(); + }); + it("applies the acceptance gates: unknown, expired, foreign-accepted, email-bound, own invite", async () => { const sender = await createApprovedSender("sender@example.com"); const recipient = await createAuthedUser("recipient@example.com"); diff --git a/apps/dashboard/e2e/managed-profiles.spec.ts b/apps/dashboard/e2e/managed-profiles.spec.ts new file mode 100644 index 000000000..838f78910 --- /dev/null +++ b/apps/dashboard/e2e/managed-profiles.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "@playwright/test"; +import { E2E_MANAGED_PROFILE_ID, mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +const CHILD_EMAIL = "managed-child-with-a-long-identifier@example.test"; +const CHILD_EXTERNAL_ID = `customer-${"long-identifier-".repeat(8)}`; +const CHILD = { + contactEmail: CHILD_EMAIL, + customerType: "individual" as const, + externalSubjectId: CHILD_EXTERNAL_ID, + profileId: E2E_MANAGED_PROFILE_ID +}; + +test("ordinary users cannot navigate to managed profiles", async ({ page }) => { + const backend = await mockBackend(page); + await seedSession(page); + await page.goto("/overview"); + + await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Managed profiles" })).toHaveCount(0); + + await page.goto("/managed-profiles"); + await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("a manager selects and stops acting for a managed profile", async ({ page }) => { + const backend = await mockBackend(page, { managedProfiles: [CHILD], onboardingState: "started", roles: ["vortex_admin"] }); + await seedSession(page); + await page.goto("/managed-profiles"); + + await expect(page.getByRole("heading", { name: "Managed profiles" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Managed profiles" })).toBeVisible(); + await page.getByRole("button", { name: `Actions for ${CHILD_EMAIL}` }).click(); + await page.getByRole("menuitem", { name: "Act for this profile" }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: `Act for ${CHILD_EMAIL}?` })).toBeVisible(); + await dialog.getByRole("button", { name: "Act for this profile" }).click(); + + await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByText(`Acting for ${CHILD_EMAIL}`)).toBeVisible(); + await expect(page.getByText("KYC/KYB is read-only while acting for another profile.")).toBeVisible(); + await expect(page.getByRole("button", { name: "KYC is read-only while acting" })).toBeDisabled(); + await page.goto("/overview?onboarding=MX"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.reload(); + await expect(page.getByText(`Acting for ${CHILD_EMAIL}`)).toBeVisible(); + + await expect(page.getByRole("link", { name: "API keys" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Settings" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Admin" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Managed profiles" })).toHaveCount(0); + + await page.waitForLoadState("networkidle"); + const apiCredentialRequestCount = backend.apiRequests.filter(request => request.path === "/v1/api-credentials").length; + await page.goto("/api-keys"); + await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByRole("heading", { name: "API keys" })).toHaveCount(0); + + const delegatedStatuses = backend.apiRequests.filter(request => request.path === "/v1/onboarding/status"); + expect(delegatedStatuses.some(request => request.managedProfileId === E2E_MANAGED_PROFILE_ID)).toBe(true); + const lifecycleRequests = backend.apiRequests.filter(request => request.path === "/v1/managed-profiles"); + expect(lifecycleRequests.length).toBeGreaterThan(0); + expect(lifecycleRequests.every(request => request.managedProfileId === undefined)).toBe(true); + expect(backend.apiRequests.filter(request => request.path === "/v1/api-credentials")).toHaveLength(apiCredentialRequestCount); + + await page.getByRole("button", { name: "Stop acting" }).click(); + await expect(page).toHaveURL(/\/managed-profiles$/); + await expect(page.getByRole("heading", { name: "Managed profiles" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("admin impersonation keeps verification status visible but blocks onboarding deep links", async ({ page }) => { + await mockBackend(page, { onboardingState: "started" }); + await seedSession(page); + await page.addInitScript(() => { + localStorage.setItem( + "vortex_dashboard_impersonation_session", + JSON.stringify({ + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "impersonation-e2e-1", + targetEmail: "target@example.test", + targetProfileId: "target-e2e-1", + token: "vtx_imp_e2e-token" + }) + ); + }); + + await page.goto("/overview?onboarding=MX"); + + await expect(page.getByText("You are acting as")).toBeVisible(); + await expect(page.getByText("KYC/KYB is read-only while acting for another profile.")).toBeVisible(); + await expect(page.getByText("Started", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "KYC is read-only while acting" })).toBeDisabled(); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("long managed identifiers and the acting banner fit a mobile viewport", async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + await mockBackend(page, { managedProfiles: [CHILD] }); + await seedSession(page); + await page.goto("/managed-profiles"); + + const action = page.getByRole("button", { name: `Actions for ${CHILD_EMAIL}` }); + await expect(action).toBeVisible(); + await action.click(); + await page.getByRole("menuitem", { name: "Act for this profile" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Act for this profile" }).click(); + await expect(page.getByText(`Acting for ${CHILD_EMAIL}`)).toBeVisible(); + + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); + await expect(page.getByRole("button", { name: "Stop acting" })).toBeVisible(); +}); diff --git a/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts b/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts index b09e65545..40402d7fb 100644 --- a/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts +++ b/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { mockBackend } from "./support/mockBackend"; +import { E2E_MANAGED_PROFILE_ID, mockBackend } from "./support/mockBackend"; import { E2E_USER_EMAIL, E2E_USER_ID, SESSION_KEYS, seedSession } from "./support/session"; // EU onboarding (KYC and KYB) is temporarily disabled: the corridor card must not offer any @@ -51,6 +51,53 @@ test("Monerium callback refreshes an expired dashboard session, then lands on th expect(backend.auth.refreshes).toBe(1); }); +test("Monerium callback does not complete OAuth while impersonating", async ({ page }) => { + const backend = await mockBackend(page, { moneriumKyc: true }); + await seedSession(page); + await page.addInitScript(() => { + localStorage.setItem( + "vortex_dashboard_impersonation_session", + JSON.stringify({ + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "impersonation-e2e-1", + targetEmail: "target@example.test", + targetProfileId: "target-e2e-1", + token: "vtx_imp_e2e-token" + }) + ); + }); + + await page.goto("/monerium/callback?code=e2e-code&state=e2e-state"); + + await expect(page).toHaveURL(/\/overview$/); + expect(backend.apiRequests.filter(request => request.path === "/v1/monerium/oauth/complete")).toEqual([]); +}); + +test("Monerium callback does not complete OAuth while acting for a managed child", async ({ page }) => { + const backend = await mockBackend(page, { moneriumKyc: true }); + await seedSession(page); + await page.addInitScript( + ({ managerProfileId, targetProfileId }) => { + localStorage.setItem( + "vortex_dashboard_managed_profile_selection", + JSON.stringify({ + customerType: "individual", + externalSubjectId: "managed-child-e2e", + managerProfileId, + targetEmail: "managed-child@example.test", + targetProfileId + }) + ); + }, + { managerProfileId: E2E_USER_ID, targetProfileId: E2E_MANAGED_PROFILE_ID } + ); + + await page.goto("/monerium/callback?code=e2e-code&state=e2e-state"); + + await expect(page).toHaveURL(/\/overview$/); + expect(backend.apiRequests.filter(request => request.path === "/v1/monerium/oauth/complete")).toEqual([]); +}); + test("in-review Monerium onboarding requiring reauthentication is disabled instead of actionable", async ({ page }) => { const backend = await mockBackend(page, { moneriumKyc: true }); backend.monerium.completed = true; diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 7163f5ddb..21280da49 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -7,6 +7,7 @@ export const E2E_RAMP_ID = "ramp-e2e-1"; export const E2E_QUOTE_ID = "quote-e2e-1"; export const E2E_FIAT_ACCOUNT_ID = "fiat-account-e2e-mx"; export const E2E_FIAT_ACCOUNT_ID_2 = "fiat-account-e2e-mx-2"; +export const E2E_MANAGED_PROFILE_ID = "managed-profile-e2e-child-1"; export const MX_USDC_RATE = 18.5; const POLYGON_USDT = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; @@ -275,6 +276,13 @@ interface MockBackendOptions { pendingInvitations?: Array>; // Capability roles returned on GET /v1/onboarding/status (default: none). roles?: string[]; + // Enables manager lifecycle access. The default 403 mirrors an ordinary dashboard user. + managedProfiles?: Array<{ + contactEmail: string | null; + customerType: "business" | "individual"; + externalSubjectId: string; + profileId: string; + }>; // Response for POST /v1/recipients/invite/:token/accept (default: an accepted MX individual invite). acceptInvite?: { status: number; body: Record }; // Response for GET /v1/recipients/invite/:token (default: a pending MX individual invite). @@ -389,6 +397,7 @@ function answerRpc(chainIdHex: string) { * changed default RPC URL fails the suite instead of silently reaching the network. */ export async function mockBackend(page: Page, options: MockBackendOptions = {}) { + const apiRequests: Array<{ managedProfileId: string | undefined; method: string; path: string }> = []; const apiCredentialRequests: Array<{ body: Record | null; method: string; path: string }> = []; const limitsRequests: Array> = []; const requestOtpRequests: Array> = []; @@ -458,6 +467,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) const url = new URL(request.url()); const path = url.pathname; const method = request.method(); + apiRequests.push({ managedProfileId: request.headers()["x-managed-profile-id"], method, path }); const fulfillJson = (body: unknown, code = 200) => route.fulfill({ json: body as object, status: code }); @@ -493,6 +503,21 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } + if (path === "/v1/managed-profiles" && method === "GET") { + if (!options.managedProfiles) { + await fulfillJson({ code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Managed profile access denied" }, 403); + return; + } + const limit = Number(url.searchParams.get("limit") ?? 20); + const offset = Number(url.searchParams.get("offset") ?? 0); + await fulfillJson({ + managedProfiles: options.managedProfiles.slice(offset, offset + limit), + manager: { allowedCorridors: ["MX"], allowedCustomerTypes: null, profileId: E2E_USER_ID }, + pagination: { limit, offset, total: options.managedProfiles.length } + }); + return; + } + if (path === "/v1/onboarding/active-entity" && method === "PUT") { if (options.selectActiveEntityError) { await fulfillJson(options.selectActiveEntityError.body, options.selectActiveEntityError.status); @@ -1132,6 +1157,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return { acceptInviteRequests, apiCredentialRequests, + apiRequests, archiveInvitationRequests, auth, avenia, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index bddfdfeed..6f30f9383 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -47,7 +47,7 @@ "build": "vite build", "dev": "vite dev --port 5174 --host", "preview": "vite preview --port 5174", - "test": "bun test src", + "test": "bun test src --max-concurrency 1", "test:e2e": "playwright test", "typecheck": "tsc --noEmit", "verify": "biome check --no-errors-on-unmatched" diff --git a/apps/dashboard/src/components/admin/AdminAccountsTable.tsx b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx new file mode 100644 index 000000000..b35a5e08b --- /dev/null +++ b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx @@ -0,0 +1,111 @@ +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 { type AdminImpersonationTarget, getAdminAccountLabel, toAdminImpersonationTarget } from "./admin-account-ui"; +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(null); + + return ( + <> + + + + Account + Entities + Verification + Pricing partner + Created + Action + + + + {accounts.map(account => { + const impersonationTarget = toAdminImpersonationTarget(account); + return ( + + +
+
+ + {getAdminAccountLabel(account)} + + {account.kind === "managed" && Managed} +
+ {account.managedProfile && ( + + Managed by {account.managedProfile.manager.email ?? account.managedProfile.manager.profileId} + + )} +
+
+ +
+ {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..4469591d9 --- /dev/null +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -0,0 +1,104 @@ +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 { useAuthStore } from "@/stores/auth.store"; +import { enterImpersonation, exitImpersonation } from "@/stores/impersonation.store"; +import { selectManagedProfile } from "@/stores/managed-profile.store"; +import { type AdminImpersonationTarget, canSelectManagedProfileDirectly } from "./admin-account-ui"; + +/** Confirms an impersonation or a direct managed-profile selection. */ +export function ImpersonateDialog({ + onOpenChange, + target +}: { + onOpenChange: (open: boolean) => void; + target: AdminImpersonationTarget | null; +}) { + const navigate = useNavigate(); + const startImpersonation = useStartImpersonation(); + const authenticatedProfileId = useAuthStore(state => state.user?.userId); + + function handleOpenChange(open: boolean) { + onOpenChange(open); + if (!open) { + startImpersonation.reset(); + } + } + + function onConfirm() { + if (!target) return; + if (target.managedProfile && canSelectManagedProfileDirectly(target, authenticatedProfileId)) { + if (!selectManagedProfile(target.managedProfile)) { + toast.error("Finish the current transfer step before changing identity"); + return; + } + handleOpenChange(false); + navigate({ to: "/overview" }); + 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 => { + const entered = enterImpersonation({ + expiresAt: response.expiresAt, + sessionId: response.sessionId, + targetEmail: response.target.email, + targetProfileId: response.target.id, + token: response.token + }); + if (!entered) { + toast.error("Finish the current transfer step before changing identity"); + return; + } + if (target.managedProfile && !selectManagedProfile(target.managedProfile)) { + exitImpersonation(); + toast.error("Finish the current transfer step before changing identity"); + return; + } + handleOpenChange(false); + navigate({ to: "/overview" }); + } + } + ); + } + + if (!target) return null; + const selectsDirectly = canSelectManagedProfileDirectly(target, authenticatedProfileId); + + return ( + + + + {target.managedProfile ? `Act as ${target.label}?` : `Log in as ${target.label}?`} + + {selectsDirectly + ? "You'll act for this managed profile using your current manager session. This is not an impersonation session." + : `${ + target.managedProfile + ? `You'll impersonate ${target.email}, this profile's manager, and act for the managed profile. ` + : "You'll act as this customer until the session expires in 30 minutes. " + }This is logged against your account, and money movement is disabled.`} + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/admin/admin-account-ui.test.ts b/apps/dashboard/src/components/admin/admin-account-ui.test.ts new file mode 100644 index 000000000..8b1c60f7d --- /dev/null +++ b/apps/dashboard/src/components/admin/admin-account-ui.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { AdminAccountIdentity } from "@/services/api/admin-console.service"; +import { canSelectManagedProfileDirectly, getAdminAccountLabel, toAdminImpersonationTarget } from "./admin-account-ui"; + +const managedAccount: AdminAccountIdentity = { + email: null, + id: "child-profile-id", + kind: "managed", + managedProfile: { + contactEmail: "child@example.com", + customerType: "business", + externalSubjectId: "customer-42", + manager: { email: "manager@example.com", isActive: true, profileId: "manager-profile-id" }, + status: "active" + } +}; + +describe("admin account identity", () => { + it("labels a managed child by contact email", () => { + assert.equal(getAdminAccountLabel(managedAccount), "child@example.com"); + }); + + it("impersonates the manager and selects the managed child", () => { + assert.deepEqual(toAdminImpersonationTarget(managedAccount), { + email: "manager@example.com", + id: "manager-profile-id", + label: "child@example.com", + managedProfile: { + customerType: "business", + externalSubjectId: "customer-42", + targetEmail: "child@example.com", + targetProfileId: "child-profile-id" + } + }); + }); + + it("does not offer a composed session through an inactive manager", () => { + assert.equal( + toAdminImpersonationTarget({ + ...managedAccount, + managedProfile: { + ...managedAccount.managedProfile!, + manager: { ...managedAccount.managedProfile!.manager, isActive: false } + } + }), + null + ); + }); + + it("selects directly when the operator is already the managed profile's manager", () => { + const target = toAdminImpersonationTarget(managedAccount); + assert.ok(target); + assert.equal(canSelectManagedProfileDirectly(target, "manager-profile-id"), true); + assert.equal(canSelectManagedProfileDirectly(target, "another-profile-id"), false); + }); +}); diff --git a/apps/dashboard/src/components/admin/admin-account-ui.ts b/apps/dashboard/src/components/admin/admin-account-ui.ts new file mode 100644 index 000000000..763134294 --- /dev/null +++ b/apps/dashboard/src/components/admin/admin-account-ui.ts @@ -0,0 +1,44 @@ +import type { AdminAccountIdentity } from "@/services/api/admin-console.service"; +import type { ManagedProfileSelection } from "@/services/auth"; + +export interface AdminImpersonationTarget { + email: string; + id: string; + label: string; + managedProfile?: Omit; +} + +export function getAdminAccountLabel(account: AdminAccountIdentity): string { + return account.email ?? account.managedProfile?.contactEmail ?? account.managedProfile?.externalSubjectId ?? account.id; +} + +export function toAdminImpersonationTarget(account: AdminAccountIdentity): AdminImpersonationTarget | null { + const label = getAdminAccountLabel(account); + if (account.kind === "authenticated") { + return account.email ? { email: account.email, id: account.id, label } : null; + } + + const managed = account.managedProfile; + if (!managed || managed.status !== "active" || !managed.customerType || !managed.manager.isActive || !managed.manager.email) { + return null; + } + + return { + email: managed.manager.email, + id: managed.manager.profileId, + label, + managedProfile: { + customerType: managed.customerType, + externalSubjectId: managed.externalSubjectId, + targetEmail: managed.contactEmail ?? managed.externalSubjectId, + targetProfileId: account.id + } + }; +} + +export function canSelectManagedProfileDirectly( + target: AdminImpersonationTarget, + authenticatedProfileId: string | undefined +): boolean { + return !!target.managedProfile && target.id === authenticatedProfileId; +} diff --git a/apps/dashboard/src/components/layout/AccountSwitcher.tsx b/apps/dashboard/src/components/layout/AccountSwitcher.tsx index f784fbb3e..5d35a9e69 100644 --- a/apps/dashboard/src/components/layout/AccountSwitcher.tsx +++ b/apps/dashboard/src/components/layout/AccountSwitcher.tsx @@ -10,13 +10,13 @@ export function AccountSwitcher() { } return ( -
+
{account.type === "company" ? ( ) : ( )} - {account.name} + {account.name}
); } diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index d6c54e869..95964ab2d 100644 --- a/apps/dashboard/src/components/layout/AppSidebar.tsx +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -1,5 +1,17 @@ import { Link, useRouterState } from "@tanstack/react-router"; -import { ArrowLeftRight, Calculator, Gauge, KeyRound, Send, Settings, ShieldCheck, Users } from "lucide-react"; +import { + ArrowLeftRight, + Calculator, + Gauge, + KeyRound, + RefreshCw, + Send, + Settings, + ShieldCheck, + UserCog, + Users, + UsersRound +} from "lucide-react"; import { Sidebar, SidebarContent, @@ -11,6 +23,10 @@ import { SidebarMenuItem, SidebarRail } from "@/components/ui/sidebar"; +import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; +import { isManagedProfilesAccessDenied, useManagedProfiles } from "@/hooks/useManagedProfiles"; +import { useImpersonationSession } from "@/stores/impersonation.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { VortexLogo } from "./VortexLogo"; const NAV_ITEMS = [ @@ -24,8 +40,28 @@ const NAV_ITEMS = [ { icon: Settings, label: "Settings", to: "/settings" } ] as const; +const ADMIN_NAV_ITEM = { icon: UserCog, label: "Admin", to: "/admin" } as const; +const MANAGED_PROFILES_NAV_ITEM = { icon: UsersRound, label: "Managed profiles", to: "/managed-profiles" } as const; +const CHILD_NAV_ITEMS = NAV_ITEMS.filter(item => item.to !== "/api-keys" && item.to !== "/settings"); + export function AppSidebar() { const pathname = useRouterState({ select: state => state.location.pathname }); + const { data: onboardingStatus } = useOnboardingStatusQuery(); + const managedProfile = useManagedProfileSelection(); + const managedProfiles = useManagedProfiles({ limit: 1, offset: 0 }, !managedProfile); + 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 isActingForChild = !!managedProfile; + const isManager = !!managedProfiles.data?.manager; + const managerCheckFailed = managedProfiles.isError && !isManagedProfilesAccessDenied(managedProfiles.error); + const navItems = isActingForChild + ? CHILD_NAV_ITEMS + : [ + ...NAV_ITEMS, + ...(isManager ? [MANAGED_PROFILES_NAV_ITEM] : []), + ...(isAdmin && !isImpersonating ? [ADMIN_NAV_ITEM] : []) + ]; return ( @@ -38,7 +74,7 @@ export function AppSidebar() { - {NAV_ITEMS.map(item => ( + {navItems.map(item => ( @@ -48,6 +84,19 @@ export function AppSidebar() { ))} + {managerCheckFailed && !isActingForChild && ( + + managedProfiles.refetch()} + tooltip="Retry managed profile access check" + type="button" + > + + Retry profile access + + + )} diff --git a/apps/dashboard/src/components/layout/ConnectWalletButton.tsx b/apps/dashboard/src/components/layout/ConnectWalletButton.tsx index 81fc970dd..f139ed139 100644 --- a/apps/dashboard/src/components/layout/ConnectWalletButton.tsx +++ b/apps/dashboard/src/components/layout/ConnectWalletButton.tsx @@ -15,7 +15,7 @@ export function ConnectWalletButton() { return ( ); } @@ -25,7 +25,8 @@ export function ConnectWalletButton() { // switchNetwork(caipNetwork) would be a no-op — let the user pick a supported one. return ( ); } diff --git a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx new file mode 100644 index 000000000..48443a84c --- /dev/null +++ b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx @@ -0,0 +1,59 @@ +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() { + if (!exitImpersonation()) return; + navigate({ to: "/admin" }); + } + + const remainingMs = new Date(session.expiresAt).getTime() - now; + + return ( +
+
+

+ You are acting as {session.targetEmail} + <> · {formatRemaining(remainingMs)} remaining +

+

+ Money movement is disabled. You can create quotes and inspect ramps, but cannot register, update, or start one. +

+
+ +
+ ); +} diff --git a/apps/dashboard/src/components/layout/ManagedProfileBanner.tsx b/apps/dashboard/src/components/layout/ManagedProfileBanner.tsx new file mode 100644 index 000000000..c5f1e9670 --- /dev/null +++ b/apps/dashboard/src/components/layout/ManagedProfileBanner.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from "@tanstack/react-router"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { clearManagedProfile, useManagedProfileSelection } from "@/stores/managed-profile.store"; + +const BLOCKED_MESSAGE = "Finish or cancel the current transfer signing step before changing profiles."; + +export function ManagedProfileBanner() { + const selection = useManagedProfileSelection(); + const navigate = useNavigate(); + + if (!selection) return null; + const label = selection.targetEmail || selection.externalSubjectId; + + function handleStop() { + try { + const stopped = clearManagedProfile(); + if (stopped === false) { + toast.error("Profile change blocked", { description: BLOCKED_MESSAGE }); + return; + } + navigate({ to: "/managed-profiles" }); + } catch { + toast.error("Profile change blocked", { description: BLOCKED_MESSAGE }); + } + } + + return ( +
+ + Acting for {label} + + +
+ ); +} diff --git a/apps/dashboard/src/components/layout/Topbar.tsx b/apps/dashboard/src/components/layout/Topbar.tsx index 910a8ce8d..34218bbf9 100644 --- a/apps/dashboard/src/components/layout/Topbar.tsx +++ b/apps/dashboard/src/components/layout/Topbar.tsx @@ -1,19 +1,21 @@ import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { AccountSwitcher } from "./AccountSwitcher"; import { ConnectWalletButton } from "./ConnectWalletButton"; import { NotificationsBell } from "./NotificationsBell"; import { UserMenu } from "./UserMenu"; export function Topbar() { + const managedProfile = useManagedProfileSelection(); return ( -
+
-
+
- + {!managedProfile && }
diff --git a/apps/dashboard/src/components/managed-profiles/ActForManagedProfileDialog.tsx b/apps/dashboard/src/components/managed-profiles/ActForManagedProfileDialog.tsx new file mode 100644 index 000000000..918f73954 --- /dev/null +++ b/apps/dashboard/src/components/managed-profiles/ActForManagedProfileDialog.tsx @@ -0,0 +1,60 @@ +import { useNavigate } from "@tanstack/react-router"; +import { UserRoundCheck } 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 type { ManagedProfile } from "@/services/api/managed-profiles.service"; +import { selectManagedProfile } from "@/stores/managed-profile.store"; +import { toManagedProfileSelection } from "./managed-profile-ui"; + +const BLOCKED_MESSAGE = "Finish or cancel the current transfer signing step before changing profiles."; + +export function ActForManagedProfileDialog({ + onOpenChange, + profile +}: { + onOpenChange: (open: boolean) => void; + profile: ManagedProfile | null; +}) { + const navigate = useNavigate(); + + function onConfirm() { + if (!profile) return; + try { + const selected = selectManagedProfile(toManagedProfileSelection(profile)); + if (selected === false) { + toast.error("Profile change blocked", { description: BLOCKED_MESSAGE }); + return; + } + onOpenChange(false); + navigate({ to: "/overview" }); + } catch { + toast.error("Profile change blocked", { description: BLOCKED_MESSAGE }); + } + } + + if (!profile) return null; + const label = profile.contactEmail ?? profile.externalSubjectId; + + return ( + + + + Act for {label}? + + The dashboard will show this profile's supported data and actions until you stop acting for it. + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx b/apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx new file mode 100644 index 000000000..755690d02 --- /dev/null +++ b/apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx @@ -0,0 +1,109 @@ +import { MoreHorizontal, UsersRound } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { CORRIDORS } from "@/domain/corridors"; +import type { CorridorId } from "@/domain/types"; +import type { ManagedProfile } from "@/services/api/managed-profiles.service"; +import { ActForManagedProfileDialog } from "./ActForManagedProfileDialog"; + +function Actions({ onAct, profile }: { onAct: (profile: ManagedProfile) => void; profile: ManagedProfile }) { + return ( + + + + + + onAct(profile)}>Act for this profile + + + ); +} + +function CorridorBadges({ corridors }: { corridors: CorridorId[] }) { + return ( +
+ {corridors.map(id => ( + + {CORRIDORS[id].flag} {CORRIDORS[id].name} + + ))} +
+ ); +} + +export function ManagedProfilesList({ profiles, corridors }: { profiles: ManagedProfile[]; corridors: CorridorId[] }) { + const [target, setTarget] = useState(null); + + if (profiles.length === 0) { + return ( +
+ + + +

No managed profiles

+

Active profiles will appear here when they are assigned to you.

+
+ ); + } + + return ( + <> +
+ + + + Contact + External subject ID + Customer type + Authorized corridors + Actions + + + + {profiles.map(profile => ( + + {profile.contactEmail ?? "Not provided"} + {profile.externalSubjectId} + {profile.customerType} + + + + + + + + ))} + +
+
+
+ {profiles.map(profile => ( + + +
+
+

{profile.contactEmail ?? "No contact email"}

+

{profile.externalSubjectId}

+
+ +
+
+ + {profile.customerType} + + +
+
+
+ ))} +
+ !open && setTarget(null)} profile={target} /> + + ); +} diff --git a/apps/dashboard/src/components/managed-profiles/managed-profile-ui.test.ts b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.test.ts new file mode 100644 index 000000000..86440d7ad --- /dev/null +++ b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isChildModePathForbidden, toManagedProfileSelection } from "./managed-profile-ui"; + +describe("managed profile UI", () => { + it("builds the persisted child selection from a profile", () => { + assert.deepEqual( + toManagedProfileSelection({ + contactEmail: "child@example.com", + customerType: "business", + externalSubjectId: "customer-42", + profileId: "profile-42" + }), + { + customerType: "business", + externalSubjectId: "customer-42", + targetEmail: "child@example.com", + targetProfileId: "profile-42" + } + ); + }); + + it("uses the external subject when contact email is unavailable", () => { + assert.equal( + toManagedProfileSelection({ + contactEmail: null, + customerType: "individual", + externalSubjectId: "customer-7", + profileId: "profile-7" + }).targetEmail, + "customer-7" + ); + }); + + it("blocks only manager-scoped child routes", () => { + assert.equal(isChildModePathForbidden("/settings"), true); + assert.equal(isChildModePathForbidden("/admin/account-id"), true); + assert.equal(isChildModePathForbidden("/managed-profiles"), true); + assert.equal(isChildModePathForbidden("/administration-guide"), false); + assert.equal(isChildModePathForbidden("/transactions"), false); + }); +}); diff --git a/apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts new file mode 100644 index 000000000..9f5d480a6 --- /dev/null +++ b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts @@ -0,0 +1,17 @@ +import type { ManagedProfile } from "@/services/api/managed-profiles.service"; +import type { ManagedProfileSelection } from "@/services/auth"; + +export const CHILD_FORBIDDEN_PATHS = ["/api-keys", "/settings", "/admin", "/managed-profiles"] as const; + +export function isChildModePathForbidden(pathname: string): boolean { + return CHILD_FORBIDDEN_PATHS.some(path => pathname === path || pathname.startsWith(`${path}/`)); +} + +export function toManagedProfileSelection(profile: ManagedProfile): Omit { + return { + customerType: profile.customerType, + externalSubjectId: profile.externalSubjectId, + targetEmail: profile.contactEmail ?? profile.externalSubjectId, + targetProfileId: profile.profileId + }; +} diff --git a/apps/dashboard/src/components/onboarding/CorridorCard.tsx b/apps/dashboard/src/components/onboarding/CorridorCard.tsx index e1dab7fdc..d18d2c817 100644 --- a/apps/dashboard/src/components/onboarding/CorridorCard.tsx +++ b/apps/dashboard/src/components/onboarding/CorridorCard.tsx @@ -20,6 +20,7 @@ interface CorridorCardProps { account: SenderAccount; corridor: Corridor; onStart: () => void; + verificationReadOnly?: boolean; } const ROUTE_HINT: Record = { @@ -38,7 +39,7 @@ const BAR_TONE: Record = { started: "bg-primary" }; -export function CorridorCard({ account, corridor, onStart }: CorridorCardProps) { +export function CorridorCard({ account, corridor, onStart, verificationReadOnly = false }: CorridorCardProps) { const kind = onboardingKindFor(corridor, account.type); const available = isOnboardingAvailable(corridor, kind); const onboarding = account.onboardings[corridor.id]; @@ -114,6 +115,10 @@ export function CorridorCard({ account, corridor, onStart }: CorridorCardProps) fiatAccounts.refetch(); }} /> + ) : verificationReadOnly && actionable ? ( + ) : disabled ? ( +
+ ); + } + + const data = account.data; + const target = toAdminImpersonationTarget(data); + + return ( + + +
+
+

{getAdminAccountLabel(data)}

+ {data.kind === "managed" && Managed} +
+ {data.managedProfile && ( +

+ Managed by {data.managedProfile.manager.email ?? data.managedProfile.manager.profileId} +

+ )} +

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..325dcb6a2 --- /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 or external ID…" + 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..d010a17b0 --- /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/routes/_app/api-keys.tsx b/apps/dashboard/src/routes/_app/api-keys.tsx index 04e608ebe..568d95890 100644 --- a/apps/dashboard/src/routes/_app/api-keys.tsx +++ b/apps/dashboard/src/routes/_app/api-keys.tsx @@ -1,9 +1,13 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; import { ApiCredentialsTable } from "@/components/api-keys/ApiCredentialsTable"; import { CreateApiCredentialDialog } from "@/components/api-keys/CreateApiCredentialDialog"; import { Stagger, StaggerItem } from "@/components/motion/Stagger"; +import { AuthService } from "@/services/auth"; export const Route = createFileRoute("/_app/api-keys")({ + beforeLoad: () => { + if (AuthService.getManagedProfileSelection()) throw redirect({ to: "/overview" }); + }, component: ApiKeysPage }); diff --git a/apps/dashboard/src/routes/_app/managed-profiles.tsx b/apps/dashboard/src/routes/_app/managed-profiles.tsx new file mode 100644 index 000000000..a1a112abb --- /dev/null +++ b/apps/dashboard/src/routes/_app/managed-profiles.tsx @@ -0,0 +1,80 @@ +import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { ManagedProfilesList } from "@/components/managed-profiles/ManagedProfilesList"; +import { Stagger, StaggerItem } from "@/components/motion/Stagger"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isManagedProfilesAccessDenied, useManagedProfiles } from "@/hooks/useManagedProfiles"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; + +const PAGE_LIMIT = 20; + +export const Route = createFileRoute("/_app/managed-profiles")({ + component: ManagedProfilesPage +}); + +function ManagedProfilesPage() { + const [offset, setOffset] = useState(0); + const selection = useManagedProfileSelection(); + const profiles = useManagedProfiles({ limit: PAGE_LIMIT, offset }); + + if (selection) return ; + if (profiles.isLoading) return ; + if (profiles.isError && isManagedProfilesAccessDenied(profiles.error)) return ; + + return ( + + +

Managed profiles

+

Choose a profile to act for using the actions menu.

+
+ + + + Profiles + + + {profiles.isError || !profiles.data ? ( +
+

+ Could not load managed profiles. Check your connection and try again. +

+ +
+ ) : ( + <> + +
+ + +
+ + )} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/routes/_app/overview.tsx b/apps/dashboard/src/routes/_app/overview.tsx index ce7c78b86..9fba66394 100644 --- a/apps/dashboard/src/routes/_app/overview.tsx +++ b/apps/dashboard/src/routes/_app/overview.tsx @@ -12,6 +12,8 @@ import { CORRIDORS, isCorridorAvailableForAccountType } from "@/domain/corridors import { type CorridorId, corridorIdSchema } from "@/domain/types"; import { useActiveAccount } from "@/hooks/useActiveAccount"; import { spring } from "@/lib/motion"; +import { useImpersonationSession } from "@/stores/impersonation.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; export const Route = createFileRoute("/_app/overview")({ component: OverviewPage, @@ -25,6 +27,8 @@ export const Route = createFileRoute("/_app/overview")({ function OverviewPage() { const account = useActiveAccount(); + const impersonation = useImpersonationSession(); + const managedProfile = useManagedProfileSelection(); const navigate = useNavigate(); const search = Route.useSearch(); const [activeCorridor, setActiveCorridor] = useState(null); @@ -43,6 +47,7 @@ function OverviewPage() { ); const approved = corridors.filter(corridor => account.onboardings[corridor.id]?.status === "approved").length; const openCorridor = activeCorridor ?? search.onboarding ?? null; + const verificationReadOnly = impersonation !== null || managedProfile !== null; function addCorridor() { if (!selectedToAdd) { @@ -78,6 +83,17 @@ function OverviewPage() { + {verificationReadOnly && ( + +
+

KYC/KYB is read-only while acting for another profile.

+

+ You can review verification status, but verification must be completed outside this acting session. +

+
+
+ )} + {corridors.length > 0 ? ( {corridors.map(corridor => ( @@ -88,7 +104,12 @@ function OverviewPage() { whileHover={{ y: -4 }} whileTap={{ scale: 0.99 }} > - setActiveCorridor(corridor.id)} /> + setActiveCorridor(corridor.id)} + verificationReadOnly={verificationReadOnly} + /> ))} @@ -128,7 +149,7 @@ function OverviewPage() { - {openCorridor && ( + {openCorridor && !verificationReadOnly && ( - snapshot.matches("AwaitingPayment") && snapshot.context.meta?.accountId === account?.id ? snapshot.context.ramp : null + snapshot.matches("AwaitingPayment") && + snapshot.context.meta?.ownerProfileId === snapshot.context.activeOwnerProfileId && + snapshot.context.meta.accountId === account?.id + ? snapshot.context.ramp + : null ); if (!account) { diff --git a/apps/dashboard/src/routes/invite.$token.tsx b/apps/dashboard/src/routes/invite.$token.tsx index 2b0da3cc9..2b9a1dd93 100644 --- a/apps/dashboard/src/routes/invite.$token.tsx +++ b/apps/dashboard/src/routes/invite.$token.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"; import { AuthCard } from "@/components/auth/AuthCard"; import { VortexLogo } from "@/components/layout/VortexLogo"; import { Button } from "@/components/ui/button"; @@ -11,6 +11,7 @@ import { CORRIDOR_BY_RAIL } from "@/services/api/mappers"; import { OnboardingService } from "@/services/api/onboarding.service"; import { type InvitePreviewResponse, RecipientsService } from "@/services/api/recipients.service"; import { useAuthStore } from "@/stores/auth.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; export const Route = createFileRoute("/invite/$token")({ component: InvitePage @@ -24,8 +25,11 @@ export const Route = createFileRoute("/invite/$token")({ */ function InvitePage() { const user = useAuthStore(state => state.user); + const managedProfile = useManagedProfileSelection(); const { token } = Route.useParams(); + if (managedProfile) return ; + return (
diff --git a/apps/dashboard/src/routes/monerium.callback.tsx b/apps/dashboard/src/routes/monerium.callback.tsx index fc90e29f2..bf81304b8 100644 --- a/apps/dashboard/src/routes/monerium.callback.tsx +++ b/apps/dashboard/src/routes/monerium.callback.tsx @@ -12,6 +12,8 @@ import { queryClient } from "@/lib/queryClient"; import { apiClient } from "@/services/api/api-client"; import { AuthService } from "@/services/auth"; import { useAuthStore } from "@/stores/auth.store"; +import { useImpersonationSession } from "@/stores/impersonation.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; const searchSchema = z.object({ code: z.string().optional(), @@ -41,8 +43,18 @@ function callbackFrom(search: z.infer): MoneriumOAuthCallba } function MoneriumCallbackPage() { - const restoreSession = useAuthStore(state => state.restoreSession); + const impersonation = useImpersonationSession(); + const managedProfile = useManagedProfileSelection(); const user = useAuthStore(state => state.user); + + if (impersonation || managedProfile) return ; + if (!user && !AuthService.getTokens()) return ; + + return ; +} + +function MoneriumCallback() { + const restoreSession = useAuthStore(state => state.restoreSession); const search = Route.useSearch(); const navigate = useNavigate(); const [state] = useMachine(moneriumCallbackMachine, { @@ -62,8 +74,6 @@ function MoneriumCallbackPage() { } }, [navigate, restoreSession, value]); - if (!user && !AuthService.getTokens()) return ; - const goToDashboard = () => navigate({ to: "/overview" }); const isLoading = value === "Routing" || value === "CompletingAuthorization" || DASHBOARD_STATES.has(value); 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..37c8cc0a1 --- /dev/null +++ b/apps/dashboard/src/services/api/admin-console.service.ts @@ -0,0 +1,138 @@ +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: "business" | "individual"; + status: string; +} + +export interface AdminManagedProfile { + contactEmail: string | null; + customerType: "business" | "individual" | null; + externalSubjectId: string; + manager: { + email: string | null; + isActive: boolean; + profileId: string; + }; + status: "active" | "deleted"; +} + +export interface AdminAccountIdentity { + id: string; + email: string | null; + kind: "authenticated" | "managed"; + managedProfile: AdminManagedProfile | null; +} + +/** One row of GET /admin-console/accounts. */ +export interface AdminAccountSummary extends AdminAccountIdentity { + 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 extends AdminAccountIdentity { + 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/alfredpay.service.ts b/apps/dashboard/src/services/api/alfredpay.service.ts index d2394ccbf..5147430b5 100644 --- a/apps/dashboard/src/services/api/alfredpay.service.ts +++ b/apps/dashboard/src/services/api/alfredpay.service.ts @@ -6,6 +6,16 @@ import type { } from "@vortexfi/shared"; import { apiClient } from "./api-client"; +const managedProfileApiClient = { + get: (url: string, config?: { params?: Record; signal?: AbortSignal }) => + apiClient.get(url, { ...config, managedProfile: true }), + post: ( + url: string, + data?: unknown, + config?: { headers?: Record; params?: Record } + ) => apiClient.post(url, data, { ...config, managedProfile: true }) +}; + /** * The dashboard's Alfredpay endpoints. The KYC subset satisfies `AlfredpayKycApi`, which is what * `createAlfredpayKycMachine` verifies senders with. The same port drives MX/CO API-based company @@ -16,14 +26,14 @@ export const AlfredpayService: AlfredpayKycApi & { deleteFiatAccount(fiatAccountId: string, country: string): Promise; listFiatAccounts(country: string, signal?: AbortSignal): Promise; } = { - ...createAlfredpayKycApi(apiClient), + ...createAlfredpayKycApi(managedProfileApiClient), addFiatAccount(payload: DomesticAddFiatAccountRequest): Promise { - return apiClient.post("/alfredpay/fiatAccounts", payload); + return apiClient.post("/alfredpay/fiatAccounts", payload, { managedProfile: true }); }, async deleteFiatAccount(fiatAccountId: string, country: string): Promise { - await apiClient.delete(`/alfredpay/fiatAccounts/${fiatAccountId}`, { params: { country } }); + await apiClient.delete(`/alfredpay/fiatAccounts/${fiatAccountId}`, { managedProfile: true, params: { country } }); }, /** @@ -32,6 +42,10 @@ export const AlfredpayService: AlfredpayKycApi & { * "send to yourself" recipient. 404s when the caller has no AlfredPay customer yet. */ listFiatAccounts(country: string, signal?: AbortSignal): Promise { - return apiClient.get("/alfredpay/fiatAccounts", { params: { country }, signal }); + return apiClient.get("/alfredpay/fiatAccounts", { + managedProfile: true, + params: { country }, + signal + }); } }; 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..74212794e --- /dev/null +++ b/apps/dashboard/src/services/api/api-client.test.ts @@ -0,0 +1,332 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { AuthService } from "@/services/auth"; +import { apiClient, isApiError, setManagedProfileAccessDeniedHandler } from "./api-client"; + +const originalFetch = globalThis.fetch; +const originalGetAcceptedImpersonationSessionSnapshot = AuthService.getAcceptedImpersonationSessionSnapshot; +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.initializeAcceptedIdentitySnapshots(); + AuthService.getAcceptedImpersonationSessionSnapshot = originalGetAcceptedImpersonationSessionSnapshot; + setManagedProfileAccessDeniedHandler(undefined); +}); + +after(() => { + globalThis.fetch = originalFetch; + AuthService.getAcceptedImpersonationSessionSnapshot = originalGetAcceptedImpersonationSessionSnapshot; + setManagedProfileAccessDeniedHandler(undefined); + 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", + targetProfileId: "customer-1", + 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("does not let caller headers override trusted identity headers", async () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "customer-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + let headers: Record | undefined; + globalThis.fetch = (async (_input, init) => { + headers = init?.headers as Record; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.post("/ping", undefined, { + headers: { authorization: "Bearer attacker", "X-Managed-Profile-Id": "attacker-profile", "X-Request-Id": "request-1" }, + managedProfile: true + }); + + assert.equal(headers?.Authorization, "Bearer vtx_imp_abc123"); + assert.equal(headers?.["X-Managed-Profile-Id"], "child-1"); + assert.equal(headers?.["X-Request-Id"], "request-1"); + assert.equal(headers?.authorization, undefined); + }); + + it("uses one impersonation snapshot for authorization and 401 handling", async () => { + const activeSnapshot = AuthService.getAcceptedImpersonationSessionSnapshot(); + let snapshotReads = 0; + AuthService.getAcceptedImpersonationSessionSnapshot = (() => { + snapshotReads += 1; + return snapshotReads === 1 ? activeSnapshot : null; + }) as typeof AuthService.getAcceptedImpersonationSessionSnapshot; + 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"); + }); + + it("does not clear a newer impersonation session after a stale 401", async () => { + const newerSession = { + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "session-2", + targetEmail: "new@example.com", + targetProfileId: "customer-2", + token: "vtx_imp_new" + }; + globalThis.fetch = (async () => { + AuthService.storeImpersonationSession(newerSession); + return new Response(null, { status: 401 }); + }) as typeof fetch; + + await assert.rejects(() => apiClient.get("/ping"), error => isApiError(error) && error.status === 401); + + assert.deepEqual(AuthService.getImpersonationSession(), newerSession); + }); +}); + +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"); + }); + + it("adds a valid managed profile only when the request explicitly opts in", async () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + const headers: Array> = []; + globalThis.fetch = (async (_input, init) => { + headers.push(init?.headers as Record); + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/delegated", { managedProfile: true }); + await apiClient.get("/manager-only"); + + assert.equal(headers[0]?.["X-Managed-Profile-Id"], "child-1"); + assert.equal(headers[1]?.["X-Managed-Profile-Id"], undefined); + }); + + it("preserves the captured managed profile header on a 401 refresh retry", async () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + const requestHeaders: Array> = []; + globalThis.fetch = (async (input, init) => { + if (String(input).includes("/auth/refresh")) { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-43", + managerProfileId: "user-1", + targetEmail: "new-child@example.com", + targetProfileId: "child-2" + }); + return new Response(JSON.stringify({ access_token: "rotated", refresh_token: "rotated-refresh" }), { + headers: { "Content-Type": "application/json" }, + status: 200 + }); + } + requestHeaders.push(init?.headers as Record); + return requestHeaders.length === 1 + ? new Response(null, { status: 401 }) + : new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/delegated", { managedProfile: true }); + + assert.equal(requestHeaders[0]?.["X-Managed-Profile-Id"], "child-1"); + assert.equal(requestHeaders[1]?.["X-Managed-Profile-Id"], "child-1"); + }); + + it("uses the tab-accepted selection instead of an unaccepted storage change", async () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "accepted@example.com", + targetProfileId: "child-1" + }); + values.set( + AuthService.MANAGED_PROFILE_STORAGE_KEY, + JSON.stringify({ + customerType: "business", + externalSubjectId: "merchant-43", + managerProfileId: "user-1", + targetEmail: "unaccepted@example.com", + targetProfileId: "child-2" + }) + ); + let managedProfileId: string | undefined; + globalThis.fetch = (async (_input, init) => { + managedProfileId = (init?.headers as Record)["X-Managed-Profile-Id"]; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/delegated", { managedProfile: true }); + + assert.equal(managedProfileId, "child-1"); + }); + + it("clears only the stale selection on managed access denial and never retries without the header", async () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + let calls = 0; + globalThis.fetch = (async (_input, init) => { + calls += 1; + assert.equal((init?.headers as Record)["X-Managed-Profile-Id"], "child-1"); + return new Response(JSON.stringify({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "denied" } }), { + headers: { "Content-Type": "application/json" }, + status: 403 + }); + }) as typeof fetch; + + await assert.rejects(() => apiClient.get("/delegated", { managedProfile: true }), error => isApiError(error) && error.status === 403); + + assert.equal(calls, 1); + assert.equal(AuthService.getManagedProfileSelection(), null); + }); + + it("runs access-denied handling while the stale child selection is still active", async () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + const selectionSnapshot = AuthService.getAcceptedManagedProfileSelectionSnapshot(); + let handled = false; + setManagedProfileAccessDeniedHandler(snapshot => { + assert.equal(snapshot, selectionSnapshot); + assert.equal(AuthService.getManagedProfileSelection()?.targetProfileId, "child-1"); + handled = AuthService.clearManagedProfileSelection(snapshot); + return handled; + }); + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "denied" } }), { + headers: { "Content-Type": "application/json" }, + status: 403 + })) as typeof fetch; + + await assert.rejects(() => apiClient.get("/delegated", { managedProfile: true }), error => isApiError(error)); + + assert.equal(handled, true); + assert.equal(AuthService.getManagedProfileSelection(), null); + }); +}); diff --git a/apps/dashboard/src/services/api/api-client.ts b/apps/dashboard/src/services/api/api-client.ts index 0bf86bff2..8276c9339 100644 --- a/apps/dashboard/src/services/api/api-client.ts +++ b/apps/dashboard/src/services/api/api-client.ts @@ -5,13 +5,33 @@ function refreshTokenOnce(): Promise { return AuthService.refreshAccessToken().catch(() => null); } +let managedProfileAccessDeniedHandler: ((selectionSnapshot: string) => boolean | Promise) | undefined; + +export function setManagedProfileAccessDeniedHandler( + handler: ((selectionSnapshot: string) => boolean | Promise) | undefined +): void { + managedProfileAccessDeniedHandler = handler; +} + export class ApiError extends Error { status: number; - data: { error?: string; message?: string; details?: string; fields?: Array<{ field: string; message: string }> }; + data: { + code?: string; + error?: string; + message?: string; + details?: string; + fields?: Array<{ field: string; message: string }>; + }; constructor( status: number, - data: { error?: string; message?: string; details?: string; fields?: Array<{ field: string; message: string }> }, + data: { + code?: string; + error?: string; + message?: string; + details?: string; + fields?: Array<{ field: string; message: string }>; + }, message: string ) { super(message); @@ -25,6 +45,12 @@ export function isApiError(error: unknown): error is ApiError { } type Params = Record; +type RequestConfig = { + managedProfile?: boolean; + params?: Params; + headers?: Record; + signal?: AbortSignal; +}; async function apiFetch( method: string, @@ -34,9 +60,10 @@ async function apiFetch( params?: Params; headers?: Record; signal?: AbortSignal; + managedProfile?: boolean; } = {} ): Promise { - const url = new URL(`${API_BASE_URL}/v1${path}`, window.location.origin); + const url = new URL(`${API_BASE_URL}/v1${path}`, typeof window === "undefined" ? "http://localhost" : window.location.origin); if (options.params) { for (const [key, value] of Object.entries(options.params)) { if (value !== undefined) url.searchParams.set(key, String(value)); @@ -47,31 +74,54 @@ async function apiFetch( const isFormData = options.data instanceof FormData; const body = isFormData ? (options.data as FormData) : options.data !== undefined ? JSON.stringify(options.data) : undefined; + const impersonationSnapshot = AuthService.getAcceptedImpersonationSessionSnapshot(); + const impersonation = AuthService.parseImpersonationSessionSnapshot(impersonationSnapshot); + const initialTokens = AuthService.getTokens(); + const bearerProfileId = impersonation?.targetProfileId ?? initialTokens?.userId ?? null; + const selectionSnapshot = options.managedProfile ? AuthService.getAcceptedManagedProfileSelectionSnapshot() : null; + const selection = AuthService.parseManagedProfileSelectionSnapshot(selectionSnapshot); + const managedProfileId = selection?.managerProfileId === bearerProfileId ? selection.targetProfileId : null; + const initialAccessToken = impersonation?.token ?? initialTokens?.accessToken; + + const callerHeaders = Object.fromEntries( + Object.entries(options.headers ?? {}).filter( + ([key]) => !["authorization", "x-managed-profile-id"].includes(key.toLowerCase()) + ) + ); const doFetch = (accessToken: string | undefined) => fetch(url.toString(), { body, headers: { - ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(!isFormData ? { "Content-Type": "application/json" } : {}), - ...options.headers + ...callerHeaders, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + ...(managedProfileId ? { "X-Managed-Profile-Id": managedProfileId } : {}) }, method, signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) }); - const initialTokens = AuthService.getTokens(); - let response = await doFetch(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(impersonationSnapshot ?? undefined); + 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); + } } } if (!response.ok) { const errorData = (await response.json().catch(() => ({}))) as { error?: string | { message?: string; code?: string }; + code?: string; fields?: Array<{ field: string; message: string }>; message?: string; }; @@ -80,9 +130,16 @@ async function apiFetch( (typeof errorData.error === "string" ? errorData.error : errorData.error?.message) ?? errorData.message ?? response.statusText; + const code = errorData.code ?? (typeof errorData.error === "object" ? errorData.error.code : undefined); + if (managedProfileId && response.status === 403 && code === "MANAGED_PROFILE_ACCESS_DENIED" && selectionSnapshot) { + if (!(await managedProfileAccessDeniedHandler?.(selectionSnapshot))) { + AuthService.clearManagedProfileSelection(selectionSnapshot); + } + } throw new ApiError( response.status, { + code, error: typeof errorData.error === "string" ? errorData.error : errorData.error?.message, fields: errorData.fields, message: errorData.message @@ -96,11 +153,19 @@ async function apiFetch( } export const apiClient = { - delete: (url: string, config?: { params?: Params }) => apiFetch("DELETE", url, { params: config?.params }), - get: (url: string, config?: { params?: Params; signal?: AbortSignal }) => - apiFetch("GET", url, { params: config?.params, signal: config?.signal }), - patch: (url: string, data?: unknown) => apiFetch("PATCH", url, { data }), - post: (url: string, data?: unknown, config?: { headers?: Record; params?: Params }) => - apiFetch("POST", url, { data, headers: config?.headers, params: config?.params }), - put: (url: string, data?: unknown) => apiFetch("PUT", url, { data }) + delete: (url: string, config?: RequestConfig) => + apiFetch("DELETE", url, { managedProfile: config?.managedProfile, params: config?.params }), + get: (url: string, config?: RequestConfig) => + apiFetch("GET", url, { managedProfile: config?.managedProfile, params: config?.params, signal: config?.signal }), + patch: (url: string, data?: unknown, config?: RequestConfig) => + apiFetch("PATCH", url, { data, managedProfile: config?.managedProfile }), + post: (url: string, data?: unknown, config?: RequestConfig) => + apiFetch("POST", url, { + data, + headers: config?.headers, + managedProfile: config?.managedProfile, + params: config?.params + }), + put: (url: string, data?: unknown, config?: RequestConfig) => + apiFetch("PUT", url, { data, managedProfile: config?.managedProfile }) }; diff --git a/apps/dashboard/src/services/api/avenia.service.test.ts b/apps/dashboard/src/services/api/avenia.service.test.ts new file mode 100644 index 000000000..2bd17eaa3 --- /dev/null +++ b/apps/dashboard/src/services/api/avenia.service.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { AuthService } from "@/services/auth"; +import { AveniaService } from "./avenia.service"; + +const originalFetch = globalThis.fetch; +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) + } +}); + +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "http://localhost" } } +}); + +beforeEach(() => { + values.clear(); + AuthService.initializeAcceptedIdentitySnapshots(); + AuthService.storeTokens({ + accessToken: "manager-token", + refreshToken: "manager-refresh-token", + userEmail: "manager@example.com", + userId: "manager-1" + }); + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "business-1", + managerProfileId: "manager-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); +}); + +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("AveniaService", () => { + it("delegates every Vortex request to the selected managed profile", async () => { + const requests: Array<{ managedProfileId?: string; path: string }> = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input)); + requests.push({ + managedProfileId: (init?.headers as Record)["X-Managed-Profile-Id"], + path: url.pathname + }); + return new Response(JSON.stringify({}), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await AveniaService.createSubaccount({} as Parameters[0]); + await AveniaService.getKybAttemptStatus("attempt-1"); + await AveniaService.getKycStatus("tax-id", "quote-1", "session-1"); + await AveniaService.getSelfieLivenessUrl("tax-id"); + await AveniaService.getUser("tax-id"); + await AveniaService.initiateKybLevel1("subaccount-1"); + await AveniaService.submitNewKyc({} as Parameters[0]); + + assert.deepEqual( + requests.map(request => request.path), + [ + "/v1/brla/createSubaccount", + "/v1/brla/kyb/attempt-status", + "/v1/brla/getKycStatus", + "/v1/brla/getSelfieLivenessUrl", + "/v1/brla/getUser", + "/v1/brla/kyb/new-level-1/web-sdk", + "/v1/brla/newKyc" + ] + ); + assert.ok(requests.every(request => request.managedProfileId === "child-1")); + }); +}); diff --git a/apps/dashboard/src/services/api/avenia.service.ts b/apps/dashboard/src/services/api/avenia.service.ts new file mode 100644 index 000000000..301e18834 --- /dev/null +++ b/apps/dashboard/src/services/api/avenia.service.ts @@ -0,0 +1,14 @@ +import { type AveniaKycApi, createAveniaKycApi } from "@vortexfi/kyc"; +import { apiClient } from "./api-client"; + +const managedProfileApiClient = { + get: (url: string, config?: { params?: Record; signal?: AbortSignal }) => + apiClient.get(url, { ...config, managedProfile: true }), + post: ( + url: string, + data?: unknown, + config?: { headers?: Record; params?: Record } + ) => apiClient.post(url, data, { ...config, managedProfile: true }) +}; + +export const AveniaService: AveniaKycApi = createAveniaKycApi(managedProfileApiClient); diff --git a/apps/dashboard/src/services/api/brla.service.ts b/apps/dashboard/src/services/api/brla.service.ts index 402304de8..b893087c0 100644 --- a/apps/dashboard/src/services/api/brla.service.ts +++ b/apps/dashboard/src/services/api/brla.service.ts @@ -3,6 +3,6 @@ import { apiClient } from "./api-client"; export const BrlaService = { getUploadUrls(request: BrKYCDataUploadRequest): Promise { - return apiClient.post("/brla/getUploadUrls", request); + return apiClient.post("/brla/getUploadUrls", request, { managedProfile: true }); } }; diff --git a/apps/dashboard/src/services/api/limits.service.ts b/apps/dashboard/src/services/api/limits.service.ts index 91f6aa75b..488feba92 100644 --- a/apps/dashboard/src/services/api/limits.service.ts +++ b/apps/dashboard/src/services/api/limits.service.ts @@ -2,5 +2,5 @@ import type { GetUserLimitsRequest, GetUserLimitsResponse } from "@vortexfi/shar import { apiClient } from "./api-client"; export const LimitsService = { - get: (request: GetUserLimitsRequest) => apiClient.post("/limits", request) + get: (request: GetUserLimitsRequest) => apiClient.post("/limits", request, { managedProfile: true }) }; diff --git a/apps/dashboard/src/services/api/managed-profiles.service.ts b/apps/dashboard/src/services/api/managed-profiles.service.ts new file mode 100644 index 000000000..9338107dc --- /dev/null +++ b/apps/dashboard/src/services/api/managed-profiles.service.ts @@ -0,0 +1,38 @@ +import type { CorridorId } from "@/domain/types"; +import { apiClient } from "./api-client"; + +export type ManagedProfileCustomerType = "business" | "individual"; + +export interface ManagedProfileManager { + allowedCorridors: CorridorId[]; + allowedCustomerTypes: ManagedProfileCustomerType[] | null; + profileId: string; +} + +export interface ManagedProfile { + contactEmail: string | null; + customerType: ManagedProfileCustomerType; + externalSubjectId: string; + profileId: string; +} + +export interface ManagedProfilesResponse { + manager: ManagedProfileManager; + managedProfiles: ManagedProfile[]; + pagination: { + limit: number; + offset: number; + total: number; + }; +} + +export interface ListManagedProfilesParams extends Record { + limit?: number; + offset?: number; +} + +export const ManagedProfilesService = { + list(params: ListManagedProfilesParams = {}, signal?: AbortSignal): Promise { + return apiClient.get("/managed-profiles", { params, signal }); + } +}; diff --git a/apps/dashboard/src/services/api/onboarding.service.ts b/apps/dashboard/src/services/api/onboarding.service.ts index e694e84aa..b6d4415df 100644 --- a/apps/dashboard/src/services/api/onboarding.service.ts +++ b/apps/dashboard/src/services/api/onboarding.service.ts @@ -42,6 +42,6 @@ export const OnboardingService = { return apiClient.put<{ activeEntityId: string; type: ActiveEntityType }>("/onboarding/active-entity", { type }); }, status(): Promise { - return apiClient.get("/onboarding/status"); + return apiClient.get("/onboarding/status", { managedProfile: true }); } }; diff --git a/apps/dashboard/src/services/api/quote.service.ts b/apps/dashboard/src/services/api/quote.service.ts index 0265294c8..5a8ee67bf 100644 --- a/apps/dashboard/src/services/api/quote.service.ts +++ b/apps/dashboard/src/services/api/quote.service.ts @@ -42,5 +42,5 @@ export function buildQuoteRequest(params: QuoteParams): CreateQuoteRequest { } export function fetchQuote(params: QuoteParams): Promise { - return apiClient.post("/quotes", buildQuoteRequest(params)); + return apiClient.post("/quotes", buildQuoteRequest(params), { managedProfile: true }); } diff --git a/apps/dashboard/src/services/api/ramp.service.ts b/apps/dashboard/src/services/api/ramp.service.ts index f286eb0e1..b8b5b9ae0 100644 --- a/apps/dashboard/src/services/api/ramp.service.ts +++ b/apps/dashboard/src/services/api/ramp.service.ts @@ -28,7 +28,7 @@ export function mapPhaseToStatus(phase: string): DomainTransactionStatus { /** Ported from the widget's RampService — the real /v1/ramp/* endpoints. */ export const RampService = { getRampStatus(rampId: string): Promise { - return apiClient.get(`/ramp/${rampId}`); + return apiClient.get(`/ramp/${rampId}`, { managedProfile: true }); }, registerRamp( @@ -36,11 +36,15 @@ export const RampService = { signingAccounts: AccountMeta[], additionalData?: RegisterRampRequest["additionalData"] ): Promise { - return apiClient.post("/ramp/register", { additionalData, quoteId, signingAccounts }); + return apiClient.post( + "/ramp/register", + { additionalData, quoteId, signingAccounts }, + { managedProfile: true } + ); }, startRamp(rampId: string): Promise { - return apiClient.post("/ramp/start", { rampId }); + return apiClient.post("/ramp/start", { rampId }, { managedProfile: true }); }, updateRamp( @@ -48,7 +52,7 @@ export const RampService = { presignedTxs: PresignedTx[], additionalData?: UpdateRampRequest["additionalData"] ): Promise { - return apiClient.post("/ramp/update", { additionalData, presignedTxs, rampId }); + return apiClient.post("/ramp/update", { additionalData, presignedTxs, rampId }, { managedProfile: true }); } }; diff --git a/apps/dashboard/src/services/api/recipients.service.ts b/apps/dashboard/src/services/api/recipients.service.ts index 848be5541..860ab479f 100644 --- a/apps/dashboard/src/services/api/recipients.service.ts +++ b/apps/dashboard/src/services/api/recipients.service.ts @@ -119,17 +119,25 @@ export const RecipientsService = { }, /** Hide a pending invitation from the list; the link stays redeemable. */ archiveInvitation(id: string): Promise<{ id: string; archived: boolean }> { - return apiClient.patch<{ id: string; archived: boolean }>(`/recipients/invitations/${id}`, { archived: true }); + return apiClient.patch<{ id: string; archived: boolean }>( + `/recipients/invitations/${id}`, + { archived: true }, + { managedProfile: true } + ); }, /** Archive an accepted relationship — removed from the list, recipient's KYC unaffected. */ archiveRecipient(id: string): Promise<{ id: string; relationshipStatus: string }> { - return apiClient.patch<{ id: string; relationshipStatus: string }>(`/recipients/${id}`, { status: "archived" }); + return apiClient.patch<{ id: string; relationshipStatus: string }>( + `/recipients/${id}`, + { status: "archived" }, + { managedProfile: true } + ); }, createInvite(body: CreateInviteRequest): Promise { - return apiClient.post("/recipients/invite", body); + return apiClient.post("/recipients/invite", body, { managedProfile: true }); }, list(): Promise { - return apiClient.get("/recipients"); + return apiClient.get("/recipients", { managedProfile: true }); }, /** Gate-checked invite preview for the confirm screen; leaves the invite untouched. */ previewInvite(token: string): Promise { diff --git a/apps/dashboard/src/services/api/transactions.service.ts b/apps/dashboard/src/services/api/transactions.service.ts index 8937720ea..1733f58f4 100644 --- a/apps/dashboard/src/services/api/transactions.service.ts +++ b/apps/dashboard/src/services/api/transactions.service.ts @@ -6,6 +6,6 @@ import { apiClient } from "./api-client"; */ export const TransactionsService = { history(limit = 50): Promise { - return apiClient.get("/ramp/history", { params: { limit } }); + return apiClient.get("/ramp/history", { managedProfile: true, params: { limit } }); } }; diff --git a/apps/dashboard/src/services/auth.test.ts b/apps/dashboard/src/services/auth.test.ts index dc25ac34b..74f56bbda 100644 --- a/apps/dashboard/src/services/auth.test.ts +++ b/apps/dashboard/src/services/auth.test.ts @@ -21,6 +21,7 @@ Object.defineProperty(globalThis, "localStorage", { beforeEach(() => { values.clear(); + AuthService.initializeAcceptedIdentitySnapshots(); AuthService.storeTokens({ accessToken: "expired-access-token", refreshToken: "refresh-token", @@ -229,3 +230,184 @@ 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", + targetProfileId: "customer-1", + 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("rejects a legacy session without a bearer profile and removes its 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.equal(AuthService.getImpersonationSession(), null); + + AuthService.storeImpersonationSession({ + expiresAt: "2026-02-01T00:00:00.000Z", + sessionId: "session-2", + targetEmail: "current@example.com", + targetProfileId: "customer-2", + 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", + targetProfileId: "customer-1", + 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", + targetProfileId: "customer-1", + 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", + targetProfileId: "customer-1", + 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", + targetProfileId: "customer-1", + token: "vtx_imp_abc123", + }); + + AuthService.signOut(); + + assert.equal(AuthService.getTokens(), null); + assert.equal(AuthService.getImpersonationSession(), null); + }); +}); + +describe("AuthService managed profile selection", () => { + it("binds a persisted selection to the effective bearer profile", () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "child@example.com", + targetProfileId: "child-1", + }); + + assert.equal(AuthService.getEffectiveBearerProfileId(), "user-1"); + assert.equal(AuthService.getEffectiveProfileId(), "child-1"); + + AuthService.storeTokens({ + accessToken: "other-access-token", + refreshToken: "other-refresh-token", + userId: "user-2", + }); + + assert.equal(AuthService.getManagedProfileSelection(), null); + assert.equal(AuthService.getEffectiveProfileId(), "user-2"); + }); + + it("compare-and-clears only the selection captured by a caller", () => { + AuthService.storeManagedProfileSelection({ + customerType: "individual", + externalSubjectId: "first", + managerProfileId: "user-1", + targetEmail: "first@example.com", + targetProfileId: "child-1", + }); + const staleSnapshot = AuthService.getManagedProfileSelectionSnapshot(); + AuthService.storeManagedProfileSelection({ + customerType: "individual", + externalSubjectId: "second", + managerProfileId: "user-1", + targetEmail: "second@example.com", + targetProfileId: "child-2", + }); + + assert.equal(AuthService.clearManagedProfileSelection(staleSnapshot ?? undefined), false); + assert.equal(AuthService.getManagedProfileSelection()?.targetProfileId, "child-2"); + }); + + it("restores the effective managed owner from the accepted persisted selection", () => { + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + + assert.equal(AuthService.getEffectiveProfileId(), "child-1"); + }); +}); diff --git a/apps/dashboard/src/services/auth.ts b/apps/dashboard/src/services/auth.ts index 6a0ca9fe8..ba14caea2 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -7,6 +7,33 @@ 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; + targetProfileId: string; +} + +export interface ManagedProfileSelection { + managerProfileId: string; + targetProfileId: string; + targetEmail: string; + externalSubjectId: string; + customerType: string; +} + +interface IdentityTransitionEffects { + activateTransferOwner: (ownerProfileId: string) => boolean; + canChangeEffectiveIdentity: () => boolean; + clearAccountState: () => void; +} + +function noop(): void { + // Default used before the authenticated app installs account cleanup effects. +} + /** * 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,7 +43,23 @@ 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"; + static readonly MANAGED_PROFILE_STORAGE_KEY = "vortex_dashboard_managed_profile_selection"; + 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 readonly managedProfileListeners = new Set<() => void>(); + private static acceptedImpersonationSnapshot: string | null | undefined; + private static acceptedManagedProfileSnapshot: string | null | undefined; private static sessionGeneration = 0; + private static identityTransitionEffects: IdentityTransitionEffects = { + activateTransferOwner: () => true, + canChangeEffectiveIdentity: () => true, + clearAccountState: noop + }; private static refreshFlight: { generation: number; refreshToken: string; @@ -33,6 +76,25 @@ export class AuthService { } } + static configureIdentityTransitionEffects(effects?: IdentityTransitionEffects): void { + this.identityTransitionEffects = + effects ?? + ({ + activateTransferOwner: () => true, + canChangeEffectiveIdentity: () => true, + clearAccountState: noop + } satisfies IdentityTransitionEffects); + } + + static canChangeEffectiveIdentity(): boolean { + return this.identityTransitionEffects.canChangeEffectiveIdentity(); + } + + static applyEffectiveIdentity(ownerProfileId: string | null): void { + this.identityTransitionEffects.clearAccountState(); + if (ownerProfileId) this.identityTransitionEffects.activateTransferOwner(ownerProfileId); + } + static getTokens(): AuthTokens | null { const accessToken = localStorage.getItem(this.ACCESS_TOKEN_KEY); const refreshToken = localStorage.getItem(this.REFRESH_TOKEN_KEY); @@ -53,6 +115,218 @@ export class AuthService { localStorage.removeItem(this.USER_EMAIL_KEY); } + static storeImpersonationSession(session: ImpersonationSession): void { + const previousSnapshot = this.getAcceptedImpersonationSessionSnapshot(); + localStorage.setItem( + this.IMPERSONATION_STORAGE_KEY, + JSON.stringify({ + expiresAt: session.expiresAt, + sessionId: session.sessionId, + targetEmail: session.targetEmail, + targetProfileId: session.targetProfileId, + token: session.token + }) + ); + this.acceptedImpersonationSnapshot = this.getImpersonationSessionSnapshot(); + this.clearLegacyImpersonationKeys(); + this.notifyImpersonationListeners(previousSnapshot); + } + + static getImpersonationSession(): ImpersonationSession | null { + return this.parseImpersonationSessionSnapshot(this.getAcceptedImpersonationSessionSnapshot()); + } + + static getAcceptedImpersonationSessionSnapshot(): string | null { + if (this.acceptedImpersonationSnapshot === undefined) { + this.acceptedImpersonationSnapshot = this.getImpersonationSessionSnapshot(); + } + return this.acceptedImpersonationSnapshot; + } + + static initializeAcceptedIdentitySnapshots(): void { + this.acceptedImpersonationSnapshot = this.getImpersonationSessionSnapshot(); + this.acceptedManagedProfileSnapshot = this.getManagedProfileSelectionSnapshot(); + } + + static acceptImpersonationSessionSnapshot(snapshot: string | null): void { + this.acceptedImpersonationSnapshot = snapshot; + } + + static restoreAcceptedImpersonationSession(): void { + const snapshot = this.getAcceptedImpersonationSessionSnapshot(); + if (snapshot === null) localStorage.removeItem(this.IMPERSONATION_STORAGE_KEY); + else localStorage.setItem(this.IMPERSONATION_STORAGE_KEY, snapshot); + } + + /** 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" || + typeof parsed.targetProfileId !== "string" + ) { + return null; + } + return { + expiresAt: parsed.expiresAt, + sessionId: parsed.sessionId, + targetEmail: parsed.targetEmail, + targetProfileId: parsed.targetProfileId, + 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(expectedSnapshot?: string): boolean { + const storedSnapshot = this.getImpersonationSessionSnapshot(); + if (expectedSnapshot !== undefined && storedSnapshot !== expectedSnapshot) return false; + const previousSnapshot = this.getAcceptedImpersonationSessionSnapshot(); + localStorage.removeItem(this.IMPERSONATION_STORAGE_KEY); + this.clearLegacyImpersonationKeys(); + this.acceptedImpersonationSnapshot = null; + this.notifyImpersonationListeners(previousSnapshot); + return true; + } + + static storeManagedProfileSelection(selection: ManagedProfileSelection): void { + const previousSnapshot = this.getAcceptedManagedProfileSelectionSnapshot(); + localStorage.setItem(this.MANAGED_PROFILE_STORAGE_KEY, JSON.stringify(selection)); + this.acceptedManagedProfileSnapshot = this.getManagedProfileSelectionSnapshot(); + this.notifyManagedProfileListeners(previousSnapshot); + } + + static getAcceptedManagedProfileSelectionSnapshot(): string | null { + if (this.acceptedManagedProfileSnapshot === undefined) { + this.acceptedManagedProfileSnapshot = this.getManagedProfileSelectionSnapshot(); + } + return this.acceptedManagedProfileSnapshot; + } + + static acceptManagedProfileSelectionSnapshot(snapshot: string | null): void { + this.acceptedManagedProfileSnapshot = snapshot; + } + + static restoreAcceptedManagedProfileSelection(): void { + const snapshot = this.getAcceptedManagedProfileSelectionSnapshot(); + if (snapshot === null) localStorage.removeItem(this.MANAGED_PROFILE_STORAGE_KEY); + else localStorage.setItem(this.MANAGED_PROFILE_STORAGE_KEY, snapshot); + } + + static getManagedProfileSelectionSnapshot(): string | null { + return localStorage.getItem(this.MANAGED_PROFILE_STORAGE_KEY); + } + + static parseManagedProfileSelectionSnapshot(snapshot: string | null): ManagedProfileSelection | null { + if (!snapshot) return null; + try { + const parsed = JSON.parse(snapshot) as Partial; + if ( + typeof parsed.managerProfileId !== "string" || + typeof parsed.targetProfileId !== "string" || + typeof parsed.targetEmail !== "string" || + typeof parsed.externalSubjectId !== "string" || + typeof parsed.customerType !== "string" + ) { + return null; + } + return parsed as ManagedProfileSelection; + } catch { + return null; + } + } + + static getManagedProfileSelection(): ManagedProfileSelection | null { + const selection = this.parseManagedProfileSelectionSnapshot(this.getAcceptedManagedProfileSelectionSnapshot()); + return selection?.managerProfileId === this.getEffectiveBearerProfileId() ? selection : null; + } + + static subscribeManagedProfileSelection(listener: () => void): () => void { + this.managedProfileListeners.add(listener); + const handleStorage = (event: StorageEvent) => { + if (event.key === null || event.key === this.MANAGED_PROFILE_STORAGE_KEY) listener(); + }; + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("storage", handleStorage); + } + return () => { + this.managedProfileListeners.delete(listener); + if (typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("storage", handleStorage); + } + }; + } + + static clearManagedProfileSelection(expectedSnapshot?: string): boolean { + const storedSnapshot = this.getManagedProfileSelectionSnapshot(); + if (expectedSnapshot !== undefined && storedSnapshot !== expectedSnapshot) return false; + const previousSnapshot = this.getAcceptedManagedProfileSelectionSnapshot(); + localStorage.removeItem(this.MANAGED_PROFILE_STORAGE_KEY); + this.acceptedManagedProfileSnapshot = null; + this.notifyManagedProfileListeners(previousSnapshot); + return true; + } + + static isManagedProfileSelectionSnapshotCurrent(expectedSnapshot: string): boolean { + return this.getManagedProfileSelectionSnapshot() === expectedSnapshot; + } + + /** 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 getEffectiveBearerProfileId(): string | null { + return this.getImpersonationSession()?.targetProfileId ?? this.getTokens()?.userId ?? null; + } + + static getEffectiveProfileId(): string | null { + return this.getManagedProfileSelection()?.targetProfileId ?? this.getEffectiveBearerProfileId(); + } + static isAuthenticated(): boolean { const tokens = this.getTokens(); if (!tokens) { @@ -149,6 +423,37 @@ export class AuthService { } static signOut(): void { + this.clearManagedProfileSelection(); + 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.getAcceptedImpersonationSessionSnapshot() === previousSnapshot) return; + for (const listener of this.impersonationListeners) { + listener(); + } + } + + private static notifyManagedProfileListeners(previousSnapshot: string | null): void { + if (this.getAcceptedManagedProfileSelectionSnapshot() === previousSnapshot) return; + for (const listener of this.managedProfileListeners) listener(); + } } diff --git a/apps/dashboard/src/stores/auth.store.ts b/apps/dashboard/src/stores/auth.store.ts index 4184b9e9b..f4c44bd38 100644 --- a/apps/dashboard/src/stores/auth.store.ts +++ b/apps/dashboard/src/stores/auth.store.ts @@ -2,7 +2,8 @@ import { disconnect } from "wagmi/actions"; import { create } from "zustand"; import { queryClient } from "@/lib/queryClient"; import { wagmiConfig } from "@/lib/wagmi"; -import { resetTransferState } from "@/machines/transferActor"; +import { activateTransferOwner, canChangeEffectiveIdentity, clearAllTransferRecovery } from "@/machines/transferActor"; +import { AdminConsoleService } from "@/services/api/admin-console.service"; import { AuthAPI } from "@/services/api/auth.api"; import { AuthService, type AuthTokens } from "@/services/auth"; import { restoreAuthSession } from "@/services/sessionRestore"; @@ -45,41 +46,57 @@ 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(); - void disconnect(wagmiConfig); + if (typeof document !== "undefined") void disconnect(wagmiConfig); } +AuthService.configureIdentityTransitionEffects({ activateTransferOwner, canChangeEffectiveIdentity, clearAccountState }); + /** Real Supabase OTP auth against /v1/auth/*; the session lives in AuthService storage. */ export const useAuthStore = create()(set => ({ logout: () => { + if (!canChangeEffectiveIdentity()) return; + const impersonation = AuthService.getImpersonationSession(); + if (impersonation) { + void AdminConsoleService.endImpersonation(impersonation.sessionId).catch(() => { + // The non-renewable server session remains bounded by its 30-minute TTL. + }); + } AuthService.signOut(); clearAccountState(); + clearAllTransferRecovery(); set({ user: null }); }, requestOtp: async email => { await AuthAPI.requestOTP(email); }, restoreSession: async () => { + AuthService.initializeAcceptedIdentitySnapshots(); const tokens = await restoreAuthSession({ refresh: () => AuthService.refreshAccessToken(), tokens: AuthService.getTokens(), verify: accessToken => AuthAPI.verifyToken(accessToken) }); set({ user: tokens ? userFromTokens(tokens) : null }); + const ownerProfileId = tokens ? AuthService.getEffectiveProfileId() : null; + if (ownerProfileId) activateTransferOwner(ownerProfileId); }, user: userFromSession(), verifyOtp: async (email, code) => { const result = await AuthAPI.verifyOTP(email, code); clearAccountState(); + clearAllTransferRecovery(); + AuthService.clearManagedProfileSelection(); + AuthService.clearImpersonationSession(); AuthService.storeTokens({ accessToken: result.accessToken, refreshToken: result.refreshToken, userEmail: email, userId: result.userId }); + activateTransferOwner(result.userId); set({ user: { email, name: displayNameFromEmail(email), userId: result.userId } }); } })); 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..e4fc000bb --- /dev/null +++ b/apps/dashboard/src/stores/impersonation.store.test.ts @@ -0,0 +1,196 @@ +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 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) + } +}); + +let accountStateClears = 0; +let identityChangeAllowed = true; +let activatedOwner: string | null = null; + +const { AuthService } = await import("@/services/auth"); +const { applyStoredImpersonationForTests, enterImpersonation, exitImpersonation } = await import("./impersonation.store"); +function configureIdentityEffects(): void { + AuthService.configureIdentityTransitionEffects({ + activateTransferOwner: (ownerProfileId: string) => { + activatedOwner = ownerProfileId; + return true; + }, + canChangeEffectiveIdentity: () => identityChangeAllowed, + clearAccountState: () => { + accountStateClears += 1; + } + }); +} + +function session(overrides: Partial = {}): ImpersonationSession { + return { + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-1", + token: "vtx_imp_token-1", + ...overrides + }; +} + +function dispatchStorage(key: string | null): void { + if (key === null || key === AuthService.IMPERSONATION_STORAGE_KEY) applyStoredImpersonationForTests(); +} + +beforeEach(() => { + configureIdentityEffects(); + values.clear(); + identityChangeAllowed = true; + AuthService.initializeAcceptedIdentitySnapshots(); + dispatchStorage(AuthService.IMPERSONATION_STORAGE_KEY); + accountStateClears = 0; + activatedOwner = null; + globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 204 }))) as typeof fetch; +}); + +after(() => { + AuthService.configureIdentityTransitionEffects(); + globalThis.fetch = originalFetch; + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } +}); + +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); + assert.equal(activatedOwner, "target-1"); + }); + + it("does not mutate identity when the transfer guard blocks the transition", () => { + identityChangeAllowed = false; + + assert.equal(enterImpersonation(session()), false); + + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 0); + }); + + 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("restores the accepted session when a cross-tab change is blocked", () => { + const accepted = session(); + enterImpersonation(accepted); + accountStateClears = 0; + identityChangeAllowed = false; + values.set(AuthService.IMPERSONATION_STORAGE_KEY, JSON.stringify(session({ sessionId: "session-2", token: "new-token" }))); + + dispatchStorage(AuthService.IMPERSONATION_STORAGE_KEY); + + assert.deepEqual(AuthService.getImpersonationSession(), accepted); + assert.deepEqual(AuthService.parseImpersonationSessionSnapshot(values.get(AuthService.IMPERSONATION_STORAGE_KEY) ?? null), accepted); + assert.equal(accountStateClears, 0); + }); + + 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..bce05a54e --- /dev/null +++ b/apps/dashboard/src/stores/impersonation.store.ts @@ -0,0 +1,71 @@ +import { useSyncExternalStore } from "react"; +import { AdminConsoleService } from "@/services/api/admin-console.service"; +import { AuthService, type ImpersonationSession } from "@/services/auth"; + +let currentSnapshot = AuthService.getAcceptedImpersonationSessionSnapshot(); +const reactListeners = new Set<() => void>(); + +function applyStoredIdentity(): void { + const nextSnapshot = AuthService.getImpersonationSessionSnapshot(); + if (nextSnapshot === currentSnapshot) return; + + if (!AuthService.canChangeEffectiveIdentity()) { + AuthService.restoreAcceptedImpersonationSession(); + return; + } + + currentSnapshot = nextSnapshot; + AuthService.acceptImpersonationSessionSnapshot(nextSnapshot); + AuthService.clearManagedProfileSelection(); + const ownerProfileId = AuthService.getEffectiveProfileId(); + AuthService.applyEffectiveIdentity(ownerProfileId); + for (const listener of reactListeners) { + listener(); + } +} + +export const applyStoredImpersonationForTests = applyStoredIdentity; + +// 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): boolean { + if (!AuthService.canChangeEffectiveIdentity()) return false; + AuthService.clearManagedProfileSelection(); + AuthService.storeImpersonationSession(session); + return true; +} + +/** + * 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(): boolean { + if (!AuthService.canChangeEffectiveIdentity()) return false; + 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(); + return true; +} diff --git a/apps/dashboard/src/stores/managed-profile.store.test.ts b/apps/dashboard/src/stores/managed-profile.store.test.ts new file mode 100644 index 000000000..3c3b739c6 --- /dev/null +++ b/apps/dashboard/src/stores/managed-profile.store.test.ts @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; + +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +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) + } +}); +let accountStateClears = 0; +let identityChangeAllowed = true; +let activatedOwner: string | null = null; + +const { AuthService } = await import("@/services/auth"); +const { enterImpersonation } = await import("./impersonation.store"); +const { applyStoredManagedProfileForTests, clearManagedProfile, clearManagedProfileSelection, selectManagedProfile } = + await import("./managed-profile.store"); +function configureIdentityEffects(): void { + AuthService.configureIdentityTransitionEffects({ + activateTransferOwner: (ownerProfileId: string) => { + activatedOwner = ownerProfileId; + return true; + }, + canChangeEffectiveIdentity: () => identityChangeAllowed, + clearAccountState: () => { + accountStateClears += 1; + } + }); +} + +beforeEach(() => { + configureIdentityEffects(); + values.clear(); + identityChangeAllowed = true; + AuthService.initializeAcceptedIdentitySnapshots(); + applyStoredManagedProfileForTests(); + accountStateClears = 0; + activatedOwner = null; + AuthService.storeTokens({ accessToken: "manager-token", refreshToken: "refresh", userId: "manager-1" }); +}); + +after(() => { + AuthService.configureIdentityTransitionEffects(); + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } +}); + +describe("managed profile transitions", () => { + it("atomically binds selection to the current bearer and activates its transfer owner", () => { + assert.equal( + selectManagedProfile({ + customerType: "business", + externalSubjectId: "merchant-42", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }), + true + ); + + assert.equal(AuthService.getManagedProfileSelection()?.managerProfileId, "manager-1"); + assert.equal(accountStateClears, 1); + assert.equal(activatedOwner, "child-1"); + }); + + it("binds a child selected immediately after entering manager impersonation", () => { + assert.equal( + enterImpersonation({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + sessionId: "session-1", + targetEmail: "impersonated-manager@example.com", + targetProfileId: "manager-2", + token: "vtx_imp_token" + }), + true + ); + assert.equal( + selectManagedProfile({ + customerType: "individual", + externalSubjectId: "customer-42", + targetEmail: "child@example.com", + targetProfileId: "child-2" + }), + true + ); + + assert.equal(AuthService.getManagedProfileSelection()?.managerProfileId, "manager-2"); + assert.equal(AuthService.getEffectiveProfileId(), "child-2"); + }); + + it("guards before mutating selection", () => { + identityChangeAllowed = false; + + assert.equal( + selectManagedProfile({ + customerType: "business", + externalSubjectId: "merchant-42", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }), + false + ); + assert.equal(AuthService.getManagedProfileSelection(), null); + assert.equal(accountStateClears, 0); + }); + + it("adopts a cross-tab selection once and switches the transfer owner", () => { + const selection = { + customerType: "individual", + externalSubjectId: "person-42", + managerProfileId: "manager-1", + targetEmail: "child@example.com", + targetProfileId: "child-2" + }; + values.set(AuthService.MANAGED_PROFILE_STORAGE_KEY, JSON.stringify(selection)); + + applyStoredManagedProfileForTests(); + + assert.deepEqual(AuthService.getManagedProfileSelection(), selection); + assert.equal(accountStateClears, 1); + assert.equal(activatedOwner, "child-2"); + }); + + it("restores the accepted selection when a cross-tab change is blocked", () => { + selectManagedProfile({ + customerType: "business", + externalSubjectId: "merchant-1", + targetEmail: "first@example.com", + targetProfileId: "child-1" + }); + accountStateClears = 0; + identityChangeAllowed = false; + const rejected = { + customerType: "business", + externalSubjectId: "merchant-2", + managerProfileId: "manager-1", + targetEmail: "second@example.com", + targetProfileId: "child-2" + }; + values.set(AuthService.MANAGED_PROFILE_STORAGE_KEY, JSON.stringify(rejected)); + + applyStoredManagedProfileForTests(); + + assert.equal(AuthService.getManagedProfileSelection()?.targetProfileId, "child-1"); + assert.equal( + AuthService.parseManagedProfileSelectionSnapshot(values.get(AuthService.MANAGED_PROFILE_STORAGE_KEY) ?? null) + ?.targetProfileId, + "child-1" + ); + assert.equal(accountStateClears, 0); + }); + + it("returns to the bearer identity when child mode stops", () => { + selectManagedProfile({ + customerType: "business", + externalSubjectId: "merchant-42", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + accountStateClears = 0; + + assert.equal(clearManagedProfile(), true); + + assert.equal(AuthService.getManagedProfileSelection(), null); + assert.equal(accountStateClears, 1); + assert.equal(activatedOwner, "manager-1"); + }); + + it("compare-and-clears the expected denied selection", () => { + selectManagedProfile({ + customerType: "business", + externalSubjectId: "merchant-42", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + const selectionSnapshot = AuthService.getAcceptedManagedProfileSelectionSnapshot(); + accountStateClears = 0; + + assert.equal(clearManagedProfileSelection(selectionSnapshot ?? undefined), true); + + assert.equal(AuthService.getManagedProfileSelection(), null); + assert.equal(accountStateClears, 1); + assert.equal(activatedOwner, "manager-1"); + }); + + it("keeps the accepted child identity when clearing a denied selection is blocked", () => { + selectManagedProfile({ + customerType: "business", + externalSubjectId: "merchant-42", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + const selectionSnapshot = AuthService.getAcceptedManagedProfileSelectionSnapshot(); + accountStateClears = 0; + activatedOwner = null; + identityChangeAllowed = false; + + assert.equal(clearManagedProfileSelection(selectionSnapshot ?? undefined), false); + + assert.equal(AuthService.getManagedProfileSelection()?.targetProfileId, "child-1"); + assert.equal(AuthService.getEffectiveProfileId(), "child-1"); + assert.equal(accountStateClears, 0); + assert.equal(activatedOwner, null); + }); +}); diff --git a/apps/dashboard/src/stores/managed-profile.store.ts b/apps/dashboard/src/stores/managed-profile.store.ts new file mode 100644 index 000000000..f0777fee6 --- /dev/null +++ b/apps/dashboard/src/stores/managed-profile.store.ts @@ -0,0 +1,53 @@ +import { useSyncExternalStore } from "react"; +import { AuthService, type ManagedProfileSelection } from "@/services/auth"; + +let currentSnapshot = AuthService.getAcceptedManagedProfileSelectionSnapshot(); +const reactListeners = new Set<() => void>(); + +function applyStoredSelection(): void { + const nextSnapshot = AuthService.getManagedProfileSelectionSnapshot(); + if (nextSnapshot === currentSnapshot) return; + if (!AuthService.canChangeEffectiveIdentity()) { + AuthService.restoreAcceptedManagedProfileSelection(); + return; + } + currentSnapshot = nextSnapshot; + AuthService.acceptManagedProfileSelectionSnapshot(nextSnapshot); + const ownerProfileId = AuthService.getEffectiveProfileId(); + AuthService.applyEffectiveIdentity(ownerProfileId); + for (const listener of reactListeners) listener(); +} + +export const applyStoredManagedProfileForTests = applyStoredSelection; + +AuthService.subscribeManagedProfileSelection(applyStoredSelection); + +export function useManagedProfileSelection(): ManagedProfileSelection | null { + const snapshot = useSyncExternalStore( + listener => { + reactListeners.add(listener); + return () => reactListeners.delete(listener); + }, + () => currentSnapshot, + () => null + ); + const selection = AuthService.parseManagedProfileSelectionSnapshot(snapshot); + return selection?.managerProfileId === AuthService.getEffectiveBearerProfileId() ? selection : null; +} + +export function selectManagedProfile(selection: Omit): boolean { + if (!AuthService.canChangeEffectiveIdentity()) return false; + const managerProfileId = AuthService.getEffectiveBearerProfileId(); + if (!managerProfileId) return false; + AuthService.storeManagedProfileSelection({ ...selection, managerProfileId }); + return true; +} + +export function clearManagedProfile(): boolean { + return clearManagedProfileSelection(); +} + +export function clearManagedProfileSelection(expectedSnapshot?: string): boolean { + if (!AuthService.canChangeEffectiveIdentity()) return false; + return AuthService.clearManagedProfileSelection(expectedSnapshot); +} 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/adr-0003-managed-headless-profiles.md b/docs/adr-0003-managed-headless-profiles.md index 26325f700..a5603432a 100644 --- a/docs/adr-0003-managed-headless-profiles.md +++ b/docs/adr-0003-managed-headless-profiles.md @@ -41,9 +41,13 @@ than introduce a parallel tenant or impersonation model. Manager, relationship, corridor, and customer-type policy is re-evaluated for new authorization decisions; a committed policy change does not cancel already-authorized -requests. Historical and status reads remain available where reconciliation requires -them. Email-bound Mykobo and Monerium operations and recipient invitations are not -delegated. +requests. Historical and status reads remain available where reconciliation requires them. +Sender-side recipient operations are delegated to the child's sender entity, with invite creation +constrained by current manager corridor policy and privileged invite discounts constrained by the +manager actor's role. Invite preview and acceptance remain bearer-invitee operations and reject +managed selection. Email-bound Mykobo and Monerium operations remain unsupported. Their legacy +routes ignore `X-Managed-Profile-Id` and remain scoped to the authenticated manager, so managed +clients must not send the selector to them. The accepted Alfredpay cross-manager email-identity exception is tracked as RISK-019 in the [security risk register](security-spec/RISK-REGISTER.md). Normative behavior is defined by diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index c14ae1df2..a2eb891ac 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -851,7 +851,7 @@ export interface paths { }; /** * List managed profiles - * @description Lists children owned by the authenticated active manager, newest first. The default filter returns only active children. Use `status=deleted` or `status=all` to include retained logical-deletion records. + * @description Lists children owned by the authenticated active manager, newest first, together with the manager's current corridor and customer-type policy. Policy is manager-scoped and applies to all children; it is not a per-child grant. The default filter returns only active children. Use `status=deleted` or `status=all` to include retained logical-deletion records. * * **Auth:** controlling manager Supabase Bearer session or secret API key. Public API keys and direct managed-child credentials are rejected. */ @@ -2705,6 +2705,7 @@ export interface components { }; ListManagedProfilesResponse: { managedProfiles: components["schemas"]["ManagedProfile"][]; + manager: components["schemas"]["ManagedProfileManagerPolicy"]; pagination: components["schemas"]["ManagedProfilePagination"]; }; MalformedJsonErrorResponse: { @@ -2748,6 +2749,13 @@ export interface components { status: number; }; }; + /** @description The authenticated manager's current policy. This policy is manager-scoped and applies to every managed child; corridors and customer types are not grants copied onto each child. */ + ManagedProfileManagerPolicy: { + allowedCorridors: ("AR" | "BR" | "CO" | "EU" | "MX" | "US")[]; + allowedCustomerTypes: ("individual" | "business")[] | null; + /** Format: uuid */ + profileId: string; + }; ManagedProfilePagination: { limit: number; offset: number; @@ -2758,7 +2766,7 @@ export interface components { }; ManagedSelectorErrorResponse: { error: { - /** @description Machine-readable middleware code such as `INVALID_MANAGED_PROFILE_ID`, `MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH`, `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `CREDENTIAL_MISMATCH`, or `MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Machine-readable middleware code such as `INVALID_MANAGED_PROFILE_ID`, `MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH`, `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `CREDENTIAL_MISMATCH`, `MANAGED_PROFILE_ACCESS_DENIED`, or `IMPERSONATION_NOT_ALLOWED`. */ code: string; message: string; status: number; @@ -5890,7 +5898,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description A page of owned managed profiles and offset pagination metadata. */ + /** @description The authenticated manager's current policy, a page of owned managed profiles, and offset pagination metadata. */ 200: { headers: { [name: string]: unknown; @@ -7106,7 +7114,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Quote ownership or managed-profile authorization failed. */ + /** @description Quote ownership, managed-profile authorization, or impersonation policy failed. */ 403: { headers: { [name: string]: unknown; @@ -7255,7 +7263,7 @@ export interface operations { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Ramp ownership or managed-profile authorization failed. */ + /** @description Ramp ownership, managed-profile authorization, or impersonation policy failed. */ 403: { headers: { [name: string]: unknown; @@ -7417,7 +7425,7 @@ export interface operations { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Ramp ownership or managed-profile authorization failed. */ + /** @description Ramp ownership, managed-profile authorization, or impersonation policy failed. */ 403: { headers: { [name: string]: unknown; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index df8f3fb33..83e486869 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2219,11 +2219,10 @@ }, "type": "array" }, - "pagination": { - "$ref": "#/components/schemas/ManagedProfilePagination" - } + "manager": { "$ref": "#/components/schemas/ManagedProfileManagerPolicy" }, + "pagination": { "$ref": "#/components/schemas/ManagedProfilePagination" } }, - "required": ["managedProfiles", "pagination"], + "required": ["manager", "managedProfiles", "pagination"], "type": "object" }, "MalformedJsonErrorResponse": { @@ -2326,6 +2325,24 @@ "required": ["error"], "type": "object" }, + "ManagedProfileManagerPolicy": { + "description": "The authenticated manager's current policy. This policy is manager-scoped and applies to every managed child; corridors and customer types are not grants copied onto each child.", + "properties": { + "allowedCorridors": { + "items": { "enum": ["AR", "BR", "CO", "EU", "MX", "US"], "type": "string" }, + "type": "array", + "uniqueItems": true + }, + "allowedCustomerTypes": { + "items": { "enum": ["individual", "business"], "type": "string" }, + "type": ["array", "null"], + "uniqueItems": true + }, + "profileId": { "format": "uuid", "type": "string" } + }, + "required": ["profileId", "allowedCorridors", "allowedCustomerTypes"], + "type": "object" + }, "ManagedProfilePagination": { "properties": { "limit": { @@ -2359,7 +2376,7 @@ "error": { "properties": { "code": { - "description": "Machine-readable middleware code such as `INVALID_MANAGED_PROFILE_ID`, `MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH`, `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `CREDENTIAL_MISMATCH`, or `MANAGED_PROFILE_ACCESS_DENIED`.", + "description": "Machine-readable middleware code such as `INVALID_MANAGED_PROFILE_ID`, `MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH`, `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `CREDENTIAL_MISMATCH`, `MANAGED_PROFILE_ACCESS_DENIED`, or `IMPERSONATION_NOT_ALLOWED`.", "type": "string" }, "message": { @@ -7315,7 +7332,7 @@ }, "/v1/managed-profiles": { "get": { - "description": "Lists children owned by the authenticated active manager, newest first. The default filter returns only active children. Use `status=deleted` or `status=all` to include retained logical-deletion records.\n\n**Auth:** controlling manager Supabase Bearer session or secret API key. Public API keys and direct managed-child credentials are rejected.", + "description": "Lists children owned by the authenticated active manager, newest first, together with the manager's current corridor and customer-type policy. Policy is manager-scoped and applies to all children; it is not a per-child grant. The default filter returns only active children. Use `status=deleted` or `status=all` to include retained logical-deletion records.\n\n**Auth:** controlling manager Supabase Bearer session or secret API key. Public API keys and direct managed-child credentials are rejected.", "operationId": "listManagedProfiles", "parameters": [ { @@ -7362,7 +7379,7 @@ } } }, - "description": "A page of owned managed profiles and offset pagination metadata." + "description": "The authenticated manager's current policy, a page of owned managed profiles, and offset pagination metadata." }, "400": { "content": { @@ -9589,7 +9606,7 @@ } } }, - "description": "Quote ownership or managed-profile authorization failed." + "description": "Quote ownership, managed-profile authorization, or impersonation policy failed." }, "500": { "content": { @@ -9853,7 +9870,7 @@ } } }, - "description": "Ramp ownership or managed-profile authorization failed." + "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed." }, "500": { "content": { @@ -10132,7 +10149,7 @@ } } }, - "description": "Ramp ownership or managed-profile authorization failed." + "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed." }, "500": { "content": { diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index fdb0db2f7..5b592be9b 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -40,7 +40,7 @@ X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 A Supabase Bearer session may replace the secret key. A public `pk_*` value cannot authenticate delegation. Vortex verifies the active manager, direct active child relationship, child's single active customer entity, allowed country, optional customer-type narrowing, and canonical country/type support for corridor-bound mutations. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured non-empty list only narrows that matrix. The manager remains the authenticated actor; ownership, KYC/provider lookup, and ramp history resolve from the child subject. Quote pricing uses the child's active profile assignment when present, otherwise the controlling manager profile's active assignment, then default Vortex pricing. This precedence is identical for manager-delegated requests and direct child credentials. -The header is supported for quote creation; ramp registration, update, start, status, history, and errors; exact limits and sanitized ramp info; aggregate onboarding status; BR customer/KYC operations; and customer creation, KYC/KYB, and fiat-account operations on the AR, CO, MX, and US corridors. Corridor removal blocks mutations and disallowed exact-limit requests but not quote discovery or historical/status reads. The EUR corridor's flows are bound to a verified login email, so they and all recipient-invitation routes do not support managed children. +The header is supported for quote creation; ramp registration, update, start, status, history, and errors; exact limits and sanitized ramp info; aggregate onboarding status; BR customer/KYC operations; customer creation, KYC/KYB, and fiat-account operations on the AR, CO, MX, and US corridors; and sender-side recipient operations. Sender-side recipient operations are invite creation, recipient and pending-invitation listing, invitation archive/unarchive, recipient relationship updates, and recipient eligibility reads. These recipient operations currently require a Supabase Bearer session; an `sk_*` key does not authorize them. Invite preview and acceptance remain invitee-scoped and do not support `X-Managed-Profile-Id`; a headless managed child cannot authenticate as an invitee or accept an invitation. Corridor removal blocks mutations and disallowed exact-limit requests but not quote discovery or historical/status reads. The EUR corridor's flows remain bound to a verified login email and do not support managed children. `POST /v1/brl/kyc/import-token` is a deliberate exception to direct child credential access. A controlling manager may call it with the manager's secret key or Supabase session plus `X-Managed-Profile-Id`, but a credential owned by the managed child is rejected with `403 MANAGED_PROFILE_ACCESS_DENIED`, even without the selector. Direct non-managed profiles may import for themselves with their own secret key or session. Public keys and ownerless credentials cannot import. The legacy `/v1/brla/kyc/import-token` path remains an equivalent migration alias. @@ -59,7 +59,7 @@ An active manager may use its Supabase session or profile-bound secret credentia | Endpoint | Purpose | |---|---| | `POST /v1/managed-profiles` | Create an `individual` or `business` child from immutable `externalSubjectId` and provider `contactEmail` values | -| `GET /v1/managed-profiles` | List children; defaults to active records with `limit=50&offset=0` | +| `GET /v1/managed-profiles` | List children and the manager's current policy; defaults to active records with `limit=50&offset=0` | | `GET /v1/managed-profiles/:profileId` | Read an owned active or deleted child | | `DELETE /v1/managed-profiles/:profileId` | Logically delete an owned child and revoke its credentials | | `POST /v1/managed-profiles/:profileId/api-credentials` | Issue a child-owned public/secret credential pair | @@ -68,7 +68,7 @@ An active manager may use its Supabase session or profile-bound secret credentia Creation is not tied to one corridor and may create only an `individual` or `business` child. Every later corridor-bound operation checks the manager's current corridors, optional customer-type narrowing, and Vortex's canonical corridor/type support. Tightening policy blocks later authorization decisions but does not cancel a request already authorized or background processing for a ramp that already started. `POST` returns `201` for a new child and `200` for an identical retry. A deleted external subject remains reserved and cannot create a replacement child. Deletion is idempotent (`204`), preserves compliance and financial history, and blocks new child activity. -Lists accept `status=active|deleted|all`, `limit=1..100`, and a non-negative `offset`; the default status is `active`. Inactive managers lose create, list, read, delete, and delegated-operation access. Requests for another manager's child return `404` on lifecycle routes. +Lists accept `status=active|deleted|all`, `limit=1..100`, and a non-negative `offset`; the default status is `active`. The response always includes `manager.profileId`, `manager.allowedCorridors`, and `manager.allowedCustomerTypes` alongside `managedProfiles` and `pagination`. This policy belongs to the manager and applies to every child; it is not copied onto individual managed profiles. Inactive managers lose create, list, read, delete, and delegated-operation access. Requests for another manager's child return `404` on lifecycle routes. The child contact email is normalized and immutable, is unique among the manager's children, is used for provider customer creation, and never becomes a Supabase login identity. A deleted child's contact email remains reserved for that manager. Partners must supply an email identity they are authorized to use; uniqueness is not global across managers. A child-owned credential authenticates directly as that child without `X-Managed-Profile-Id`. Every use dynamically requires the active manager relationship; corridor-bound mutations and exact-limit reads use the controlling manager's current corridor/type policy. A direct child credential cannot select another managed child. Logical deletion immediately invalidates and revokes both halves. diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index 55df2aaf5..402517208 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -492,6 +492,7 @@ for (const [path, method, requiredStatuses] of MANAGED_PROFILE_OPERATIONS) { const createManagedProfile = operationAt("/v1/managed-profiles", "post"); const createManagedProfileResponses = createManagedProfile.responses as JsonObject; +const schemas = ((openapi.components as JsonObject).schemas ?? {}) as JsonObject; if ( JSON.stringify(createManagedProfile.requestBody).includes("#/components/schemas/CreateManagedProfileRequest") === false || JSON.stringify(createManagedProfileResponses["200"]).includes("#/components/schemas/ManagedProfileResponse") === false || @@ -509,6 +510,14 @@ const listParameter = (name: string): JsonObject | undefined => const limitSchema = listParameter("limit")?.schema as JsonObject | undefined; const offsetSchema = listParameter("offset")?.schema as JsonObject | undefined; const statusSchema = listParameter("status")?.schema as JsonObject | undefined; +const listManagedProfilesResponseSchema = schemas.ListManagedProfilesResponse as JsonObject; +const listManagedProfilesResponseProperties = (listManagedProfilesResponseSchema.properties ?? {}) as JsonObject; +const listManagedProfilesResponseRequired = Array.isArray(listManagedProfilesResponseSchema.required) + ? listManagedProfilesResponseSchema.required + : []; +const managerPolicySchema = schemas.ManagedProfileManagerPolicy as JsonObject; +const managerPolicyProperties = (managerPolicySchema.properties ?? {}) as JsonObject; +const managerPolicyRequired = Array.isArray(managerPolicySchema.required) ? managerPolicySchema.required : []; if ( limitSchema?.default !== 50 || limitSchema.maximum !== 100 || @@ -520,11 +529,25 @@ if ( ) { throw new Error("GET /v1/managed-profiles must document the controller's pagination and status defaults."); } +if ( + JSON.stringify(listManagedProfilesResponseProperties.manager) !== + JSON.stringify({ $ref: "#/components/schemas/ManagedProfileManagerPolicy" }) || + !listManagedProfilesResponseRequired.includes("manager") || + JSON.stringify(managerPolicyRequired.sort()) !== + JSON.stringify(["allowedCorridors", "allowedCustomerTypes", "profileId"].sort()) || + JSON.stringify((managerPolicyProperties.profileId as JsonObject)?.format) !== JSON.stringify("uuid") || + JSON.stringify(((managerPolicyProperties.allowedCorridors as JsonObject)?.items as JsonObject)?.enum) !== + JSON.stringify(["AR", "BR", "CO", "EU", "MX", "US"]) || + JSON.stringify((managerPolicyProperties.allowedCustomerTypes as JsonObject)?.type) !== JSON.stringify(["array", "null"]) || + JSON.stringify(((managerPolicyProperties.allowedCustomerTypes as JsonObject)?.items as JsonObject)?.enum) !== + JSON.stringify(["individual", "business"]) +) { + throw new Error("GET /v1/managed-profiles must return the required manager-scoped policy contract."); +} const createCredential = operationAt("/v1/managed-profiles/{profileId}/api-credentials", "post"); const createCredentialResponses = createCredential.responses as JsonObject; const listCredentialResponses = operationAt("/v1/managed-profiles/{profileId}/api-credentials", "get").responses as JsonObject; -const schemas = ((openapi.components as JsonObject).schemas ?? {}) as JsonObject; const apiCredentialProperties = ((schemas.ApiCredential as JsonObject).properties ?? {}) as JsonObject; const createCredentialRequestSchema = schemas.CreateApiCredentialRequest as JsonObject; const createCredentialRequestProperties = (createCredentialRequestSchema.properties ?? {}) as JsonObject; diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index 13b743dab..bb6a918c6 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -61,7 +61,7 @@ the normalized lifecycle `started`, `pending`, `in_review`, `approved`, or `reje while `status_external` preserves a provider's original value when one exists. Legacy placement caveat: the migration 040 backfill attached pre-cutover provider rows to -the profile's 038-backfilled *individual* entity — including business-typed rows. The +the profile's 038-backfilled _individual_ entity — including business-typed rows. The row's `customer_type` is therefore authoritative for type-scoped lookups; the owning entity's `type` is not. Typed provider lookups and ownership checks scope by profile, and new alfredpay rows co-locate with a profile's existing rows of the same `customer_type`. @@ -112,7 +112,10 @@ child-owned credentials through nested lifecycle routes. Logical deletion retain profile and its financial/compliance records, permanently reserves the manager-scoped external-subject and contact-email pairs, and revokes all child credentials. Delegated authorization is active on quote, ramp, limits, ramp-info, onboarding-status, Avenia, and Alfredpay -routes; recipient invitations remain unavailable to managed children. +routes, plus sender-side recipient list, invitation creation/archive, relationship mutation, +and eligibility. Invite preview and acceptance remain bearer-invitee operations and reject a +managed-child selector. The managed-profile list response includes the active manager's profile ID +and current corridor/customer-type policy even when no children match the list query. Migration 063 rollback locks both managed tables and refuses to proceed while either a child relationship or manager configuration exists, so manager policy cannot be silently @@ -136,25 +139,41 @@ Current product behavior and acknowledged gaps are in ## Authentication and ownership flow -1. Existing authentication accepts a valid secret API key or Supabase bearer token and - establishes the actor profile. +1. `requirePartnerOrUserAuth()` accepts a valid secret API key or Supabase bearer token. + 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. On delegated routes, `X-Managed-Profile-Id` selects a child profile. The authorization middleware verifies the active manager, direct active relationship, managed child, active child customer entity, configured corridor, optional customer-type narrowing, and canonical corridor/type capability for mutations. 3. `getEffectiveUserId()` uses the verified child subject when delegation is present; - otherwise it preserves the existing Supabase/API-credential resolution. + otherwise it uses the bearer principal or validated secret-key profile. For an + impersonation token, `req.userId` already reflects step 1's target substitution. 4. Ownership middleware scopes quotes, ramps, provider accounts, recipients, and history to that effective user and their customer entities. 5. 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 bypasses route authorization. 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. + The derived request context retains `actorProfileId`, `subjectProfileId`, `controllingManagerProfileId`, `customerEntityId`, and the manager-child relationship ID. It never overwrites `req.userId`, and a public API key cannot authenticate a manager. Alfredpay customer creation uses the child's immutable provider contact email, never the -manager's login email. Email-bound Mykobo and Monerium routes remain unsupported. +manager's login email. Email-bound Mykobo and Monerium routes remain unsupported. These legacy +routes ignore a managed selector and remain scoped to the authenticated manager, so managed clients +must not send that header to them. Child-owned credentials authenticate directly as the child. Public and secret validation derive the unique active manager relationship on every request; corridor-bound route @@ -176,8 +195,10 @@ 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,managedProfileAuth,ownershipAuth}.ts` +- Principal resolution: `apps/api/src/api/middlewares/{bearerPrincipal,dualAuth,effectiveUser,managedProfileAuth,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 - Managed-profile schema: `apps/api/src/database/migrations/063-create-managed-profiles.ts` - 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/` diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 23e471acd..890b8cef7 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -206,6 +206,14 @@ different set of endpoints than the widget. Covered so far: approved AlfredPay corridor creates a self payout account and updates the card/recipient state; disconnected wallet actions open AppKit's `Connect` view, while the connected address opens its `Account` view. The connected-wallet-only funding gate remains pinned. +- **Managed profiles** (`managed-profiles.spec.ts`): ordinary-user route denial, manager child + selection, persisted acting mode, route-scoped managed-profile headers, hidden manager-only + navigation, stopping child mode, and long-identifier mobile layout. + +Managed-child selection has unit coverage for persisted manager-bound selection, cross-tab changes, +route-scoped header attachment and authorization failure handling, transfer identity guards, and +owner-keyed payment recovery. API integration coverage exercises delegated recipient operations and +policy revalidation. Notes: @@ -227,7 +235,8 @@ Notes: - **Not covered**: the Avenia KYC liveness step, which redirects to an external Avenia-hosted page and cannot complete hermetically (the same limitation as the widget's BRL onramp); the permit/TokenRelayer cross-chain SELL variant, which needs relayer-contract execution the mock - does not model; and the overview/recipients/transactions tables. + does not model; the managed-profile transfer-signing switch guard at the browser level (covered + by transfer/store unit tests); and the overview/recipients/transactions tables. ### EUR re-enablement precondition diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 3ad5e1d69..f74c7b733 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -26,7 +26,10 @@ two people. self-offramps, and fiat-funded self-onramps for BRL, MXN, COP, USD, and ARS. Cross-border fiat-to-fiat transfers, recipient payability, and invited-recipient payout-instrument registration remain target-state rather than current behavior. EUR onramps remain unavailable while dashboard - onboarding uses Monerium but active EUR ramps resolve Mykobo. + onboarding uses Monerium but active EUR ramps resolve Mykobo. The API and dashboard implement + managed headless profiles and route-scoped manager delegation: active managers can select a child, + act through supported dashboard surfaces, and return to their own account without changing the + authenticated manager identity. ## User stories @@ -42,6 +45,9 @@ two people. - As a sender, I pick the corridors I care about (BR, EU, MX, CO, US, AR) and track only those. - As a sender, I complete KYC (individual) or KYB (company) per corridor from the dashboard. Monerium uses its hosted OAuth portal; after the callback exchange, the dashboard reopens the EU onboarding modal. +- KYC/KYB is owner-only in the dashboard. During admin impersonation or while a manager acts for + a managed child, onboarding cards and statuses remain visible, but start, continue, retry, and + provider re-authentication actions are disabled and onboarding deep links do not open a flow. - BR companies complete Avenia's hosted company and representative steps; MX/CO companies submit AlfredPay KYB details and documents in the dashboard; US companies use AlfredPay's hosted flow. AlfredPay company onboarding is not offered for AR until provider support is confirmed. @@ -141,6 +147,87 @@ two people. category — recipient-approval alerts — was dropped for now: no such notification type exists in the backend yet.) +### Managed profiles (implemented) + +This is managed-child delegation, not another login or admin impersonation mechanism. A managed +child is headless and has no Supabase identity. The manager remains the authenticated actor, and +supported API requests carry the selected child's profile ID in `X-Managed-Profile-Id`. The API +must continue to verify the active manager, direct active relationship, child entity, corridor, +and customer-type policy on every delegated authorization decision. + +- As an active managed-profile manager, I see **Managed profiles** in the sidebar. Ordinary users + do not see the item. Manager detection uses the authenticated manager lifecycle API rather than + a client-side role claim. +- As a manager, I can keep using the dashboard as my own account when no child is selected. +- As a manager, I open **Managed profiles** and see my active children. Each row identifies the + child by contact email and external subject ID, shows its immutable customer type, and shows the + corridors authorized for the manager. Corridors are manager policy, not per-child grants. +- As a manager, I use a row's three-dot menu to open a confirmation dialog and choose **Act for + this profile**. The product must not call this action “Log in as” or “Impersonate”. +- Confirming stores the selection, clears account-scoped query and notification state, disconnects + the displayed wallet session, and redirects to `/overview`. It MUST NOT clear ramp ephemerals, + payment instructions, ramp identifiers, or other recovery material owned by the manager or a + previously selected child. The selection persists across navigation and browser refreshes until + explicitly stopped, and is bound to the authenticated manager profile so it cannot survive a + change of login identity. +- While acting for a child, a persistent yellow banner above the topbar names the child and offers + **Stop acting**. Stopping clears the selection and returns to `/managed-profiles` under the + manager's own account. +- Entering child mode, switching children, or stopping child mode is blocked while the transfer + machine is in its client-owned preparation and signing sequence. This sequence starts when a + submitted transfer enters final quote/balance validation and includes ramp registration, + ephemeral signing, user-wallet signing or broadcast, and submission of the signed ramp update. + The selector and banner explain that the current signing step must finish or fail before the + identity can change; they never reset the machine to force the switch through. +- Once the ramp and all currently required signatures are durably submitted to the backend, an + identity change is allowed. A BUY awaiting payment keeps its payment instructions and ramp ID + under the originating manager/child identity. A started ramp continues on the backend and + remains discoverable in that identity's transaction history even if local polling stops. + Returning to the originating identity restores any resumable payment state. +- Transfer resume state is keyed by the effective owner identity (manager profile when acting as + self, otherwise managed child profile), not one global dashboard key. It must never be displayed, + resumed, or submitted under another selected identity. +- If the selected relationship is deleted, the manager is disabled, or authorization otherwise + becomes invalid, the dashboard clears child mode and returns to the manager's selection page + rather than silently retrying against the manager's own resources. + +**Child-mode navigation.** Onboarding status, Recipients, Get a quote, New transfer, Transactions, +and Limits remain available where their API routes support managed-child authorization. KYC/KYB +actions are read-only: a manager cannot start, continue, retry, or re-authenticate verification for +the child from the dashboard. Generic API keys, Settings and notification preferences, the admin +console, webhook management, and email-bound Monerium/Mykobo operations remain manager-scoped or +unavailable and must not be shown as child operations. The dashboard API client adds +`X-Managed-Profile-Id` only when a service explicitly opts into a supported delegated route; it +must never attach the header indiscriminately, because an endpoint that ignores it would otherwise +operate on the manager while the UI claims to show the child. + +The legacy Monerium and Mykobo routes are the known instance of that ignored-header behavior: they +always use the authenticated manager identity. Dashboard services do not opt them into managed +selection, and child-mode onboarding actions remain disabled. + +**Recipients in child mode.** The selected child is the sender and owns its invitations and +sender-recipient relationships. The manager may list recipients, create invitations, archive +invitations, update or archive relationships, and check eligibility on the child's behalf. +Invitation creation remains subject to the manager's corridor policy. Privileged discount +attachment checks the authenticated manager actor's `discount_manager` role rather than granting +that role to the child. Invite preview and acceptance are not delegated: those actions belong to +the authenticated invitee, and a headless managed child cannot accept an invitation. Recipient +management remains onboarding/advisory functionality until third-party recipient payout is +implemented; selecting a child does not make recipient-directed ramp registration available. + +**Composition with admin impersonation.** A `vortex_admin` may impersonate an authenticated +manager and then use that manager identity to select one of its direct managed children. These are +two separate states: stopping child selection returns to the impersonated manager's managed-profile +page, while exiting the admin impersonation session returns the operator to `/admin`. Direct admin +impersonation of a headless child remains unsupported. The admin account list and detail identify +managed rows by child contact email, show the controlling manager's email, and offer a composed +**Act as** action that starts the manager impersonation and immediately selects that child. +KYC/KYB remains read-only throughout direct or composed admin impersonation; the operator can inspect +the target's verification status but cannot open or mutate a provider verification flow. +Managed-child creation/deletion and manager/child credential creation/revocation are also blocked +during impersonation, while their list/read operations remain available. Alfredpay fiat-account +creation and deletion remain available by accepted operator policy. + ## High-level implementation strategy The dashboard is the same stack as the widget, and reuses its logic wherever the logic is @@ -194,6 +281,13 @@ provider-shaped rather than UI-shaped. transactions page omits the matching initial BUY ramp from history and offers **Resume payment** in a prominent standalone card. Resume affordances are scoped to the account that created the ramp; switching accounts does not expose its payment details. + Managed-child selection extends this rule by keying resumable snapshots to the effective owner + profile rather than using one global snapshot. Selection changes are forbidden while the machine + is in `CheckingQuote`, `CheckingBalance`, `Registering`, or `SigningUserTxs`. Once registration + and signing updates are durably accepted, its owner-scoped `AwaitingPayment` snapshot or backend + transaction record survives selection changes and is available again when that owner is selected. + Ramp ephemeral storage is independent recovery custody and is never pruned or cleared by + manager/child selection. The customer can return to the same instructions while the payment window remains open. Once the instructions expire, **Get a new quote** clears only the local transfer state. Starting an expired ramp remains rejected by the API. @@ -296,6 +390,62 @@ 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`. + +**Money movement is excluded, but the session is not generally read-only.** An operator may +create quotes and inspect ramp status, history, and errors, but the backend rejects ramp +registration, update, and start while impersonating. Other customer-account mutations remain +available. An impersonated request also 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. + +**Admin impersonation targets authenticated profiles only.** Managed headless profiles and their +manager-child relationships now exist as a separate delegation layer. An operator does not +impersonate a headless child directly: the operator may impersonate its authenticated manager and +then select the child through the same route-scoped managed-profile authorization used by that +manager. The dashboard implements that selector and child-mode experience as described above. + +**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, warning that money movement is disabled, 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 067 and 068 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 4453adb95..33a6a6b63 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, manage partners, or configure managed-profile managers. It uses a single shared secret (`ADMIN_SECRET` env var) compared via Bearer token. Read-only access to client @@ -30,10 +39,24 @@ 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. -8. **Only admin auth may configure managed-profile managers** — `PUT /v1/admin/managed-profile-managers/:profileId` creates or replaces activation and a non-empty set of supported corridors for an existing authenticated profile. It may also set `allowedCustomerTypes` to a non-empty, duplicate-free subset of `individual` and `business`; missing or null leaves customer types unrestricted beyond the canonical corridor capability matrix. `GET` reads that configuration. Deactivation uses `isActive = false`; configuration is retained rather than physically deleted. -9. **Admin headless provisioning MUST remain separate from legacy managed-user provisioning** — `POST /v1/admin/managed-profile-managers/:profileId/managed-profiles` requires admin auth and invokes the shared null-login-email provisioning service with `creation_source = vortex`. It requires an immutable provider `contactEmail` separate from the child's login identity, and the manager path parameter must identify an active configured manager. This route MUST NOT reuse or alter legacy `POST /v1/admin/managed-profiles`, which provisions an email-backed Supabase identity. -10. **Managed children MUST NOT become managers** — Manager configuration rejects `profiles.kind = managed`, preserving the direct, non-nested management model. +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 broad customer-account impersonation + ([`admin-impersonation.md`](admin-impersonation.md)). If the shared secret could grant that + role, it would be sufficient by itself to gain sensitive read and mutation 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 `. +9. **Only admin auth may configure managed-profile managers** — `PUT /v1/admin/managed-profile-managers/:profileId` creates or replaces activation and a non-empty set of supported corridors for an existing authenticated profile. It may also set `allowedCustomerTypes` to a non-empty, duplicate-free subset of `individual` and `business`; missing or null leaves customer types unrestricted beyond the canonical corridor capability matrix. `GET` reads that configuration. Deactivation uses `isActive = false`; configuration is retained rather than physically deleted. +10. **Admin headless provisioning MUST remain separate from legacy managed-user provisioning** — `POST /v1/admin/managed-profile-managers/:profileId/managed-profiles` requires admin auth and invokes the shared null-login-email provisioning service with `creation_source = vortex`. It requires an immutable provider `contactEmail` separate from the child's login identity, and the manager path parameter must identify an active configured manager. This route MUST NOT reuse or alter legacy `POST /v1/admin/managed-profiles`, which provisions an email-backed Supabase identity. +11. **Managed children MUST NOT become managers** — Manager configuration rejects `profiles.kind = managed`, preserving the direct, non-nested management model. ## Threat Vectors & Mitigations @@ -43,6 +66,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) | @@ -53,11 +77,12 @@ 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. - [x] Managed-profile manager configuration routes require `adminAuth`, validate non-empty duplicate-free corridor and optional customer-type sets, and retain deactivated configurations. **PASS** - [x] Admin headless provisioning requires `adminAuth`, targets an active configured manager, and remains distinct from legacy email-backed provisioning. **PASS** 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..523393ac7 --- /dev/null +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -0,0 +1,313 @@ +# 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). Authenticated profiles +are direct session targets. Managed headless profiles are reached by impersonating their +authenticated manager and composing that session with the existing managed-profile selector. + +Impersonation is not read-only. The operator may create quotes, inspect ramp and KYC/KYB status, +history, and errors, and perform customer-account mutations outside the protected boundaries. +Ramp registration/update/start and KYC/KYB initiation, submission, upload, retry, and OAuth actions +reject the request. Durable credential minting and revocation are denied, as are managed-child +creation and deletion. Alfredpay fiat-account creation and deletion remain deliberately available: +these provider-side payout-account mutations outlive the session and are part of the accepted +operator capability (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 account list; search matches login email, managed contact/external ID, or controlling-manager email; managed rows include child contact identity and controlling-manager identity | — | +| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — profile kind, managed relationship when present, entities, provider customers, KYC cases, and recent direct 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. + +For a managed child, the dashboard starts the session against the authenticated manager returned +by the account lookup, then stores the child profile ID as the managed-profile selection. The +impersonation audit target remains the manager. Delegated requests carry `X-Managed-Profile-Id` +and continue through the normal active-manager, direct-relationship, entity, customer-type, and +corridor authorization checks. No impersonation token directly targets a headless profile. + +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 with broad read and mutation rights, 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 both `POST /v1/api-credentials` and `POST + /v1/managed-profiles/:profileId/api-credentials`: a credential minted while acting as someone + else would outlive the 30-minute session and become a standing backdoor into the target or a + managed child. +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 broad read and mutation rights 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 068 use `ON DELETE RESTRICT`. Operators must resolve + retention/deletion policy explicitly instead of erasing security history through a profile + cascade. +16. **An impersonated request MUST NOT register, update, or start a ramp** — the three mutating + ramp routes apply `rejectImpersonation` after optional or required bearer authentication and + before managed-profile authorization or controller execution. During active maintenance, the + existing maintenance guard returns `503` before authentication; it still prevents controller + execution and mutation. Quote creation and ramp GET routes deliberately omit the impersonation + guard, so support operators can discover rates and inspect target-owned ramps without + initiating or advancing money movement. +17. **An impersonated request MUST NOT initiate or mutate KYC/KYB** — provider action routes apply + `rejectImpersonation` after principal resolution and before controller execution or multipart + buffering. Aggregate and provider status reads deliberately omit the guard so verification + status remains observable. Normal managed-profile API delegation remains supported; the + dashboard independently keeps KYC/KYB read-only while a manager acts for a child. +18. **An impersonated request MUST NOT mutate managed-child or credential lifecycle** — manager and + child credential creation/revocation plus managed-child creation/deletion apply + `rejectImpersonation`. Credential and managed-profile list/read operations remain available for + support inspection. This boundary prevents an operator session from minting a durable backdoor, + disabling integrations through credential revocation, or creating/deleting retained child + identities. Alfredpay fiat-account creation and deletion are intentionally outside this denial: + their durable provider-side mutation is explicitly accepted by RISK-018. + +## 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 for the target or a managed child while impersonating, which outlives the session | `rejectImpersonation` on both credential-creation routes (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) | +| Impersonation initiates or advances money movement | Operator calls ramp register, update, or start while acting as a customer | All three mutating ramp routes apply `rejectImpersonation` after principal resolution and before controller execution (Invariant 16); quote creation and ramp inspection remain available | +| 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 broad customer-account access | `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 + +- Ramp money movement, KYC/KYB actions, managed-child lifecycle, and credential lifecycle mutations + are denied, but recipient, active-entity, notification, and Alfredpay fiat-account mutations remain + available. A compromised operator account can therefore still make sensitive and durable changes + to a customer's account and provider-side payout accounts. + Tracked as an accepted risk in the risk register (RISK-018). +- 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 credential minting through `/v1/api-credentials` and the + managed-profile credential-creation route — **PASS** (`api-credentials.route.test.ts`). +- [x] `rejectImpersonation` blocks `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST + /v1/ramp/start`, while quote creation reaches normal validation and ramp history remains + readable — **PASS** (`ramp.route.test.ts`). +- [x] `rejectImpersonation` blocks Alfredpay, Avenia, Monerium, and Mykobo KYC/KYB action routes + during admin impersonation while aggregate status stays readable — **PASS** + (`provider-verification.route.test.ts`). +- [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 quote + operations and maintenance denials — **PASS** (`quote.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] Managed-child creation/deletion and manager/child credential creation/revocation reject + impersonation, while list/read operations remain available — **PASS** + (`api-credentials.route.test.ts`). +- [x] Alfredpay fiat-account creation and deletion remain available during impersonation by accepted + policy; KYC/KYB actions remain denied — **PASS** (`alfredpay.route.ts`; RISK-018). +- [x] The operator-facing frontend that consumes `/v1/admin-console/*` presents a + non-dismissible banner naming the impersonated account and warning that money movement is + disabled 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/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index e710e2c1f..13f8ccfa5 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -23,28 +23,28 @@ Every credential has a non-null `profile_id`. A null `partner_id` is profile-man ### Capability Matrix -| Operation | Public key | Secret key | Supabase session | -|---|---:|---:|---:| -| Create quote and apply attribution | Yes | Yes | Yes | -| Create widget session | Yes | Yes | Yes | -| Read sanitized `GET /v1/ramp-info` | Yes | Yes | No | -| Read exact used or remaining financial limits | No | Yes | Yes | -| Register, update, start, or read a ramp | No | Yes | Yes | -| Read ramp history or diagnostic error logs | No | Yes | Yes | -| Manage fiat/provider accounts | No | Yes | Yes | -| Act for an authorized managed child | No | Yes | Yes | -| Use a child-owned credential as the managed child | Public capabilities only | Yes | N/A | -| Manage a directly owned child's credentials | No | Yes | Yes | -| Manage webhooks | No | Yes | No | -| Create, list, or revoke profile-managed credentials | No | No | Yes | -| List or revoke partner-managed credentials of the session's own profile | No | No | Yes | -| Create partner-managed credentials, or manage another profile's | No | No | Admin | +| Operation | Public key | Secret key | Supabase session | +| ----------------------------------------------------------------------- | -----------------------: | ---------: | ---------------: | +| Create quote and apply attribution | Yes | Yes | Yes | +| Create widget session | Yes | Yes | Yes | +| Read sanitized `GET /v1/ramp-info` | Yes | Yes | No | +| Read exact used or remaining financial limits | No | Yes | Yes | +| Register, update, start, or read a ramp | No | Yes | Yes | +| Read ramp history or diagnostic error logs | No | Yes | Yes | +| Manage fiat/provider accounts | No | Yes | Yes | +| Act for an authorized managed child | No | Yes | Yes | +| Use a child-owned credential as the managed child | Public capabilities only | Yes | N/A | +| Manage a directly owned child's credentials | No | Yes | Yes | +| Manage webhooks | No | Yes | No | +| Create, list, or revoke profile-managed credentials | No | No | Yes | +| List or revoke partner-managed credentials of the session's own profile | No | No | Yes | +| Create partner-managed credentials, or manage another profile's | No | No | Admin | Possession of a public key never authorizes exact financial usage, provider identifiers, ramp history, diagnostics, or mutations. A corresponding secret key is stronger proof and may be accepted on public-key-capable routes. ### Credential Management -`POST`, `GET`, and `DELETE /v1/api-credentials` require a Supabase Bearer session and are owner-scoped to the session's profile: creation always mints profile-managed credentials, while listing and revocation cover every credential of that profile, partner-managed included. Creation generates both values in one transaction, returns the secret once, defaults to one-year expiry, and rejects expiry beyond two years. Listing returns one object per credential and never returns the secret value. +`POST`, `GET`, and `DELETE /v1/api-credentials` require a Supabase Bearer session and are owner-scoped to the session's profile: creation always mints profile-managed credentials, while listing and revocation cover every credential of that profile, partner-managed included. During admin impersonation, listing remains available but creation and revocation return `403 IMPERSONATION_NOT_ALLOWED`. Creation generates both values in one transaction, returns the secret once, defaults to one-year expiry, and rejects expiry beyond two years. Listing returns one object per credential and never returns the secret value. A profile may have at most five non-revoked, non-expired credentials. Creation locks the profile row and performs the active count and insert in one transaction, preventing concurrent requests from exceeding the cap. `DELETE /v1/api-credentials/:credentialId` updates the one row's `revoked_at`, atomically disabling both values without a request body or second key ID. @@ -97,24 +97,25 @@ Its response is an allowlisted per-corridor projection: 19. **Startup MUST fail closed**: after migrations and before listening, the API verifies required `api_credentials` columns, nullability, indexes, constraints, and that the legacy `api_keys` table is absent. Any failure prevents serving traffic. 20. **`ramp-info` MUST be subject-derived and sanitized**: it accepts no user selector and returns only the documented KYC state and buy/sell booleans. 21. **Managed-profile selection MUST be authorization-derived**: `X-Managed-Profile-Id` is accepted only on delegated routes after a Supabase session or secret credential establishes the manager actor. Secret-key middleware explicitly records the authenticated credential profile; delegated authorization MUST NOT infer authentication by inspecting `CredentialContext.strength`. Authorization requires an active manager, a direct active relationship, a managed child with exactly one customer entity matching its active entity, and, for policy-bound operations, every required corridor, canonical corridor/type support, and inclusion under any non-null manager customer-type narrowing. Null customer types add no restriction beyond the canonical matrix. The verified child becomes the effective operation subject without replacing the authenticated actor. A direct child credential cannot present the selector to act for another child. -22. **Managed-profile lifecycle MUST remain manager-scoped and logically deleted**: `POST/GET/DELETE /v1/managed-profiles` accepts only a Supabase session or secret credential whose subject is an active configured manager. Creation derives the manager from authentication, requires immutable `externalSubjectId`, `contactEmail`, and customer type values, rejects a customer type outside the manager's non-null `allowedCustomerTypes` narrowing rather than creating a child the manager could never operate, accepts no corridor grant, is idempotent by `(manager_profile_id, external_subject_id)`, and rejects reuse of a normalized `(manager_profile_id, contact_email)` by another child. Listing defaults to active children; direct reads may return retained deleted children. Foreign children return `404`. Deletion locks the child profile and relationship, atomically marks the relationship deleted and revokes all active child credentials, preserves customer/provider/KYC/ramp records, and returns `204` on repeated requests. Deleted external subject IDs and contact emails remain permanently reserved within that manager. Database triggers enforce the immutability of both `external_subject_id` and `contact_email`, so the identity a manager's records are keyed by cannot be reassigned after creation. -23. **Child credentials MUST remain relationship-controlled**: `POST/GET/DELETE /v1/managed-profiles/:profileId/api-credentials` requires the active controlling manager, scopes every operation by both manager and child, and is the only credential-issuance path that accepts a managed subject. It issues only `partner_id = NULL` credentials under the child's shared five-active-credential cap. Credential creation locks the child profile and relationship in the same order as logical deletion. Public and secret validation of a managed child's credential dynamically requires the unique relationship and manager to remain active. Corridor-bound routes apply the manager's current corridor and optional customer-type narrowing plus the canonical corridor capability matrix, and deletion revokes both halves. Direct child credentials cannot manage webhooks or manager lifecycle resources. Manager deactivation, relationship deletion, or policy changes block authorization decisions that begin after the change commits; they do not cancel requests already authorized and in flight. The retained relationship provides manager-level attribution; durable distinction between delegated-manager and direct-child-credential requests is not required unless credential-level attribution becomes a product requirement. +22. **Managed-profile lifecycle MUST remain manager-scoped and logically deleted**: `POST/GET/DELETE /v1/managed-profiles` accepts only a Supabase session or secret credential whose subject is an active configured manager. During admin impersonation, list/read operations remain available but creation and deletion return `403 IMPERSONATION_NOT_ALLOWED`. Creation derives the manager from authentication, requires immutable `externalSubjectId`, `contactEmail`, and customer type values, rejects a customer type outside the manager's non-null `allowedCustomerTypes` narrowing rather than creating a child the manager could never operate, accepts no corridor grant, is idempotent by `(manager_profile_id, external_subject_id)`, and rejects reuse of a normalized `(manager_profile_id, contact_email)` by another child. Listing defaults to active children and returns the active manager projection `{ profileId, allowedCorridors, allowedCustomerTypes }` even when the child list is empty; direct reads may return retained deleted children. Foreign children return `404`. Deletion locks the child profile and relationship, atomically marks the relationship deleted and revokes all active child credentials, preserves customer/provider/KYC/ramp records, and returns `204` on repeated requests. Deleted external subject IDs and contact emails remain permanently reserved within that manager. Database triggers enforce the immutability of both `external_subject_id` and `contact_email`, so the identity a manager's records are keyed by cannot be reassigned after creation. +23. **Child credentials MUST remain relationship-controlled**: `POST/GET/DELETE /v1/managed-profiles/:profileId/api-credentials` requires the active controlling manager, scopes every operation by both manager and child, and is the only credential-issuance path that accepts a managed subject. During admin impersonation, credential listing remains available but creation and revocation return `403 IMPERSONATION_NOT_ALLOWED`. It issues only `partner_id = NULL` credentials under the child's shared five-active-credential cap. Credential creation locks the child profile and relationship in the same order as logical deletion. Public and secret validation of a managed child's credential dynamically requires the unique relationship and manager to remain active. Corridor-bound routes apply the manager's current corridor and optional customer-type narrowing plus the canonical corridor capability matrix, and deletion revokes both halves. Direct child credentials cannot manage webhooks or manager lifecycle resources. Manager deactivation, relationship deletion, or policy changes block authorization decisions that begin after the change commits; they do not cancel requests already authorized and in flight. The retained relationship provides manager-level attribution; durable distinction between delegated-manager and direct-child-credential requests is not required unless credential-level attribution becomes a product requirement. + ## Threat Vectors & Mitigations -| Threat | Mitigation | -|---|---| -| Secret exposed in browser or telemetry | Public capability exists for browser use; secret values are server-only, returned once, and forbidden from logs/events. | -| Database read leaks usable secret | Only a high-entropy secret's SHA-256 digest and non-secret lookup prefix are stored. | -| Public key escalates to financial access | Route-level capability matrix rejects public keys from sensitive reads and mutations. | -| Caller supplies another manager's child ID | Delegated middleware scopes the active relationship by both authenticated manager and child profile before deriving an effective subject. | -| Public key from one credential is combined with another secret | Resolve both and return `403 CREDENTIAL_MISMATCH` before business logic. | -| Concurrent creation exceeds the cap | Lock the profile, count active non-expired credentials, and insert in one transaction. | -| Revocation leaves one half active | One row and one `revoked_at` update disable both values. | -| Partner deactivation leaves one half active | Public and secret validation both require the credential's partner to be active. | -| Legacy or ambiguous rows remain reachable | No legacy runtime lookup; migration 061 rejects active legacy rows and removes the table, and startup requires it to be absent. Production migration uses explicit immutable-ID mappings, never names. | -| Shared managed identity crosses customer ownership | Require one genuine managed profile per subject and immutable partner/external-user association. | -| Public eligibility read leaks PII or exact limits | `ramp-info` uses an explicit projection, accepts no body/query subject selector, and permits the managed-child header only with a manager secret. | -| Deleted or deactivated child credential remains usable | Both credential halves dynamically require the active relationship and manager; logical deletion also revokes every child credential. | +| Threat | Mitigation | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Secret exposed in browser or telemetry | Public capability exists for browser use; secret values are server-only, returned once, and forbidden from logs/events. | +| Database read leaks usable secret | Only a high-entropy secret's SHA-256 digest and non-secret lookup prefix are stored. | +| Public key escalates to financial access | Route-level capability matrix rejects public keys from sensitive reads and mutations. | +| Caller supplies another manager's child ID | Delegated middleware scopes the active relationship by both authenticated manager and child profile before deriving an effective subject. | +| Public key from one credential is combined with another secret | Resolve both and return `403 CREDENTIAL_MISMATCH` before business logic. | +| Concurrent creation exceeds the cap | Lock the profile, count active non-expired credentials, and insert in one transaction. | +| Revocation leaves one half active | One row and one `revoked_at` update disable both values. | +| Partner deactivation leaves one half active | Public and secret validation both require the credential's partner to be active. | +| Legacy or ambiguous rows remain reachable | No legacy runtime lookup; migration 061 rejects active legacy rows and removes the table, and startup requires it to be absent. Production migration uses explicit immutable-ID mappings, never names. | +| Shared managed identity crosses customer ownership | Require one genuine managed profile per subject and immutable partner/external-user association. | +| Public eligibility read leaks PII or exact limits | `ramp-info` uses an explicit projection, accepts no body/query subject selector, and permits the managed-child header only with a manager secret. | +| Deleted or deactivated child credential remains usable | Both credential halves dynamically require the active relationship and manager; logical deletion also revokes every child credential. | ## Audit Checklist diff --git a/docs/security-spec/03-ramp-engine/recipient-transfers.md b/docs/security-spec/03-ramp-engine/recipient-transfers.md index 591b1605b..ca5a82310 100644 --- a/docs/security-spec/03-ramp-engine/recipient-transfers.md +++ b/docs/security-spec/03-ramp-engine/recipient-transfers.md @@ -7,17 +7,18 @@ onboards (KYC/KYB via the widget) under their **own** profile, and the sender ma transfers (offramps) that pay out to that recipient. Backed by the migration-`042` tables (`recipient_invitations`, `sender_recipients`, `recipient_payout_references`), all anchored to `customer_entities`; migration-`050` added the sender-local `alias`, the retained raw `token`, -and `archived_at` to `recipient_invitations` (dropping the unused `amount`). Routes live under `/v1/recipients` (`recipients.controller.ts`), all behind -`requireAuth` (Supabase bearer token): +and `archived_at` to `recipient_invitations` (dropping the unused `amount`). Routes live under +`/v1/recipients` (`recipients.controller.ts`) behind Supabase bearer authentication. Sender-side +routes additionally accept an authorized managed-child selector; preview and acceptance do not: -| Endpoint | Purpose | -| :-- | :-- | -| `POST /v1/recipients/invite` | Sender creates an invite (with a sender-local `alias`); response returns the raw link token | -| `POST /v1/recipients/invite/:token/accept` | Authenticated recipient redeems the token | -| `GET /v1/recipients` | Sender lists relationships + pending invitations; pending items include the raw token for re-copy | -| `PATCH /v1/recipients/:id` | Sender sets nickname / `active` / `blocked` / `archived` (archived rows are excluded from the list) | -| `PATCH /v1/recipients/invitations/:id` | Sender archives/unarchives a pending invitation — a cosmetic list hide; the token stays redeemable (this is **not** revocation) | -| `GET /v1/recipients/:id/eligibility` | Transfer gate: `{ canCreateTransfer, blockingReasonCode? }` | +| Endpoint | Purpose | +| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------ | +| `POST /v1/recipients/invite` | Sender creates an invite (with a sender-local `alias`); response returns the raw link token | +| `POST /v1/recipients/invite/:token/accept` | Authenticated recipient redeems the token | +| `GET /v1/recipients` | Sender lists relationships + pending invitations; pending items include the raw token for re-copy | +| `PATCH /v1/recipients/:id` | Sender sets nickname / `active` / `blocked` / `archived` (archived rows are excluded from the list) | +| `PATCH /v1/recipients/invitations/:id` | Sender archives/unarchives a pending invitation — a cosmetic list hide; the token stays redeemable (this is **not** revocation) | +| `GET /v1/recipients/:id/eligibility` | Transfer gate: `{ canCreateTransfer, blockingReasonCode? }` | This matters because the sender↔recipient link is an authorization edge over money movement: a broken invite or scoping check lets an attacker attach themselves as someone's recipient, or pay @@ -44,14 +45,14 @@ out against another tenant's relationship. canonical (trimmed, lowercased) form, else `403 INVITE_EMAIL_MISMATCH`. 3. **Invites bind to one recipient, and expire.** A `pending` invite is redeemable by anyone holding the token (subject to 2). Once accepted it binds to `accepted_by_profile_id`: any - *other* profile presenting the token gets `409 INVITE_ALREADY_ACCEPTED`. Revoked/expired → - `410`. Expiry is 14 days (`INVITE_TTL_MS`); redemption of a *pending* invite past `expires_at` + _other_ profile presenting the token gets `409 INVITE_ALREADY_ACCEPTED`. Revoked/expired → + `410`. Expiry is 14 days (`INVITE_TTL_MS`); redemption of a _pending_ invite past `expires_at` transitions the row to `expired`; sender listing performs the same transition and includes expired rows without a token so the sender can see why the link stopped working. This holds under concurrency: the acceptance transaction re-reads the invitation `FOR UPDATE` and re-checks acceptance/revocation under the lock, so two profiles redeeming the same token simultaneously produce exactly one relationship (integration-tested with parallel accepts). -3a. **Re-entry.** The accepting recipient may re-present the token to resume onboarding: the accept + 3a. **Re-entry.** The accepting recipient may re-present the token to resume onboarding: the accept endpoint is idempotent for that profile, returning `200` with the existing relationship instead of `201`. Re-entry does not re-notify the sender, does not move `accepted_at`, and does not revive an `archived` relationship. It survives `expires_at` passing — the relationship already @@ -72,16 +73,17 @@ out against another tenant's relationship. different rail — returns `409 RELATIONSHIP_BLOCKED` and leaves the invite `pending`; only `archived` reactivates. Acceptance (entity resolve + relationship upsert + invite state) runs in one transaction. -5a. **Relationships are per payout rail.** `sender_recipients` is unique on + 5a. **Relationships are per payout rail.** `sender_recipients` is unique on `(sender, recipient, COALESCE(rail, '*'))` (migration 054; `rail` backfilled from the linked invitation): the same pair holds one relationship row per corridor, each keeping its own - `invitation_id`. Accepting an invite on a *new* rail adds a row; accepting on an already-linked + `invitation_id`. Accepting an invite on a _new_ rail adds a row; accepting on an already-linked rail repoints that rail's row (a renewal). Before the split, a second-rail acceptance repointed - the pair's single row, silently dropping the first corridor from the sender's list *and* its + the pair's single row, silently dropping the first corridor from the sender's list _and_ its invitation-derived transfer-eligibility gate. -6. **All sender-side routes are entity-scoped.** List/PATCH (relationship and invitation - archive)/eligibility resolve the caller's `customer_entity` from `req.userId` and filter on - `sender_customer_entity_id`; foreign ids +6. **All sender-side routes are entity-scoped.** Create/list/PATCH (relationship and invitation + archive)/eligibility resolve the sender profile through `getEffectiveUserId` and filter on + `sender_customer_entity_id`; for managed delegation this is the authorization-verified child, + while the authenticated manager remains the actor. Foreign ids return a uniform `404`. Entity resolution is deterministic: a partial unique index on `customer_entities (profile_id, type)` (migration 049) makes the acceptance-path `findOrCreate` race-safe, and `getOrCreateCustomerEntityForProfile` resolves type-less @@ -104,8 +106,10 @@ out against another tenant's relationship. provider cannot onboard (`400 UNSUPPORTED_INVITEE_TYPE`, e.g. AR business — Alfredpay has no AR company KYB), and senders with no approved onboarding anywhere (`403 NO_APPROVED_CORRIDOR`; approvals are read from `provider_customers.status`, which every - provider persists). The dashboard's corridor filter is a UX mirror of these rules, not the - enforcement point. + provider persists). For delegated managed senders, malformed input retains the same `400` + errors before authorization, then the requested country must be in the manager's current + `allowedCorridors` and valid for the child's immutable type. The dashboard's corridor filter is + a UX mirror of these rules, not the enforcement point. 10. **Sender self accounts are not recipient payout references.** For Alfredpay self offramps, the dashboard lists and creates provider-side fiat accounts owned by the authenticated sender and registration carries their `fiatAccountId` in `additionalData`. This does not create a @@ -116,7 +120,7 @@ out against another tenant's relationship. (`buyBps`/`sellBps`, integers `0..configuredMaximum`, where the deployment setting `RECIPIENT_INVITE_MAX_DISCOUNT_BPS` defaults to and can never exceed the immutable application hard cap of `300`; `0` means none) - only from profiles holding the `discount_manager` role in `profile_roles` + only when the authenticated actor holds the `discount_manager` role in `profile_roles` (`403 DISCOUNT_ROLE_REQUIRED` otherwise — the role check is server-side, the dashboard's field visibility is UX only). Validated seeds are stored on `recipient_invitations.seeded_discounts` as `{ rampType, fiatCurrency, bps }[]`, with @@ -160,16 +164,25 @@ out against another tenant's relationship. `recipientRelationshipId`, and `recipientPayoutReferenceId` in `additionalData` with `400` instead of silently ignoring them. No eligibility response authorizes money movement. Enabling recipient payout requires a separately reviewed registration schema, ownership and - eligibility enforcement, and provider-side payout-instrument resolution. -13. **Recipient invitation operations do not support managed-profile selection.** Every - `/v1/recipients` route rejects `X-Managed-Profile-Id` rather than silently applying the - request to the authenticated manager. Managed children have no invitation acceptance - contract; their active customer entity is fixed during provisioning. + eligibility enforcement, and provider-side payout-instrument resolution. +13. **Managed delegation is sender-only.** `POST /invite`, sender list, invitation archive, + relationship update/archive, and eligibility accept an authorized `X-Managed-Profile-Id` and + operate on the child's sender entity. Every delegated decision revalidates the active direct + relationship and the manager's current customer-type narrowing. Creation authorizes the + requested corridor; listing omits records outside the manager's current corridor policy; + invitation archive, relationship update/archive, and eligibility first resolve an owner-scoped + target and authorize its stored invitation country (or the relationship rail for retained + legacy rows) before acting. A foreign target remains `404`, including when its corridor is + currently denied, and target resolution never falls back to the manager's own entity. + `GET /invite/:token` preview and + `POST /invite/:token/accept` explicitly reject the selector with + `400 MANAGED_PROFILE_UNSUPPORTED`; redemption always uses the bearer-authenticated invitee + profile and never a managed child selected by its manager. ### Ramp registration vs. the recipient model — intentionally out of scope Ramp registration today is structurally a **self-offramp** flow, and (post ownership -enforcement) payout destinations are already bound to the *sender* on two of three corridors — +enforcement) payout destinations are already bound to the _sender_ on two of three corridors — verified against the code: - **Mykobo/EUR** (`MykoboOfframpPayout.register`): the withdraw intent is created for the sender's own @@ -212,7 +225,7 @@ and attempts to attach recipient context to registration are rejected. No code p - **Intercepted link redeemed by the wrong party**: optional email binding rejects mismatched accounts; unbound links are deliberately bearer-redeemable (shareable-link product) and rely on TTL + first-redeemer binding + sender review of the resulting relationship. An intercepted link - is only useful *before* the intended recipient redeems it; after that it is inert to everyone + is only useful _before_ the intended recipient redeems it; after that it is inert to everyone else (invariant 3). - **Cross-tenant access to relationships**: entity-scoped queries; PATCH/eligibility of a foreign relationship returns `404` (no existence oracle). diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 309c2aa29..767173e72 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -45,7 +45,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. The country-prefixed aliases `/v1/mx/*`, `/v1/co/*`, and `/v1/ar/*` mount the same authenticated router as `/v1/alfredpay/*`; on those aliases, the path country is canonical and replaces any query or body country before validation and corridor authorization. The legacy `/v1/alfredpay/*` prefix remains available and continues to require the country in the request. -**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a manager-selected child or direct child credential uses the child's provider records. `GET /alfredpayStatus` accepts an optional `type` selector so headless business flows resolve the business customer explicitly; omitting it retains the active-entity lookup used by existing UI consumers. Managed-child mutations require the controlling manager's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the manager's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. Provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. +**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a manager-selected child or direct child credential uses the child's provider records. `GET /alfredpayStatus` accepts an optional `type` selector so headless business flows resolve the business customer explicitly; omitting it retains the active-entity lookup used by existing UI consumers. Managed-child mutations require the controlling manager's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. KYC/KYB action routes reject admin impersonation while status and business-detail reads remain available. Fiat-account creation and deletion deliberately remain available during admin impersonation as an accepted durable operator capability under RISK-018. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the manager's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. Provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. ## Security Invariants @@ -74,7 +74,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 22. **Uploaded filenames MUST be sanitized to ASCII before reaching Alfredpay** — `AlfredpayApiService` rewrites the multipart filename of every KYC/KYB upload to `[A-Za-z0-9._-]` (accents transliterated, everything else replaced) rather than forwarding the name the user's file happened to carry. Alfredpay's relate-person endpoint answers a non-ASCII filename with a bare 5xx `111301 UNKNOWN_ERROR` that names no field, which stranded MX company onboardings at the representative's ID upload. The trigger is invisible: macOS separates the time from AM/PM with U+202F, so `Screenshot 2026-07-09 at 12.23.56 PM.png` is rejected while the same name retyped with an ordinary space is accepted, and accented filenames fail for the same reason — the provider stores every upload under a generated `{uuid}.{ext}`, so the submitted name is discarded on arrival and nothing is lost by rewriting it. This also keeps user-controlled text out of a downstream `Content-Disposition` header. The sanitizer MUST copy the bytes into a new `File`: under Bun, `new File([file], name)`, `new Blob([file])` and `FormData.append(field, file, name)` all alias or ignore their way back to the original name, so the guarantee is asserted on the value that reaches the wire (`alfredpayApiService.test.ts`), not on the helper alone. 23. **Dashboard Alfredpay BUY confirmation MUST only start processing, never assert settlement** — The dashboard renders the server-issued MXN/USD/COP/ARS payment instructions after registration and keeps the ramp unstarted. `I have made the payment` may call `/ramp/start`, but token crediting still depends on Alfredpay's independently verified payment status; the client confirmation is not proof of payment. 24. **Reported Alfredpay usage MUST be user-scoped and provider-leg denominated** — `POST /v1/limits` derives the effective user from authentication and counts only that user's ramps whose `complete` phase-history timestamp falls in the current UTC calendar month. Routed BUY usage is the Alfredpay fiat input; routed SELL usage is `metadata.blocks.alfredpayOfframp.inputAmountDecimal` in `ALFREDPAY_EVM_TOKEN`, not the public source-token amount. This informational aggregate is cached in memory for 60 seconds; quote-time limit enforcement never reads that cache. Alfredpay does not document whether its cumulative quota resets by calendar month or uses a rolling window, so the calendar-month period is an explicit Vortex assumption rather than provider-confirmed semantics. -25. **Managed Alfredpay operations MUST remain child-, type-, and corridor-scoped** — Customer creation, KYC/KYB, and fiat-account routes may use a manager-selected child or direct child credential as the effective profile. Before provider mutations, authorization requires the country corridor, the child's immutable entity type to match any route-specific KYC/KYB type, the canonical corridor capability matrix to support that type, and any current manager `allowedCustomerTypes` narrowing to include it; null policy adds no narrowing. Status, business-detail, and fiat-account reads do not require current mutation policy. Multipart routes authenticate the relationship and route-specific entity type before buffering, but enforce the country policy after `multer` parses the body. Individual/business customer creation MUST use the normalized immutable `managed_profiles.contact_email`, never the manager's login email. `(manager_profile_id, contact_email)` MUST remain unique, including deleted relationships, so one manager cannot provision multiple children against the same Alfredpay email identity. Conflict recovery MUST reject a found provider customer whose country or type differs from the request, and MUST reject one already claimed by a different profile — a manager chooses its child's contact email and Alfredpay identifies customers by that email, so adopting on email alone would transfer another profile's verification state. The authorized country corridor MUST be resolved from a source the handler itself reads: a request carrying a different `country` in its query string and body is rejected before authorization instead of being authorized on one value and executed on the other. +25. **Managed Alfredpay operations MUST remain child-, type-, and corridor-scoped** — Customer creation, KYC/KYB, and fiat-account routes may use a manager-selected child or direct child credential as the effective profile. Before provider mutations, authorization requires the country corridor, the child's immutable entity type to match any route-specific KYC/KYB type, the canonical corridor capability matrix to support that type, and any current manager `allowedCustomerTypes` narrowing to include it; null policy adds no narrowing. Status, business-detail, and fiat-account reads do not require current mutation policy. Multipart routes authenticate the relationship and route-specific entity type before buffering, but enforce the country policy after `multer` parses the body. Admin impersonation is rejected before KYC/KYB provider actions or multipart buffering; fiat-account creation and deletion are the explicit accepted exception. Individual/business customer creation MUST use the normalized immutable `managed_profiles.contact_email`, never the manager's login email. `(manager_profile_id, contact_email)` MUST remain unique, including deleted relationships, so one manager cannot provision multiple children against the same Alfredpay email identity. Conflict recovery MUST reject a found provider customer whose country or type differs from the request, and MUST reject one already claimed by a different profile — a manager chooses its child's contact email and Alfredpay identifies customers by email, so adopting on email alone would transfer another profile's verification state. The authorized country corridor MUST be resolved from a source the handler itself reads: a request carrying a different `country` in its query string and body is rejected before authorization instead of being authorized on one value and executed on the other. 26. **A terminal verification outcome MUST be queued for notification before it is persisted** — Alfredpay publishes no verification webhook, so every observer that can make the customer terminal — the dashboard's shared refresh, `AlfredpayStatusWorker`, `/alfredpayStatus`, and `/getKycStatus` — MUST enqueue before its status write. An account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. A failure must leave the account non-terminal so a later poll retries both. The notification key is `(alfredpay, verification_*, submissionId)`, which makes retries and racing observers idempotent. See `resend.md` invariant 13. 27. **The background verification sweep MUST be bounded, fair, and MUST NOT poll accounts it cannot notify** — `AlfredpayStatusWorker` costs two to three Alfredpay calls per account (submission-id resolution, then status). It MUST bound the sweep by account age (60 days on `provider_customers.updatedAt`, since an account abandoned mid-wizard never reaches a terminal status) and by batch size. A stable keyset cursor advances after every full page and wraps at the end; repeatedly selecting only the newest page would starve older eligible accounts. Entities with a null `profile_id` are partner-owned and have no profile to email; they MUST be excluded in the query so they never consume provider requests. Only the `mykobo` flow-variant backend owns the provider status workers, and each cron uses `waitForCompletion`, preventing duplicate cross-backend polls and overlapping same-process cycles. 28. **Alfredpay offramp pricing observations MUST remain source-labelled, while executable provider terms may reconcile the local SELL deposit** — The persisted block metadata records the exact Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. Alfredpay's rate MUST NOT become a general Vortex reference source. Its executable `fromAmount`/`toAmount` may be used only inside `AlfredpayOfframp` to solve or cap the provider deposit needed for the Vortex-derived customer target. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index fca0d94f1..b381c3a48 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -172,7 +172,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 23. **BRL Base destination variants MUST use token-specific static topology** — Base USDC MUST omit Squid entirely. Other configured non-BRLA Base outputs MUST execute exactly one same-chain `squidRouterSwap` phase before `destinationTransfer`; transaction preparation MUST use the Base builder, omit `squidRouterPay` and backup transactions, and allocate `destinationTransfer` at the nonce immediately after the Squid swap. BRLA remains the direct bypass in invariant 14. 24. **Dashboard BRL BUY confirmation MUST not bypass PIX verification** — The dashboard displays the server-generated `depositQrCode`, keeps the ramp unstarted, and calls `/ramp/start` only after the user confirms submitting PIX. That click is not proof of settlement; `brlaOnrampMint` must still verify the Avenia/Base balance before advancing. 25. **Unified BRL limit reads MUST use the authenticated user's provider account** — `POST /v1/limits` MUST derive the Avenia subaccount through `resolveAveniaAccountForUser`; it MUST NOT accept a caller-supplied tax ID or subaccount. BRL `max`, `used`, year, and month are mapped directly from Avenia's BRL fiat-in/fiat-out limit row. Tax IDs and provider subaccount IDs are never returned. -26. **Managed BRLA operations MUST remain child-, type-, and corridor-scoped** — Supported customer, KYC/KYB, and onboarding-status routes may derive the effective user from a verified manager selector or direct child credential. Mutating provider/KYC operations require the controlling manager's current `BR` corridor, any current manager customer-type narrowing, and canonical `BR` support for the child's immutable entity type; status and account reads preserve access after policy removal. Tax IDs and subaccount IDs still require ownership through the child's customer entities. Subaccount creation MUST reject a requested account type that differs from the child's provisioned customer-entity type before calling Avenia, preventing a managed child from acquiring a second entity. Individual document-upload and KYC-submission routes MUST require an individual managed profile, and their controllers MUST independently reject a non-individual owned Avenia row before any provider call. `GET /v1/brla/validatePixKey` remains an anonymous preflight utility rather than a managed-child operation: presenting a credential grants no capability unavailable to an anonymous caller, so managed corridor policy does not apply to it. +26. **Managed BRLA operations MUST remain child-, type-, and corridor-scoped** — Supported customer, KYC/KYB, and onboarding-status routes may derive the effective user from a verified manager selector or direct child credential. Mutating provider/KYC operations require the controlling manager's current `BR` corridor, any current manager customer-type narrowing, and canonical `BR` support for the child's immutable entity type; status and account reads preserve access after policy removal. Tax IDs and subaccount IDs still require ownership through the child's customer entities. Subaccount creation MUST reject a requested account type that differs from the child's provisioned customer-entity type before calling Avenia, preventing a managed child from acquiring a second entity. Individual document-upload and KYC-submission routes MUST require an individual managed profile, and their controllers MUST independently reject a non-individual owned Avenia row before any provider call. Admin impersonation is rejected before route-level validation, controller execution, and provider access; token import additionally rejects before its route-local JSON parser. `GET /v1/brla/validatePixKey` remains an anonymous preflight utility rather than a managed-child operation: presenting a credential grants no capability unavailable to an anonymous caller, so managed corridor policy does not apply to it. 27. **Avenia API KYB mutations MUST be ownership-bound and document-gated** — `/v1/brla/kyb/documents`, `/v1/brla/kyb/ubos`, and `/v1/brla/kyb/new-level-1/api` accept Supabase sessions or profile-bound secret API credentials. Every operation resolves the supplied subaccount to an Avenia business `provider_customers` row owned by one of the effective profile's customer entities before calling Avenia. UBO identification/selfie documents and final-submission corporate documents are fetched from that same subaccount and must be provider-ready with the expected document type. Binary bytes are uploaded directly to Avenia's short-lived pre-signed URL; Vortex does not proxy or persist them. 28. **Avenia API KYB retries MUST reconcile an active provider attempt before creating another** — A successful API submission binds the returned attempt ID to the existing KYB case, sets both canonical rows to `pending`, records external `PENDING`, and clears prior rejection fields. After the POST, the binding transaction locks and rereads the provider customer before the exact case. If concurrent reconciliation already bound the returned attempt, its newer pending, processing, or terminal state remains unchanged; a different concurrent attempt binding fails closed. Before the POST, Vortex lists attempts through the already ownership-verified business account's `provider_subaccount_id`. Exactly one company-level attempt in `PENDING` or `PROCESSING` is transactionally bound to the case and mirrored to both canonical rows, and the endpoint returns that attempt ID without another POST. Company-level names are matched as a family — legacy `level-1` plus every `kyb-level-1` generation (currently `kyb-level-1-v2`) — because Avenia renames the level across generations and an exact-name filter silently stops matching; other level names are excluded, and the nightly Avenia contract test pins the observed family. The same reconciliation runs after a definitive provider `409`. Zero active attempts after a conflict, multiple active attempts, malformed responses, and terminal attempts fail closed; unrelated provider and transport errors are propagated. When no active attempt exists during preflight, terminal attempt retry eligibility remains Avenia's decision on the single subsequent POST. 29. **The Avenia webhook MUST reject any body whose RSA-PSS signature does not verify** — Verification runs against the raw request bytes before the payload is parsed or any lookup happens. An absent `Signature` header, a non-buffer body, or a failed verify MUST return 401 and MUST NOT enqueue anything. diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 082349751..f7fadfac3 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -28,6 +28,8 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec 16. Local `authorization_started` and Monerium `created` and `incomplete` profiles MUST map to `started`; only provider `pending` is displayed as in review. 17. Missing app-specific Monerium authorization MUST surface as `MONERIUM_REAUTHENTICATION_REQUIRED` on the affected onboarding account without failing aggregate status loading. 18. Starting reauthorization for an account that already has a bound Monerium profile MUST preserve its canonical verification status. The account status changes to `started` only before the first profile is bound. +19. Admin impersonation MUST NOT start or complete Monerium OAuth. `GET /status` remains available so an operator can inspect the target's persisted verification state. +20. Managed-profile selection is unsupported on these legacy routes. `X-Managed-Profile-Id` is ignored and every operation remains scoped to the Supabase-authenticated manager. Managed clients MUST NOT send the selector; the dashboard omits it and disables Monerium actions in child mode. ## Threat Vectors & Mitigations @@ -63,3 +65,5 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec - [x] Production configuration requires the client ID and exact callback URI. - [x] Persisted terminal statuses remain available after restart; pending profiles require reauthorization when credentials are lost. - [x] Pending Monerium profiles refresh through dashboard onboarding polling without making aggregation depend on provider availability. +- [x] OAuth start and completion reject admin impersonation while status remains readable. +- [x] Dashboard Monerium requests omit managed selection and child mode disables Monerium actions; the legacy API remains manager-scoped if a direct client supplies the ignored selector. diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index f2af55f9e..c3fda84c0 100644 --- a/docs/security-spec/05-integrations/mykobo.md +++ b/docs/security-spec/05-integrations/mykobo.md @@ -86,7 +86,7 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do 12. **`mykoboPayoutOnBase` MUST not advance until both the on-chain transfer is confirmed and Mykobo reports `COMPLETED`** — Confirming only the on-chain side would mark the ramp complete while Mykobo could still reject the deposit. 13. **`MykoboTransactionStatus` of `FAILED` / `CANCELLED` / `EXPIRED` MUST be treated as unrecoverable** — The handler throws via `createUnrecoverableError` so the ramp transitions to a failed state instead of looping. 14. **Recovery on resumed `mykoboPayoutOnBase` MUST detect existing tx hashes** — If `mykoboPayoutTxHash` is in state, the handler waits for that receipt rather than blindly re-broadcasting. If the prior tx reverted, the same presigned tx is re-broadcast — EVM nonce uniqueness prevents double-spend of the ephemeral's EURC. -15. **Mykobo KYC profile creation MUST be gated by Vortex auth** — The `/v1/mykobo/profiles` endpoints require a Supabase OTP session (see `01-auth/supabase-otp.md`); anonymous profile creation is rejected. +15. **Mykobo KYC profile creation MUST be gated by direct Vortex auth** — The `/v1/mykobo/profiles` endpoints require a Supabase OTP session (see `01-auth/supabase-otp.md`); anonymous profile creation is rejected, and admin impersonation may read profile status but MUST NOT submit a profile or KYC documents. Managed-profile selection is unsupported on these legacy routes: `X-Managed-Profile-Id` is ignored and the operation remains scoped to the Supabase-authenticated manager. Managed clients MUST NOT send the selector; the dashboard omits it and disables Mykobo actions in child mode. 16. **Mykobo KYC documents MUST NOT be stored by Vortex** — The frontend submits ID and source-of-funds files directly to the backend, which forwards them to Mykobo as multipart form-data without persisting. No Mykobo profile fields are stored in Vortex's database beyond the email→profile linkage (the KYC mirror lives in `provider_customers`, `provider = 'mykobo'`, keyed by the owning `customer_entity`; `provider_customer_id` holds the last-synced email) used to look up profile state. Profile submission first records canonical `started`; a failed creation or missing/unknown profile state maps to `pending`; provider `pending`, `approved`, and `rejected` map to `in_review`, `approved`, and `rejected` respectively. 17. **Mykobo HTTP responses MUST be validated** — `MykoboApiService.request` checks `response.ok`, raises `MykoboApiError` with status + body on failure, and re-acquires the token on `401` exactly once before re-throwing. `MykoboApiError` MUST be caught and translated to `RecoverablePhaseError` (transient) or `UnrecoverablePhaseError` (terminal status) at the handler boundary. 18. **Mykobo bearer-token refresh MUST be safe under concurrent requests** — `MykoboApiService.tokenPromise` debounces concurrent `acquireToken` calls so multiple in-flight requests share a single token acquisition. Token refresh is single-use per cached token; on refresh failure the service falls back to re-acquiring with the access/secret keys. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 0528de966..e7f7cd62b 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -12,11 +12,13 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - Cookie parser: Enabled (for Supabase auth tokens) **Input validation** (`middlewares/validators.ts`): + - Hand-written validators for each endpoint (no schema library like Zod/Joi) - Validators check field presence, type, and basic format (e.g., valid address, valid enum) - Applied as Express middleware on route definitions **Error handling** (`middlewares/error.ts`): + - Global error handler converts all errors to `APIError` format - Stack traces stripped in non-development environments - 404 handler for unmatched routes @@ -24,22 +26,25 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - Fiat-provider failures raised while handling the mutating ramp endpoints (`POST /v1/ramp/register`, `POST /v1/ramp/update`, `POST /v1/ramp/start`) are normalized before they reach the caller (`mapProviderFailure` in `controllers/ramp.controller.ts`). Both providers throw a `ProviderHttpError` (`BrlaApiError` for Avenia/BRLA, `AlfredpayApiError` for Alfredpay; base class in `packages/shared/src/services/providerHttpError.ts` — named to avoid colliding with the price-layer `ProviderApiError` in `api/errors/providerErrors.ts`), covering both non-ok HTTP responses and transport failures (DNS/timeout/connection reset, carried as `status: 0`). The handler maps these to a `422` (upstream `4xx` — account/request rejected) or `502` (upstream `5xx`/transport — provider unavailable) with a generic "payment provider" message. The raw upstream body (e.g. `{"error":"user is blocked"}`) is **never** forwarded to the caller; it is logged server-side only, **truncated** to 300 chars, alongside the failing `provider`/`endpoint`/`method`/`status` (never query parameters, which may carry a PIX key or other PII) so operators can pinpoint which provider call failed and why. This context is embedded in the error log message itself (`formatProviderContext`) because the app logger (`config/logger.ts`) formats only `{ timestamp, level, message, label }` and drops metadata objects. The Avenia and Alfredpay controllers under `controllers/` handle their own errors inline and do not route through this path. **Request correlation and client observability** (`api/observability/`): + - Incoming requests receive or propagate a non-secret request ID. - The API returns `X-Request-ID` so clients can include it in support/debug reports. - Partner-facing quote/ramp/auth outcomes are recorded as sanitized operational events; see `07-operations/client-observability.md`. **Unified API credentials** (`api/services/apiCredential.service.ts`): + - One `api_credentials` row contains the public value and secret digest/prefix for one profile subject and optional partner. - Public clients send `X-Public-Key`; secret clients send `X-API-Key`. If both are present, they must resolve to the same credential or the API returns `403 CREDENTIAL_MISMATCH`. - Public capability is limited to quote/widget attribution and the sanitized `GET /v1/ramp-info` projection. Exact limits, ramp details/history/errors, provider-account operations, mutations, and webhooks require secret or session capability. - Startup runs schema/index/constraint checks and refuses to listen while the legacy `api_keys` table exists. There is no legacy authentication fallback. **Maintenance-window enforcement** (`middlewares/maintenanceGuard.ts`): + - Active maintenance windows are sourced from the `maintenance_schedules` table via `MaintenanceService`. - During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. - Rejections include `Retry-After`, `Cache-Control: no-store`, and downtime metadata (`maintenance_start`, `maintenance_end`, affected operations) in the error payload so direct API clients can pause and retry after the window. -**Route structure:** 41 `*.route.ts` files under `api/routes/` (34 under `v1/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. +**Route structure:** 43 `*.route.ts` files under `api/routes/` (36 under `v1/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. **Multipart uploads:** Four operations use in-memory Multer buffering. Alfredpay's `POST /v1/alfredpay/submitKycFile`, `submitKybFile`, and `submitKybRelatedPersonFile` (also mounted under the country aliases `/v1/mx`, `/v1/co`, and `/v1/ar`) allow one file up to 5MB; secret/Bearer authentication and the managed relationship/entity-type gate run before buffering, while multipart country authorization runs after parsing. On a country alias, the path-derived country replaces any multipart country field before that authorization. Mykobo's `POST /v1/mykobo/profiles` is Supabase-authenticated before buffering and accepts up to four named files (`front`, `back`, `face`, `utility_bill`), each up to 10MB. These routes bound individual file size but do not currently configure a MIME/type `fileFilter`; the Mykobo request can buffer up to 40MB in aggregate. @@ -57,7 +62,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 10. **Request IDs MUST be correlation-only** — Request IDs may be accepted from clients or generated by the API, but they must not grant access, alter authorization, or be treated as trusted identity. 11. **API observability MUST NOT change request outcomes** — Client event persistence/logging must be best-effort and must not change controller response bodies, status codes, or ramp/quote state. 12. **Maintenance windows MUST be backend-enforced on mutable ramp entrypoints** — `POST /v1/quotes`, `POST /v1/quotes/best`, `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST /v1/ramp/start` must reject during active maintenance with `503`, `Retry-After`, and explicit downtime start/end metadata. UI disabling is not sufficient because partners may call the API directly. -13. **Provider-backed ramp endpoints MUST reject callers without an effective user** — Alfredpay and Avenia/BRL flows derive their provider customer/subaccount from the Supabase session profile or `CredentialContext.profileId`, then `profiles.id -> customer_entities.profile_id -> provider_customers.customer_entity_id`; provider and corridor filters select the canonical account. Quote creation is anonymous-eligible on every corridor (Alfredpay quotes carry only a tracking-metadata customer id — the `"anonymous"` sentinel for non-KYC'd callers), but `POST /v1/ramp/register` requires Supabase or secret-credential capability and `RampService.registerRamp` rejects missing effective users with `400 Invalid quote`. Quotes owned by a *different* user are rejected with `403`; anonymous quotes (no owner) may be claimed, with provider identity always derived from the claimer's own canonical KYC records. +13. **Provider-backed ramp endpoints MUST reject callers without an effective user** — Alfredpay and Avenia/BRL flows derive their provider customer/subaccount from the Supabase session profile or `CredentialContext.profileId`, then `profiles.id -> customer_entities.profile_id -> provider_customers.customer_entity_id`; provider and corridor filters select the canonical account. Quote creation is anonymous-eligible on every corridor (Alfredpay quotes carry only a tracking-metadata customer id — the `"anonymous"` sentinel for non-KYC'd callers), but `POST /v1/ramp/register` requires Supabase or secret-credential capability and `RampService.registerRamp` rejects missing effective users with `400 Invalid quote`. Quotes owned by a _different_ user are rejected with `403`; anonymous quotes (no owner) may be claimed, with provider identity always derived from the claimer's own canonical KYC records. 14. **Active customer-entity selection MUST be authenticated, owner-scoped, and immutable** — `PUT /v1/onboarding/active-entity` accepts only `individual` or `business`, locks the authenticated profile while selecting, and may bind only an active `customer_entities` row owned by that profile. An identical retry returns the existing selection. A different later type, an ownership mismatch, or multiple active owned entities of the requested type is rejected with `409`; no arbitrary row is selected. 15. **Legacy active-entity backfill MUST be unambiguous** — Migration 048 selects only one active entity that already owns provider or recipient data. Empty automatically-created individual entities do not force the selection. Profiles with multiple meaningful entities or no meaningful entity remain null and `GET /v1/onboarding/status` returns `selectionRequired: true`. 16. **Authenticated all-wallet ramp history MUST be user-scoped** — `GET /v1/ramp/history` requires a principal with an effective user and filters directly on `RampState.userId`. The endpoint MUST NOT accept a client-supplied owner ID, include null-owned or foreign-user ramps, infer ownership from a destination wallet or pricing partner, or fall back to partner-wide history for an unlinked partner key. The legacy `/v1/ramp/history/:walletAddress` route remains available under its existing user-or-partner ownership rules. @@ -68,31 +73,31 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 21. **Credential startup MUST fail closed** — the process must not listen unless the complete `api_credentials` schema, nullability, indexes, and constraints exist and the legacy `api_keys` table is absent. Runtime auth must not fall back to legacy rows, hashes, prefixes, or pairing heuristics. 22. **`ramp-info` MUST expose only a sanitized subject-derived projection** — `GET /v1/ramp-info` may accept public or secret credential capability, but not a Supabase session. It derives the profile from `CredentialContext`; a manager secret may additionally use the authorization-derived `X-Managed-Profile-Id` selector, while a public key may not. It accepts no body/query profile, user, or PII identifier and returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. 23. **Managed-profile provisioning MUST use immutable associations** — `POST /v1/admin/managed-profiles` requires admin auth, normalizes email, and binds a genuine Supabase/profile identity to unique `(partner_id, external_user_id)` and unique `profile_id` records. Existing Auth identities may be reconciled only when their immutable metadata matches. Technical subjects must not receive customer entities or register ramps. -24. **Managed-profile context MUST be route-authorized** — On supported child-oriented routes, `X-Managed-Profile-Id` is a selector only. A Supabase session or secret API credential establishes the manager actor; middleware verifies active manager configuration, a direct active relationship, the managed child and its single active customer entity, every required corridor, optional manager customer-type narrowing, and canonical corridor capability before attaching an immutable actor/subject context. Public API keys cannot establish the manager actor, raw headers never alter `req.userId`, and ownership checks use the verified child subject. A child-owned credential instead authenticates directly as its child and dynamically derives the same controlling relationship and current policy; it cannot select another child. Manager or relationship deactivation and policy narrowing block authorization decisions begun after the committed change but do not cancel already-authorized requests in flight. The header is explicitly CORS-allowlisted for browser-based manager sessions, and relationship plus route-specific entity-type authorization precedes multipart buffering; multipart country authorization follows body parsing. +24. **Managed-profile context MUST be route-authorized** — On supported child-oriented routes, `X-Managed-Profile-Id` is a selector only. A Supabase session or secret API credential establishes the manager actor; middleware verifies active manager configuration, a direct active relationship, and the managed child with its single active customer entity before attaching an immutable actor/subject context. Corridor- or customer-type-scoped operations additionally enforce the manager's current customer-type narrowing, every required corridor, and canonical corridor capability; policy-free status and historical reads remain available. Public API keys cannot establish the manager actor, raw headers never alter `req.userId`, and ownership checks use the verified child subject. Target-specific authorization must resolve the target under that verified child before evaluating its stored corridor, preserving the route's missing-resource response for foreign targets and never falling back to the manager's resource. A child-owned credential instead authenticates directly as its child and dynamically derives the same controlling relationship and current policy; it cannot select another child. Admin impersonation may compose with managed selection when the impersonated profile is the active controlling manager; the admin remains attributable through the impersonation context. Manager or relationship deactivation, and policy narrowing on policy-scoped operations, block authorization decisions begun after the committed change but do not cancel already-authorized requests in flight. The header is explicitly CORS-allowlisted for browser-based manager sessions, and relationship plus route-specific entity-type authorization precedes multipart buffering; multipart country authorization follows body parsing. 25. **Headless profile lifecycle MUST fail closed** — Manager lifecycle routes derive the manager from a Supabase session or profile-bound secret credential and require its current manager configuration to be active. Creation requires an immutable provider contact email separate from the child's null login email; normalized contact emails are unique and permanently reserved within each manager. Child reads, credential management, and deletion are scoped by both manager and child profile IDs so foreign relationships are indistinguishable from missing rows. Only the manager-scoped child-credential route may issue credentials for a managed subject; generic profile-managed and admin partner-managed creation reject them. Credential creation and logical deletion lock the child profile and relationship in a common order; deletion is idempotent, revokes child credentials in the same transaction, and leaves retained provider, KYC, quote, ramp, and callback state intact. Managed profiles cannot create a second customer-entity type after provisioning. -26. **Unsupported managed operations MUST fail explicitly** — Recipient invitation routes reject `X-Managed-Profile-Id` rather than silently applying the request to the manager. Direct child credentials are rejected from webhook and manager lifecycle routes. Managed children have one immutable active customer entity from provisioning, so `PUT /v1/onboarding/active-entity` is not a delegated child operation. +26. **Managed selector handling MUST be explicit per route** — Recipient invite preview and acceptance reject `X-Managed-Profile-Id` rather than redeeming as a selected child; sender-side recipient routes are delegated only after managed-profile authorization. Direct child credentials are rejected from webhook and manager lifecycle routes. Managed children have one immutable active customer entity from provisioning, so `PUT /v1/onboarding/active-entity` is not a delegated child operation. The legacy Monerium and Mykobo routes are the accepted exception: they ignore the selector and remain scoped to the Supabase-authenticated manager. Managed clients must not send the header to those routes, and dashboard child mode disables those actions. 27. **Public onboarding discovery MUST keep OpenAPI authoritative for request schemas** — `GET /v1/onboarding/requirements` is unauthenticated and returns only the reviewed static Avenia/Alfredpay flow identity, document requirements, ordered non-GET API/hosted/upload actions, workflow value bindings, and documentation/OpenAPI links. Initial reads, readiness getters, redirect getters, and status polling MUST NOT be advertised; integration documentation and OpenAPI own those completion details. No top-level field catalog or independent request schema is returned. `fixedBody`, `fixedQuery`, and `derivedValues` may bind provider discriminators or prior step outputs only to body/query fields accepted by the referenced OpenAPI operation. The endpoint MUST NOT inspect profile state, return customer or provider identifiers, accept an owner selector, or advertise unsupported combinations such as AR business or Monerium flows. Every advertised API step, request-schema fragment, and workflow-binding target is checked against the reviewed OpenAPI document so stale mappings fail the documentation gate. ## Threat Vectors & Mitigations -| Threat | Mitigation | -|---|---| -| **⚠️ Memory exhaustion via large request body** — Attacker sends a 20MB JSON payload repeatedly to exhaust server memory | Rate limiting (100 req/min) provides some protection, but 100 requests × 20MB = 2GB of memory pressure per minute per IP. **The 20MB limit should be reduced to 1-10MB.** | -| **CORS bypass** — Attacker's site makes cross-origin requests to the API | Explicit origin whitelist prevents this. However, the whitelist includes `staging--pendulum-pay.netlify.app` — if the staging site is compromised or has XSS, it becomes a CORS-allowed origin in production. | -| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | No mitigation beyond the per-IP limit. No account-based rate limiting, no endpoint-specific limits, no progressive penalties. High-value endpoints (ramp creation, quote generation) get the same limit as read-only endpoints. | -| **Input validation bypass** — Validator doesn't check a field that the controller uses | Hand-written validators are prone to omissions. No schema library enforces completeness. New fields added to controllers may not get corresponding validators. | -| **Mass assignment** — Extra fields in the request body are passed to database operations | Validators check for expected fields but don't strip unknown fields. If a controller passes `req.body` directly to a database query (e.g., Sequelize `create(req.body)`), extra fields could set unintended columns. | -| **Error response information leak** — The `errors` array in error responses reveals internal validation logic or database field names | Error handler wraps errors in `APIError`. The `errors` array content depends on what validators put there. Validator messages reference field names from the API schema, not necessarily database internals, but should be audited. | -| **Staging CORS origin in production** — `staging--pendulum-pay.netlify.app` is in the CORS whitelist | If the staging site has an XSS vulnerability, an attacker could use it to make authenticated cross-origin requests to the production API. Staging origins should ideally be removed from production CORS config. | -| **No per-endpoint rate limiting** — Sensitive endpoints (ramp creation, admin operations) have the same rate limit as public read endpoints | An attacker can create 100 ramps per minute per IP. For endpoints that trigger expensive operations (XCM, SquidRouter), this could amplify costs. | -| **Cookie-based auth without CSRF protection** — Cookie parser is enabled for Supabase auth tokens | If auth tokens are stored in cookies (not just headers), cross-site requests from CORS-allowed origins could carry auth cookies automatically. Verify whether CSRF tokens or `SameSite` cookie attributes are used. | -| **Observability side effects** — Event persistence failure breaks a partner-facing API call | Observability helpers must catch persistence/logging errors and run best-effort only. See `client-observability.md`. | -| **Direct API bypass of UI maintenance mode** — Partner SDK or custom API clients ignore the frontend and continue creating quotes or mutating ramps during planned downtime | Mutable quote/ramp routes run the maintenance guard server-side and fail closed with `503 Service Unavailable`, `Retry-After`, and the active window's start/end timestamps. | -| **Cross-user history disclosure** — A caller requests account-wide ramp history and receives ramps belonging to another profile or to a pricing partner | The controller requires `getEffectiveUserId(req)` and the service query adds `RampState.userId = effectiveUserId`; wallet addresses and partner pricing never grant ownership. | -| **Public credential escalates to private data** — A browser-held public key is sent to limits, ramp diagnostics, or provider-account endpoints | Route middleware enforces the public/secret capability matrix; only the separately sanitized `ramp-info` projection is public-key-readable. | -| **Mixed credentials create confused-deputy attribution** — A caller combines one public key with another credential's secret key | Both resolve to immutable credential IDs and mismatch is rejected with `403` before downstream authorization or pricing. | -| **Managed-child selector becomes generic impersonation** — An authenticated profile sends an arbitrary child UUID | Only routes with delegated middleware honor the header, and the middleware requires a direct active manager-child relationship before deriving the subject. | -| **Partial production migration reaches traffic** — Legacy, unpaired, or malformed credential data remains after deployment | Migration 061 rejects active legacy rows and drops the old table; startup verifies unified schema invariants and requires that table to be absent. The rollout uses explicit immutable-ID manifests, never display names. | +| Threat | Mitigation | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **⚠️ Memory exhaustion via large request body** — Attacker sends a 20MB JSON payload repeatedly to exhaust server memory | Rate limiting (100 req/min) provides some protection, but 100 requests × 20MB = 2GB of memory pressure per minute per IP. **The 20MB limit should be reduced to 1-10MB.** | +| **CORS bypass** — Attacker's site makes cross-origin requests to the API | Explicit origin whitelist prevents this. However, the whitelist includes `staging--pendulum-pay.netlify.app` — if the staging site is compromised or has XSS, it becomes a CORS-allowed origin in production. | +| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | No mitigation beyond the per-IP limit. No account-based rate limiting, no endpoint-specific limits, no progressive penalties. High-value endpoints (ramp creation, quote generation) get the same limit as read-only endpoints. | +| **Input validation bypass** — Validator doesn't check a field that the controller uses | Hand-written validators are prone to omissions. No schema library enforces completeness. New fields added to controllers may not get corresponding validators. | +| **Mass assignment** — Extra fields in the request body are passed to database operations | Validators check for expected fields but don't strip unknown fields. If a controller passes `req.body` directly to a database query (e.g., Sequelize `create(req.body)`), extra fields could set unintended columns. | +| **Error response information leak** — The `errors` array in error responses reveals internal validation logic or database field names | Error handler wraps errors in `APIError`. The `errors` array content depends on what validators put there. Validator messages reference field names from the API schema, not necessarily database internals, but should be audited. | +| **Staging CORS origin in production** — `staging--pendulum-pay.netlify.app` is in the CORS whitelist | If the staging site has an XSS vulnerability, an attacker could use it to make authenticated cross-origin requests to the production API. Staging origins should ideally be removed from production CORS config. | +| **No per-endpoint rate limiting** — Sensitive endpoints (ramp creation, admin operations) have the same rate limit as public read endpoints | An attacker can create 100 ramps per minute per IP. For endpoints that trigger expensive operations (XCM, SquidRouter), this could amplify costs. | +| **Cookie-based auth without CSRF protection** — Cookie parser is enabled for Supabase auth tokens | If auth tokens are stored in cookies (not just headers), cross-site requests from CORS-allowed origins could carry auth cookies automatically. Verify whether CSRF tokens or `SameSite` cookie attributes are used. | +| **Observability side effects** — Event persistence failure breaks a partner-facing API call | Observability helpers must catch persistence/logging errors and run best-effort only. See `client-observability.md`. | +| **Direct API bypass of UI maintenance mode** — Partner SDK or custom API clients ignore the frontend and continue creating quotes or mutating ramps during planned downtime | Mutable quote/ramp routes run the maintenance guard server-side and fail closed with `503 Service Unavailable`, `Retry-After`, and the active window's start/end timestamps. | +| **Cross-user history disclosure** — A caller requests account-wide ramp history and receives ramps belonging to another profile or to a pricing partner | The controller requires `getEffectiveUserId(req)` and the service query adds `RampState.userId = effectiveUserId`; wallet addresses and partner pricing never grant ownership. | +| **Public credential escalates to private data** — A browser-held public key is sent to limits, ramp diagnostics, or provider-account endpoints | Route middleware enforces the public/secret capability matrix; only the separately sanitized `ramp-info` projection is public-key-readable. | +| **Mixed credentials create confused-deputy attribution** — A caller combines one public key with another credential's secret key | Both resolve to immutable credential IDs and mismatch is rejected with `403` before downstream authorization or pricing. | +| **Managed-child selector becomes generic impersonation** — An authenticated profile sends an arbitrary child UUID | Only routes with delegated middleware honor the header, and the middleware requires a direct active manager-child relationship before deriving the subject. | +| **Partial production migration reaches traffic** — Legacy, unpaired, or malformed credential data remains after deployment | Migration 061 rejects active legacy rows and drops the old table; startup verifies unified schema invariants and requires that table to be absent. The rollout uses explicit immutable-ID manifests, never display names. | ## Audit Checklist diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 5de9b2dd3..5dc966a9a 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 6c604e1e0..928aa2e53 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 with broad read and mutation rights. Ramp registration, update, and start; provider onboarding and KYC/KYB mutations; managed-child creation/deletion; and manager/child credential creation/revocation are denied. Quote generation, recipient, active-entity, notification, and other customer-account operations remain available. Alfredpay fiat-account creation and deletion are explicitly accepted even though these provider-side payout-account mutations outlive the session. | 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 ramp money movement, KYC/KYB mutations, managed-child lifecycle, manager/child credential lifecycle, 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 changing the allowed mutation scope; managed sub-account composition has been reviewed with lifecycle and credential mutations denied. See `01-auth/admin-impersonation.md`. | | RISK-019 | Accepted | High | Product + Compliance | Managed-profile contact email uniqueness is manager-scoped, while Alfredpay uses email as provider identity. Different managers can submit the same normalized email; on an Alfredpay `409`, Vortex may adopt the provider customer returned for that email when country and customer type match, without independent proof that the second manager controls that provider identity. | Manager/child authorization remains isolated; contact email is immutable and unique within one manager; conflict recovery rejects country/type mismatch; the provider customer ID remains globally unique locally. Partners must supply an email identity they are authorized to use, and operations must investigate cross-manager collision errors rather than bypass uniqueness. | Before onboarding managers whose customer-email namespaces may overlap, enforce global or provider-scoped ownership of contact email, or replace email-based adoption with a provider ownership/claim proof and migrate existing relationships. | | RISK-020 | Deferred | High | Cross-chain + Operations | Moonbeam is unavailable. Historical ramps, residual ephemeral funds, and legacy rebalancer state may remain stranded. A successful `moonbeamCleanup` now records retirement acknowledgement rather than an on-chain sweep. | Moonbeam-dependent registration/update/start, phase execution, automatic recovery, status polling, and legacy rebalancing are disabled without deleting persisted flow identities or recovery data. | Reconcile every affected ramp/account and complete a reviewed manual rescue before restoring any Moonbeam runtime path or automatic recovery. | | RISK-021 | Accepted | High | Product + Compliance + Operations | Individual Avenia token-import code is enabled despite unresolved provider, legal, consent, and live-sandbox production-readiness confirmations. The caller is responsible for placing the canonical CPF in the source Sumsub applicant's TIN field; when Avenia omits `accountInfo.taxId`, Vortex accepts provider approval without independently comparing that CPF. This accepts enabled source behavior, not production readiness or evidence that any confirmation exists. | Auth-first profile binding, immutable KYC method selection, durable no-replay claims, exact-attempt polling, rejection of non-empty CPF mismatches, provisional consent evidence, and sensitive-data redaction. | Production rollout requires every [blocking confirmation and sandbox contract flow](../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations) in the proposal. Add mandatory provider-returned CPF validation if caller responsibility proves insufficient. |