From cd6d1c69a66d72b92d6aee1c08156bf655757172 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:01:22 +0200 Subject: [PATCH 01/29] feat(api): add vortex_admin profile role granted out-of-band --- apps/api/package.json | 1 + apps/api/scripts/grant-vortex-admin.ts | 37 +++++++++++++++++++ .../admin/profileRoles.controller.test.ts | 22 +++++++++++ .../admin/profileRoles.controller.ts | 17 ++++++++- .../059-allow-vortex-admin-profile-role.ts | 18 +++++++++ apps/api/src/models/profileRole.model.ts | 14 +++++-- 6 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 apps/api/scripts/grant-vortex-admin.ts create mode 100644 apps/api/src/database/migrations/059-allow-vortex-admin-profile-role.ts diff --git a/apps/api/package.json b/apps/api/package.json index d003a0b15..2200a998c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -93,6 +93,7 @@ "credentials:migrate": "bun scripts/migrate-api-credentials.ts", "credentials:preflight": "bun scripts/preflight-api-credential-migration.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/profileRoles.controller.test.ts b/apps/api/src/api/controllers/admin/profileRoles.controller.test.ts index a5c65131c..10b3aec10 100644 --- a/apps/api/src/api/controllers/admin/profileRoles.controller.test.ts +++ b/apps/api/src/api/controllers/admin/profileRoles.controller.test.ts @@ -70,6 +70,28 @@ 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("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..b59831fe0 100644 --- a/apps/api/src/api/controllers/admin/profileRoles.controller.ts +++ b/apps/api/src/api/controllers/admin/profileRoles.controller.ts @@ -1,7 +1,11 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../../config/logger"; -import ProfileRole, { PROFILE_ROLE_NAMES, type ProfileRoleName } from "../../../models/profileRole.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 +35,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({ diff --git a/apps/api/src/database/migrations/059-allow-vortex-admin-profile-role.ts b/apps/api/src/database/migrations/059-allow-vortex-admin-profile-role.ts new file mode 100644 index 000000000..b8186fca8 --- /dev/null +++ b/apps/api/src/database/migrations/059-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/models/profileRole.model.ts b/apps/api/src/models/profileRole.model.ts index fa1a6ced9..3a444c198 100644 --- a/apps/api/src/models/profileRole.model.ts +++ b/apps/api/src/models/profileRole.model.ts @@ -2,10 +2,18 @@ import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; // Admin-managed capability roles per profile. discount_manager: may attach pricing -// discounts to recipient invites (seeded on acceptance). -export type ProfileRoleName = "discount_manager"; +// discounts to recipient invites (seeded on acceptance). vortex_admin: may use the +// /v1/admin-console surface, including impersonating another profile. +export type ProfileRoleName = "discount_manager" | "vortex_admin"; -export const PROFILE_ROLE_NAMES: ProfileRoleName[] = ["discount_manager"]; +export const PROFILE_ROLE_NAMES: ProfileRoleName[] = ["discount_manager", "vortex_admin"]; + +// Roles grantable through POST /v1/admin/profile-roles, which is guarded only by the shared +// ADMIN_SECRET. vortex_admin confers the ability to act as any customer — including moving +// their money — so that secret must never be sufficient to grant it; it is granted +// out-of-band instead (see scripts/grant-vortex-admin.ts). Revocation stays available for +// every role via DELETE, as a safety valve. +export const HTTP_GRANTABLE_PROFILE_ROLES: ProfileRoleName[] = ["discount_manager"]; export interface ProfileRoleAttributes { id: string; From edcb207a44f5e7ef543e4e915f12958e53d9c6f0 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:01:30 +0200 Subject: [PATCH 02/29] feat(api): add admin impersonation session model and service --- apps/api/.env.example | 4 + .../services/impersonation.service.test.ts | 209 ++++++++++++++++++ .../src/api/services/impersonation.service.ts | 161 ++++++++++++++ apps/api/src/config/vars.ts | 3 + ...060-create-admin-impersonation-sessions.ts | 83 +++++++ .../models/adminImpersonationSession.model.ts | 80 +++++++ apps/api/src/models/index.ts | 7 + 7 files changed, 547 insertions(+) create mode 100644 apps/api/src/api/services/impersonation.service.test.ts create mode 100644 apps/api/src/api/services/impersonation.service.ts create mode 100644 apps/api/src/database/migrations/060-create-admin-impersonation-sessions.ts create mode 100644 apps/api/src/models/adminImpersonationSession.model.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index b0afbd769..122d9d069 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -16,6 +16,10 @@ ADMIN_SECRET=your-secure-admin-secret-here # Use a different secret than ADMIN_SECRET to reduce blast radius. METRICS_DASHBOARD_SECRET=your-secure-metrics-dashboard-secret-here +# Kill switch for vortex_admin "act as another profile" sessions. Off unless explicitly +# "true". Turning it off also invalidates sessions that are already in flight. +IMPERSONATION_ENABLED=false + # Supabase Configuration SUPABASE_URL=https://your-project-id.supabase.co SUPABASE_ANON_KEY=your-anon-key-here diff --git a/apps/api/src/api/services/impersonation.service.test.ts b/apps/api/src/api/services/impersonation.service.test.ts new file mode 100644 index 000000000..ee1d63edb --- /dev/null +++ b/apps/api/src/api/services/impersonation.service.test.ts @@ -0,0 +1,209 @@ +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 { 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; + }); + + it("persists only the SHA-256 hash of the token, never the raw value", async () => { + const actor = await createTestUser(); + 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 createTestUser(); + 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 createTestUser(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + await session.update({ expiresAt: new Date(Date.now() - 1000) }); + + expect(await resolveSession(token)).toBeNull(); + }); + + it("returns null for a revoked session", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + await revokeSession(session.id, "manual revoke"); + + expect(await resolveSession(token)).toBeNull(); + }); + + it("returns null for an unknown token", async () => { + expect(await resolveSession(`${IMPERSONATION_TOKEN_PREFIX}unknown-token-value`)).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 createTestUser(); + 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("rejects an actor impersonating themselves", async () => { + const actor = await createTestUser(); + + await expect(createSession({ actorProfileId: actor.id, targetProfileId: actor.id })).rejects.toBeInstanceOf( + ImpersonationTargetError + ); + }); + + it("rejects a non-existent target", async () => { + const actor = await createTestUser(); + + 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 createTestUser(); + 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 createTestUser(); + 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 createTestUser(); + 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("lists active sessions before closed ones even when a closed one was created more recently", async () => { + const liveActor = await createTestUser(); + 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 createTestUser(); + 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 createTestUser(); + const liveTarget = await createTestUser(); + const { session: live } = await createSession({ actorProfileId: liveActor.id, targetProfileId: liveTarget.id }); + + const expiredActor = await createTestUser(); + 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..7f4c1b820 --- /dev/null +++ b/apps/api/src/api/services/impersonation.service.ts @@ -0,0 +1,161 @@ +import crypto from "crypto"; +import { literal } from "sequelize"; +import { config } from "../../config/vars"; +import AdminImpersonationSession from "../../models/adminImpersonationSession.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 function isImpersonationToken(token: string): boolean { + return token.startsWith(IMPERSONATION_TOKEN_PREFIX); +} + +function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +/** + * Mints a session and returns the raw token exactly once — only its SHA-256 is persisted, + * so a leaked database row cannot be replayed. + */ +export async function createSession(input: { + actorProfileId: string; + targetProfileId: string; +}): Promise<{ token: string; session: AdminImpersonationSession; target: User }> { + if (!config.impersonationEnabled) { + throw new ImpersonationDisabledError(); + } + + if (input.actorProfileId === input.targetProfileId) { + throw new ImpersonationTargetError("An admin cannot impersonate themselves"); + } + + const target = await User.findByPk(input.targetProfileId); + if (!target) { + throw new ImpersonationTargetError("Target profile was not found"); + } + + // 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" }, + { + where: { + actorProfileId: input.actorProfileId, + revokedAt: null, + targetProfileId: input.targetProfileId + } + } + ); + + const token = `${IMPERSONATION_TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`; + const session = await AdminImpersonationSession.create({ + actorProfileId: input.actorProfileId, + expiresAt: new Date(Date.now() + IMPERSONATION_TTL_MS), + targetProfileId: input.targetProfileId, + tokenHash: hashToken(token) + }); + + 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 = await User.findByPk(session.targetProfileId, { attributes: ["id", "email"] }); + if (!target) { + 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 { + return AdminImpersonationSession.findAll({ + include: [ + { as: "actor", attributes: ["id", "email"], model: User }, + { as: "target", attributes: ["id", "email"], model: User } + ], + limit: Math.min(input.limit ?? 50, 200), + order: [ + // Mirrors isSessionActive() in SQL so live sessions sort above closed ones. + [ + literal(`("AdminImpersonationSession"."revoked_at" IS NULL AND "AdminImpersonationSession"."expires_at" > NOW())`), + "DESC" + ], + ["createdAt", "DESC"] + ], + where: input.actorProfileId ? { actorProfileId: input.actorProfileId } : undefined + }); +} + +/** True when the session is live right now — used to render "active" in the audit view. */ +export function isSessionActive(session: AdminImpersonationSession): boolean { + return session.revokedAt === null && session.expiresAt.getTime() > Date.now(); +} diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 610fba5be..09aa5ad8b 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -129,6 +129,8 @@ interface Config { logs: string; adminSecret: string; metricsDashboardSecret: string; + /** Kill switch for vortex_admin "act as another profile" sessions. */ + impersonationEnabled: boolean; supabase: { url: string; anonKey: string; @@ -228,6 +230,7 @@ export const config: Config = { deploymentEnv: readDeploymentEnv(), env: nodeEnv, flowVariant: readFlowVariant(), + impersonationEnabled: process.env.IMPERSONATION_ENABLED === "true", integrations: { alchemy: { diff --git a/apps/api/src/database/migrations/060-create-admin-impersonation-sessions.ts b/apps/api/src/database/migrations/060-create-admin-impersonation-sessions.ts new file mode 100644 index 000000000..01e3ed73d --- /dev/null +++ b/apps/api/src/database/migrations/060-create-admin-impersonation-sessions.ts @@ -0,0 +1,83 @@ +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, + onDelete: "CASCADE", + 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" + }); + // Supports "does this actor already hold a live session on this target?" without a scan. + await queryInterface.sequelize.query( + `CREATE INDEX "idx_admin_impersonation_sessions_active" + ON "admin_impersonation_sessions" ("actor_profile_id", "target_profile_id") + WHERE "revoked_at" IS NULL;` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "admin_impersonation_sessions" + ADD CONSTRAINT "chk_admin_impersonation_sessions_distinct" CHECK (actor_profile_id <> target_profile_id);` + ); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("admin_impersonation_sessions"); +} diff --git a/apps/api/src/models/adminImpersonationSession.model.ts b/apps/api/src/models/adminImpersonationSession.model.ts new file mode 100644 index 000000000..8d32043c2 --- /dev/null +++ b/apps/api/src/models/adminImpersonationSession.model.ts @@ -0,0 +1,80 @@ +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: "CASCADE", + 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" } + ], + 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 e11b8bb7c..ee3dcb74f 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"; @@ -50,6 +51,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" }); ProfilePartnerAssignment.belongsTo(Partner, { as: "buyPartner", foreignKey: "buyPartnerId" }); ProfilePartnerAssignment.belongsTo(Partner, { as: "sellPartner", foreignKey: "sellPartnerId" }); Partner.hasMany(ProfilePartnerAssignment, { as: "buyProfileAssignments", foreignKey: "buyPartnerId" }); @@ -111,6 +117,7 @@ NotificationPreference.belongsTo(User, { as: "profile", foreignKey: "profileId" // Initialize models const models = { + AdminImpersonationSession, Anchor, ApiClientEvent, ApiCredential, From 172495857f83b5926cd408141c6c6edd0322622a Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:01:37 +0200 Subject: [PATCH 03/29] feat(api): resolve impersonation tokens on bearer-authenticated routes --- .../api/middlewares/bearerPrincipal.test.ts | 139 ++++++++++++++++++ .../src/api/middlewares/bearerPrincipal.ts | 64 ++++++++ apps/api/src/api/middlewares/dualAuth.ts | 9 +- .../ownershipAuth.impersonation.test.ts | 44 ++++++ .../supabaseAuth.impersonation.test.ts | 114 ++++++++++++++ apps/api/src/api/middlewares/supabaseAuth.ts | 20 ++- .../routes/v1/api-credentials.route.test.ts | 69 +++++++++ .../api/routes/v1/api-credentials.route.ts | 3 + 8 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/api/middlewares/bearerPrincipal.test.ts create mode 100644 apps/api/src/api/middlewares/bearerPrincipal.ts create mode 100644 apps/api/src/api/middlewares/ownershipAuth.impersonation.test.ts create mode 100644 apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts create mode 100644 apps/api/src/api/routes/v1/api-credentials.route.test.ts diff --git a/apps/api/src/api/middlewares/bearerPrincipal.test.ts b/apps/api/src/api/middlewares/bearerPrincipal.test.ts new file mode 100644 index 000000000..c08d7c2d5 --- /dev/null +++ b/apps/api/src/api/middlewares/bearerPrincipal.test.ts @@ -0,0 +1,139 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { config } from "../../config/vars"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { SupabaseAuthService } from "../services/auth"; +import { createSession, revokeSession } from "../services/impersonation.service"; +import { rejectImpersonation, resolveBearerPrincipal } from "./bearerPrincipal"; + +function response(): Response & { json: ReturnType; status: ReturnType } { + const res = {} as Response & { json: ReturnType; status: ReturnType }; + res.status = mock(() => res); + res.json = mock(() => res); + return res; +} + +describe("resolveBearerPrincipal", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + mock.restore(); + }); + + afterAll(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + it("resolves a live impersonation token to the target, not the actor", async () => { + const actor = await createTestUser(); + const target = await createTestUser({ email: "target@example.com" }); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const principal = await resolveBearerPrincipal(token); + + expect(principal).toEqual({ + impersonation: { + actorProfileId: actor.id, + expiresAt: session.expiresAt, + sessionId: session.id, + targetEmail: target.email, + targetProfileId: target.id + }, + userEmail: target.email, + userId: target.id, + valid: true + }); + if (principal.valid) { + expect(principal.userId).not.toBe(actor.id); + } + }); + + it("resolves a Supabase token unchanged, with impersonation undefined", async () => { + const verify = spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ + email: "user@example.com", + user_id: "user-1", + valid: true + }); + + const principal = await resolveBearerPrincipal("some-supabase-token"); + + expect(principal).toEqual({ userEmail: "user@example.com", userId: "user-1", valid: true }); + if (principal.valid) { + expect(principal.impersonation).toBeUndefined(); + } + expect(verify).toHaveBeenCalledTimes(1); + }); + + it("returns invalid for an expired impersonation token", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + await session.update({ expiresAt: new Date(Date.now() - 1000) }); + + expect(await resolveBearerPrincipal(token)).toEqual({ valid: false }); + }); + + it("returns invalid for a revoked impersonation token", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + await revokeSession(session.id, "manual revoke"); + + expect(await resolveBearerPrincipal(token)).toEqual({ valid: false }); + }); + + it("returns invalid for an unknown impersonation token", async () => { + expect(await resolveBearerPrincipal("vtx_imp_unknown-token")).toEqual({ valid: false }); + }); +}); + +describe("rejectImpersonation", () => { + it("calls next() when the request carries no impersonation context", () => { + const req = {} as Request; + const res = response(); + const next = mock(() => undefined) as NextFunction; + + rejectImpersonation(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("responds 403 IMPERSONATION_NOT_ALLOWED and does not call next() when impersonation is set", () => { + const req = { + impersonation: { + actorProfileId: "actor-1", + expiresAt: new Date(), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-1" + } + } as Request; + const res = response(); + const next = mock(() => undefined) as NextFunction; + + rejectImpersonation(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(httpStatus.FORBIDDEN); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: "IMPERSONATION_NOT_ALLOWED", + message: "This action is not available while acting as another account.", + status: httpStatus.FORBIDDEN + } + }); + }); +}); diff --git a/apps/api/src/api/middlewares/bearerPrincipal.ts b/apps/api/src/api/middlewares/bearerPrincipal.ts new file mode 100644 index 000000000..be80834f4 --- /dev/null +++ b/apps/api/src/api/middlewares/bearerPrincipal.ts @@ -0,0 +1,64 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { SupabaseAuthService } from "../services/auth"; +import { type ImpersonationContext, isImpersonationToken, resolveSession } from "../services/impersonation.service"; + +export type { ImpersonationContext }; + +/** + * The principal a bearer token resolves to. An impersonation token resolves to the + * *target* profile — everything downstream (`getEffectiveUserId`, `ownershipAuth`, + * controllers) then scopes to the target with no further changes. + */ +export type BearerPrincipal = + | { valid: true; userId: string; userEmail?: string; impersonation?: ImpersonationContext } + | { valid: false }; + +/** + * Single entry point for turning a bearer token into a principal. Routes on the + * `vtx_imp_` prefix so ordinary Supabase tokens keep exactly their current path and cost. + */ +export async function resolveBearerPrincipal(token: string): Promise { + if (isImpersonationToken(token)) { + const impersonation = await resolveSession(token); + if (!impersonation) { + return { valid: false }; + } + return { + impersonation, + userEmail: impersonation.targetEmail, + userId: impersonation.targetProfileId, + valid: true + }; + } + + const result = await SupabaseAuthService.verifyToken(token); + if (!result.valid || !result.user_id) { + return { valid: false }; + } + return { userEmail: result.email, userId: result.user_id, valid: true }; +} + +/** + * Refuses routes that an impersonated caller must never reach: minting API credentials + * (which would outlive the session and become a permanent backdoor) and the admin console + * itself (no privilege re-escalation, no impersonation chaining). + */ +export function rejectImpersonation(req: Request, res: Response, next: NextFunction): void { + if (req.impersonation) { + impersonationNotAllowedResponse(res); + return; + } + next(); +} + +/** Shared with the routes that gate on impersonation inline instead of via the middleware. */ +export function impersonationNotAllowedResponse(res: Response): void { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "IMPERSONATION_NOT_ALLOWED", + message: "This action is not available while acting as another account.", + status: httpStatus.FORBIDDEN + } + }); +} diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index 11aa3e8f4..9ffd54852 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -6,8 +6,8 @@ import { observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; -import { SupabaseAuthService } from "../services/auth"; import { getKeyType, isValidSecretKeyFormat, validatePublicApiKey, validateSecretApiKey } from "./apiKeyAuth.helpers"; +import { resolveBearerPrincipal } from "./bearerPrincipal"; export { assertQuoteOwnership, assertRampOwnership } from "./ownershipAuth"; @@ -88,7 +88,7 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } if (authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7); - const result = await SupabaseAuthService.verifyToken(token); + const result = await resolveBearerPrincipal(token); if (!result.valid) { recordDualAuthFailure(req, 401, "auth_invalid_api_key"); return res.status(401).json({ @@ -100,8 +100,9 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } }); } - req.userId = result.user_id; - req.userEmail = result.email; + req.userId = result.userId; + req.userEmail = result.userEmail; + req.impersonation = result.impersonation; return next(); } diff --git a/apps/api/src/api/middlewares/ownershipAuth.impersonation.test.ts b/apps/api/src/api/middlewares/ownershipAuth.impersonation.test.ts new file mode 100644 index 000000000..0b05dbf92 --- /dev/null +++ b/apps/api/src/api/middlewares/ownershipAuth.impersonation.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import RampState from "../../models/rampState.model"; +import { assertRampOwnership } from "./ownershipAuth"; + +// Impersonation only substitutes the principal at the bearer-token seam (req.userId becomes +// the target's profile id); ownership checks never see `req.impersonation` itself. These tests +// confirm the target's rights apply, and only the target's. +describe("assertRampOwnership under impersonation", () => { + const originalRampFindByPk = RampState.findByPk; + + afterEach(() => { + RampState.findByPk = originalRampFindByPk; + }); + + const impersonation = { + actorProfileId: "operator-1", + expiresAt: new Date(Date.now() + 60_000), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-user" + }; + + it("allows an impersonated request to access a ramp owned by the target", async () => { + RampState.findByPk = mock(async () => ({ + quoteId: "quote-1", + userId: "target-user" + })) as typeof RampState.findByPk; + + await expect( + assertRampOwnership({ impersonation, userId: "target-user" } as never, "ramp-1") + ).resolves.toBeUndefined(); + }); + + it("denies an impersonated request access to a ramp owned by an unrelated third profile", async () => { + RampState.findByPk = mock(async () => ({ + quoteId: "quote-1", + userId: "unrelated-third-profile" + })) as typeof RampState.findByPk; + + await expect( + assertRampOwnership({ impersonation, userId: "target-user" } as never, "ramp-1") + ).rejects.toThrow("Authenticated user does not own this ramp"); + }); +}); diff --git a/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts b/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts new file mode 100644 index 000000000..697948552 --- /dev/null +++ b/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts @@ -0,0 +1,114 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import { config } from "../../config/vars"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { SupabaseAuthService } from "../services/auth"; +import { createSession } from "../services/impersonation.service"; +import { optionalAuth, requireAuth } from "./supabaseAuth"; + +function request(authorization?: string): Request { + return { + headers: authorization === undefined ? {} : { authorization }, + path: "/v1/quote" + } as Request; +} + +function response(): Response & { json: ReturnType; status: ReturnType } { + const res = {} as Response & { json: ReturnType; status: ReturnType }; + res.json = mock(() => res); + res.status = mock(() => res); + return res; +} + +describe("Supabase auth middleware under impersonation", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + mock.restore(); + }); + + afterAll(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + it("requireAuth sets req.userId to the target and attaches req.impersonation", async () => { + const actor = await createTestUser({ email: "operator@example.com" }); + const target = await createTestUser({ email: "customer@example.com" }); + const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const req = request(`Bearer ${token}`); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.userId).toBe(target.id); + expect(req.impersonation).toEqual({ + actorProfileId: actor.id, + expiresAt: session.expiresAt, + sessionId: session.id, + targetEmail: target.email, + targetProfileId: target.id + }); + }); + + it("optionalAuth sets req.userId to the target and attaches req.impersonation", async () => { + const actor = await createTestUser({ email: "operator2@example.com" }); + const target = await createTestUser({ email: "customer2@example.com" }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const req = request(`Bearer ${token}`); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await optionalAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.userId).toBe(target.id); + expect(req.impersonation?.targetProfileId).toBe(target.id); + }); + + it("sets req.userEmail to the target's email, never the operator's", async () => { + const actor = await createTestUser({ email: "operator3@example.com" }); + const target = await createTestUser({ email: "customer3@example.com" }); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const req = request(`Bearer ${token}`); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await requireAuth(req, res, next); + + expect(req.userEmail).toBe(target.email); + expect(req.userEmail).not.toBe(actor.email); + }); + + it("leaves req.impersonation undefined for a plain Supabase-authenticated request", async () => { + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ + email: "user@example.com", + user_id: "user-1", + valid: true + }); + + const req = request("Bearer plain-supabase-token"); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await requireAuth(req, res, next); + + expect(req.userId).toBe("user-1"); + expect(req.impersonation).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index 5b629546f..3db7269d3 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -1,6 +1,8 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; -import { AccessTokenVerificationError, SupabaseAuthService } from "../services/auth"; +import { AccessTokenVerificationError } from "../services/auth"; +import type { ImpersonationContext } from "../services/impersonation.service"; +import { resolveBearerPrincipal } from "./bearerPrincipal"; declare global { // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. @@ -8,6 +10,8 @@ declare global { interface Request { userId?: string; userEmail?: string; + /** Set only when the caller presented an impersonation token; `userId` is the target. */ + impersonation?: ImpersonationContext; } } } @@ -26,7 +30,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio } const token = authHeader.substring(7); - const result = await SupabaseAuthService.verifyToken(token); + const result = await resolveBearerPrincipal(token); if (!result.valid) { return res.status(401).json({ @@ -34,8 +38,9 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio }); } - req.userId = result.user_id; - req.userEmail = result.email; + req.userId = result.userId; + req.userEmail = result.userEmail; + req.impersonation = result.impersonation; next(); } catch (error) { const unavailable = error instanceof AccessTokenVerificationError && error.transient; @@ -60,12 +65,13 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti } try { - const result = await SupabaseAuthService.verifyToken(authHeader.substring(7)); + const result = await resolveBearerPrincipal(authHeader.substring(7)); if (!result.valid) { return res.status(401).json({ error: "Invalid or expired token" }); } - req.userId = result.user_id; - req.userEmail = result.email; + req.userId = result.userId; + req.userEmail = result.userEmail; + req.impersonation = result.impersonation; next(); } catch (error) { const unavailable = error instanceof AccessTokenVerificationError && error.transient; diff --git a/apps/api/src/api/routes/v1/api-credentials.route.test.ts b/apps/api/src/api/routes/v1/api-credentials.route.test.ts new file mode 100644 index 000000000..1031fde17 --- /dev/null +++ b/apps/api/src/api/routes/v1/api-credentials.route.test.ts @@ -0,0 +1,69 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import express from "express"; +import { config } from "../../../config/vars"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { SupabaseAuthService } from "../../services/auth"; +import { createSession } from "../../services/impersonation.service"; +import apiCredentialsRoutes from "./api-credentials.route"; + +const BASE_PATH = "/v1/api-credentials"; + +describe("rejectImpersonation wiring on /v1/api-credentials", () => { + const originalImpersonationEnabled = config.impersonationEnabled; + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use(BASE_PATH, apiCredentialsRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not bind test server"); + } + baseUrl = `http://127.0.0.1:${address.port}${BASE_PATH}`; + }); + + afterAll(() => { + server?.close(); + config.impersonationEnabled = originalImpersonationEnabled; + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = true; + }); + + afterEach(() => { + mock.restore(); + }); + + it("refuses an impersonated caller with 403 IMPERSONATION_NOT_ALLOWED", async () => { + const actor = await createTestUser(); + const target = await createTestUser(); + const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); + + const res = await fetch(baseUrl, { headers: { Authorization: `Bearer ${token}` } }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + }); + + it("allows a plain authenticated (non-impersonated) caller through", async () => { + const user = await createTestUser(); + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ + email: user.email, + user_id: user.id, + valid: true + }); + + const res = await fetch(baseUrl, { headers: { Authorization: "Bearer plain-supabase-token" } }); + + expect(res.status).toBe(200); + }); +}); diff --git a/apps/api/src/api/routes/v1/api-credentials.route.ts b/apps/api/src/api/routes/v1/api-credentials.route.ts index 280d36e04..a04e669d0 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.ts @@ -1,9 +1,12 @@ import { Request, Response, Router } from "express"; import { createUserApiKey, listUserApiKeys, revokeUserApiKey } from "../../controllers/userApiKeys.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); router.use(requireAuth); +// A credential minted while acting as someone else would outlive the session. +router.use(rejectImpersonation); router.post("/", createUserApiKey as unknown as (req: Request, res: Response) => void); router.get("/", listUserApiKeys as unknown as (req: Request, res: Response) => void); router.delete("/:credentialId", revokeUserApiKey as unknown as (req: Request<{ credentialId: string }>, res: Response) => void); From abeeddc03a517b0eac654bb6c94016e8f7623c31 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:01:44 +0200 Subject: [PATCH 04/29] feat(api): stamp impersonation context on api client events --- .../apiClientEvent.service.test.ts | 37 +++++++++++++++++++ .../observability/apiClientEvent.service.ts | 8 ++++ apps/api/src/api/observability/types.ts | 4 +- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/observability/apiClientEvent.service.test.ts b/apps/api/src/api/observability/apiClientEvent.service.test.ts index 1d45d779d..c8a69fa4c 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.test.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.test.ts @@ -120,6 +120,24 @@ describe("buildApiClientRequestMetadata", () => { }); }); + it("stamps impersonation metadata when the request carries an impersonation context", () => { + const metadata = buildApiClientRequestMetadata({ + impersonation: { actorProfileId: "actor-1", sessionId: "session-1" }, + method: "GET", + path: "/v1/ramp/status" + }); + + expect(metadata.impersonationSessionId).toBe("session-1"); + expect(metadata.impersonatorProfileId).toBe("actor-1"); + }); + + it("omits impersonation metadata keys entirely for a non-impersonated request", () => { + const metadata = buildApiClientRequestMetadata({ method: "GET", path: "/v1/ramp/status" }); + + expect("impersonationSessionId" in metadata).toBe(false); + expect("impersonatorProfileId" in metadata).toBe(false); + }); + it("records only counts or presence flags for allowlisted sensitive payload fields", () => { const metadata = buildApiClientRequestMetadata( { @@ -171,4 +189,23 @@ describe("recordApiClientEventSafe", () => { await expect(recordApiClientEventSafe({ operation: "quote_create", status: "failure" })).resolves.toBeUndefined(); }); + + it("persists impersonation metadata through sanitizeMetadata", async () => { + let created: Record | undefined; + ApiClientEvent.create = mock(async (attributes: Record) => { + created = attributes; + return attributes as never; + }) as typeof ApiClientEvent.create; + + const metadata = buildApiClientRequestMetadata({ + impersonation: { actorProfileId: "actor-1", sessionId: "session-1" }, + method: "GET", + path: "/v1/ramp/status" + }); + + await recordApiClientEventSafe({ metadata, operation: "ramp_status", status: "success", userId: "target-1" }); + + expect((created?.metadata as Record).impersonationSessionId).toBe("session-1"); + expect((created?.metadata as Record).impersonatorProfileId).toBe("actor-1"); + }); }); diff --git a/apps/api/src/api/observability/apiClientEvent.service.ts b/apps/api/src/api/observability/apiClientEvent.service.ts index 32be8c975..40508dbae 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -33,6 +33,7 @@ interface ApiClientRequestLike { params?: unknown; path?: string; query?: unknown; + impersonation?: { sessionId: string; actorProfileId: string }; } interface RequestMetadataOptions { @@ -91,6 +92,13 @@ export function buildApiClientRequestMetadata( requestPath: buildTemplatedRequestPath(req.path, req.params) }; + // Every event raised during an impersonated request stays attributable to the operator, + // even though `userId` on the event is the target's. + if (req.impersonation) { + metadata.impersonationSessionId = req.impersonation.sessionId; + metadata.impersonatorProfileId = req.impersonation.actorProfileId; + } + addSelectedValues(metadata, "requestBody", req.body, options.bodyKeys); addSelectedValues(metadata, "requestParam", req.params, options.paramKeys); addSelectedValues(metadata, "requestQuery", req.query, options.queryKeys); diff --git a/apps/api/src/api/observability/types.ts b/apps/api/src/api/observability/types.ts index 60ac1ab6a..268b7b2e5 100644 --- a/apps/api/src/api/observability/types.ts +++ b/apps/api/src/api/observability/types.ts @@ -10,7 +10,9 @@ export type ApiClientOperation = | "ramp_update" | "ramp_start" | "ramp_status" - | "ramp_errors"; + | "ramp_errors" + | "admin_impersonation_start" + | "admin_impersonation_end"; export type ApiClientEventStatus = "success" | "failure"; From 65c99ffa7ebeea41df0c48a7b502d96fdda05a2e Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:01:50 +0200 Subject: [PATCH 05/29] feat(api): add vortex admin console accounts and impersonation routes --- .../admin-console/accounts.controller.ts | 211 +++++++++++++++++ .../admin-console/impersonation.controller.ts | 194 ++++++++++++++++ .../api/middlewares/vortexAdminAuth.test.ts | 78 +++++++ .../src/api/middlewares/vortexAdminAuth.ts | 36 +++ .../routes/v1/admin-console/accounts.route.ts | 21 ++ .../admin-console/admin-console.route.test.ts | 212 ++++++++++++++++++ .../v1/admin-console/impersonation.route.ts | 33 +++ apps/api/src/api/routes/v1/index.ts | 24 +- 8 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/api/controllers/admin-console/accounts.controller.ts create mode 100644 apps/api/src/api/controllers/admin-console/impersonation.controller.ts create mode 100644 apps/api/src/api/middlewares/vortexAdminAuth.test.ts create mode 100644 apps/api/src/api/middlewares/vortexAdminAuth.ts create mode 100644 apps/api/src/api/routes/v1/admin-console/accounts.route.ts create mode 100644 apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts create mode 100644 apps/api/src/api/routes/v1/admin-console/impersonation.route.ts 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..e9587eb6c --- /dev/null +++ b/apps/api/src/api/controllers/admin-console/accounts.controller.ts @@ -0,0 +1,211 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { Op } from "sequelize"; +import logger from "../../../config/logger"; +import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase from "../../../models/kycCase.model"; +import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { isSessionActive } from "../../services/impersonation.service"; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +function clampLimit(value: unknown): number { + const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN; + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT; + return Math.min(parsed, MAX_LIMIT); +} + +function parseCursor(value: unknown): number { + const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +} + +function emptyVerificationSummary(): Record { + return { + [VerificationStatus.Approved]: 0, + [VerificationStatus.InReview]: 0, + [VerificationStatus.Pending]: 0, + [VerificationStatus.Rejected]: 0, + [VerificationStatus.Started]: 0 + }; +} + +/** + * GET /v1/admin-console/accounts + * Paginated, search-filtered account list. Deliberately a cheap read — unlike + * onboarding.controller.ts's getOnboardingStatus, it never triggers provider status + * refreshes. + */ +export async function listAccounts(req: Request, res: Response): Promise { + try { + const search = typeof req.query.search === "string" ? req.query.search.trim() : ""; + const limit = clampLimit(req.query.limit); + const offset = parseCursor(req.query.cursor); + + const { rows: profiles, count: total } = await User.findAndCountAll({ + attributes: ["id", "email", "createdAt"], + limit: limit + 1, + offset, + order: [["createdAt", "DESC"]], + where: search ? { email: { [Op.iLike]: `%${search}%` } } : {} + }); + + const hasMore = profiles.length > limit; + const pageProfiles = hasMore ? profiles.slice(0, limit) : profiles; + const profileIds = pageProfiles.map(profile => profile.id); + + const [entities, activeAssignments] = await Promise.all([ + profileIds.length ? CustomerEntity.findAll({ where: { profileId: profileIds } }) : [], + profileIds.length + ? ProfilePartnerAssignment.findAll({ + where: { + [Op.or]: [{ expiresAt: null }, { expiresAt: { [Op.gt]: new Date() } }], + isActive: true, + userId: profileIds + } + }) + : [] + ]); + + const entityIds = entities.map(entity => entity.id); + const providerCustomers = entityIds.length + ? await ProviderCustomer.findAll({ attributes: ["customerEntityId", "status"], where: { customerEntityId: entityIds } }) + : []; + const entityProfileById = new Map(entities.map(entity => [entity.id, entity.profileId])); + + res.status(httpStatus.OK).json({ + accounts: pageProfiles.map(profile => { + const profileEntities = entities.filter(entity => entity.profileId === profile.id); + const verificationSummary = emptyVerificationSummary(); + for (const customer of providerCustomers) { + if (entityProfileById.get(customer.customerEntityId) === profile.id) { + verificationSummary[customer.status] += 1; + } + } + + return { + activePartnerName: activeAssignments.find(assignment => assignment.userId === profile.id)?.partnerName ?? null, + createdAt: profile.createdAt, + email: profile.email, + entities: profileEntities.map(entity => ({ id: entity.id, status: entity.status, type: entity.type })), + id: profile.id, + verificationSummary + }; + }), + limit, + nextCursor: hasMore ? String(offset + limit) : null, + total + }); + } catch (error) { + logger.error("Error listing admin-console accounts:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to list accounts", status: httpStatus.INTERNAL_SERVER_ERROR } + }); + } +} + +/** + * GET /v1/admin-console/accounts/:profileId + * Full account detail: entities, nested provider customers + KYC cases (mirrors the + * nesting in onboarding.controller.ts), and recent impersonation sessions targeting + * this profile. + */ +export async function getAccount(req: Request<{ profileId: string }>, res: Response): Promise { + try { + const { profileId } = req.params; + const profile = await User.findByPk(profileId); + if (!profile) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "USER_NOT_FOUND", message: "Profile was not found", status: httpStatus.NOT_FOUND } + }); + return; + } + + const entities = await CustomerEntity.findAll({ where: { profileId } }); + const entityIds = entities.map(entity => entity.id); + + const [providerCustomers, kycCases, impersonationSessions] = await Promise.all([ + entityIds.length + ? ProviderCustomer.findAll({ order: [["updatedAt", "DESC"]], where: { customerEntityId: entityIds } }) + : [], + entityIds.length ? KycCase.findAll({ where: { customerEntityId: entityIds } }) : [], + AdminImpersonationSession.findAll({ + include: [{ as: "actor", attributes: ["id", "email"], model: User }], + limit: 20, + order: [["createdAt", "DESC"]], + where: { targetProfileId: profileId } + }) + ]); + + const kycCaseByProviderCustomer = new Map(); + for (const kycCase of kycCases) { + if (kycCase.providerCustomerId) { + kycCaseByProviderCustomer.set(kycCase.providerCustomerId, kycCase); + } + } + + res.status(httpStatus.OK).json({ + activeEntityId: profile.activeCustomerEntityId, + createdAt: profile.createdAt, + email: profile.email, + entities: entities.map(entity => ({ + country: entity.country, + id: entity.id, + providerCustomers: providerCustomers + .filter(customer => customer.customerEntityId === entity.id) + .map(customer => { + const kycCase = kycCaseByProviderCustomer.get(customer.id) ?? null; + return { + companyName: customer.companyName, + country: customer.country, + createdAt: customer.createdAt, + customerType: customer.customerType, + id: customer.id, + kycCase: kycCase + ? { + approvedAt: kycCase.approvedAt, + failureReasons: kycCase.failureReasons, + id: kycCase.id, + level: kycCase.level, + rejectedAt: kycCase.rejectedAt, + status: kycCase.status, + statusExternal: kycCase.statusExternal, + submittedAt: kycCase.submittedAt, + type: kycCase.type + } + : null, + provider: customer.provider, + rail: customer.rail, + status: customer.status, + statusExternal: customer.statusExternal, + updatedAt: customer.updatedAt + }; + }), + status: entity.status, + type: entity.type + })), + id: profile.id, + impersonationSessions: impersonationSessions.map(session => { + const actor = (session as AdminImpersonationSession & { actor?: User }).actor; + return { + active: isSessionActive(session), + actor: actor ? { email: actor.email, id: actor.id } : { email: null, id: session.actorProfileId }, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + id: session.id, + revokedAt: session.revokedAt, + revokedReason: session.revokedReason + }; + }) + }); + } catch (error) { + logger.error("Error reading admin-console account detail:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to read account", status: httpStatus.INTERNAL_SERVER_ERROR } + }); + } +} diff --git a/apps/api/src/api/controllers/admin-console/impersonation.controller.ts b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts new file mode 100644 index 000000000..162ad6236 --- /dev/null +++ b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts @@ -0,0 +1,194 @@ +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, + ImpersonationDisabledError, + ImpersonationTargetError, + isSessionActive, + listSessions, + revokeSession +} from "../../services/impersonation.service"; + +/** + * 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" || !targetProfileId) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { code: "INVALID_IMPERSONATION_INPUT", message: "targetProfileId is required", 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 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 limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined; + const sessions = await listSessions({ limit: Number.isFinite(limit) ? limit : undefined }); + + 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; + 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/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/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..5e1c49863 --- /dev/null +++ b/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts @@ -0,0 +1,212 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import express from "express"; +import { config } from "../../../../config/vars"; +import AdminImpersonationSession from "../../../../models/adminImpersonationSession.model"; +import ProfileRole from "../../../../models/profileRole.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../../test-utils/db"; +import { createTestAlfredpayCustomer, createTestUser } from "../../../../test-utils/factories"; +import { SupabaseAuthService } from "../../../services/auth"; +import { createSession } from "../../../services/impersonation.service"; +import accountsRoutes from "./accounts.route"; +import impersonationRoutes from "./impersonation.route"; + +describe("admin-console routes", () => { + let server: ReturnType; + let baseUrl: string; + const originalImpersonationEnabled = config.impersonationEnabled; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use("/v1/admin-console/accounts", accountsRoutes); + app.use("/v1/admin-console/impersonation", impersonationRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not bind test server"); + } + baseUrl = `http://127.0.0.1:${address.port}/v1/admin-console`; + }); + + afterAll(() => { + server?.close(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.impersonationEnabled = originalImpersonationEnabled; + }); + + afterEach(() => { + config.impersonationEnabled = originalImpersonationEnabled; + }); + + async function createAdmin() { + const admin = await createTestUser(); + await ProfileRole.create({ role: "vortex_admin", userId: admin.id }); + return admin; + } + + function authAs(user: { id: string; email: string }) { + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ email: user.email, user_id: user.id, valid: true }); + return { Authorization: "Bearer whatever" }; + } + + describe("GET /accounts", () => { + it("lists a profile with its entities and verification summary", async () => { + const admin = await createAdmin(); + const target = await createTestUser(); + await createTestAlfredpayCustomer(target.id); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/accounts?search=${encodeURIComponent(target.email)}`, { headers }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + accounts: { id: string; entities: { id: string }[]; verificationSummary: Record }[]; + }; + const account = body.accounts.find(a => a.id === target.id); + expect(account).toBeDefined(); + expect(account?.entities.length).toBe(1); + expect(account?.verificationSummary.approved).toBe(1); + }); + + it("returns full detail for a single profile", async () => { + const admin = await createAdmin(); + const target = await createTestUser(); + await createTestAlfredpayCustomer(target.id); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/accounts/${target.id}`, { headers }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + id: string; + entities: { providerCustomers: { provider: string }[] }[]; + impersonationSessions: unknown[]; + }; + expect(body.id).toBe(target.id); + expect(body.entities.length).toBe(1); + expect(body.entities[0].providerCustomers[0].provider).toBe("alfredpay"); + expect(body.impersonationSessions).toEqual([]); + }); + + it("returns 404 for an unknown profile", async () => { + const admin = await createAdmin(); + const headers = authAs(admin); + + const response = await fetch(`${baseUrl}/accounts/${crypto.randomUUID()}`, { headers }); + expect(response.status).toBe(404); + }); + }); + + 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"); + }); + }); + + 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); + }); + }); +}); 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/index.ts b/apps/api/src/api/routes/v1/index.ts index edeba260c..2e81e59f0 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -7,6 +7,8 @@ import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import partnerPricingConfigsRoutes from "./admin/partner-pricing-configs.route"; import profilePartnerAssignmentsRoutes from "./admin/profile-partner-assignments.route"; import profileRolesRoutes from "./admin/profile-roles.route"; +import adminConsoleAccountsRoutes from "./admin-console/accounts.route"; +import adminConsoleImpersonationRoutes from "./admin-console/impersonation.route"; import alfredpayRoutes from "./alfredpay.route"; import apiCredentialsRoutes from "./api-credentials.route"; import authRoutes from "./auth.route"; @@ -241,8 +243,10 @@ router.use("/admin/profile-partner-assignments", profilePartnerAssignmentsRoutes router.use("/admin/partner-pricing-configs", partnerPricingConfigsRoutes); /** - * Admin routes for profile capability roles (e.g. discount_manager); profiles are - * addressed by id or email (unique key) + * Admin routes for profile capability roles; profiles are addressed by id or email + * (unique key). POST only grants HTTP-grantable roles (discount_manager) — vortex_admin + * must be granted out-of-band (see scripts/grant-vortex-admin.ts) since ADMIN_SECRET + * alone must never be sufficient to confer it. DELETE can still revoke any role. * POST /v1/admin/profile-roles * DELETE /v1/admin/profile-roles/:userIdOrEmail/:role */ @@ -255,6 +259,22 @@ router.use("/admin/managed-profiles", managedProfilesRoutes); */ router.use("/admin/api-client-events", apiClientEventsRoutes); +/** + * Vortex-admin console (Supabase-authenticated + vortex_admin role). Deliberately not + * under /v1/admin/*, which never accepts Supabase auth as a fallback + * (see docs/security-spec/01-auth/admin-auth.md). + * GET /v1/admin-console/accounts + * GET /v1/admin-console/accounts/:profileId + */ +router.use("/admin-console/accounts", adminConsoleAccountsRoutes); + +/** + * POST /v1/admin-console/impersonation + * GET /v1/admin-console/impersonation + * DELETE /v1/admin-console/impersonation/:sessionId + */ +router.use("/admin-console/impersonation", adminConsoleImpersonationRoutes); + router.get("/ip", (request: Request, response: Response) => { response.send(request.ip); }); From 5a9f9a6be753915e6b09199961be0fb046b269e5 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:01:56 +0200 Subject: [PATCH 06/29] feat(dashboard): route requests through the active impersonation token --- .../src/services/api/api-client.test.ts | 126 ++++++++++++++++++ apps/dashboard/src/services/api/api-client.ts | 19 ++- apps/dashboard/src/services/auth.test.ts | 42 ++++++ apps/dashboard/src/services/auth.ts | 48 +++++++ apps/dashboard/src/stores/auth.store.ts | 2 +- .../src/stores/impersonation.store.ts | 48 +++++++ 6 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/src/services/api/api-client.test.ts create mode 100644 apps/dashboard/src/stores/impersonation.store.ts 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..907eac7db --- /dev/null +++ b/apps/dashboard/src/services/api/api-client.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { AuthService } from "@/services/auth"; +import { apiClient, isApiError } from "./api-client"; + +const originalFetch = globalThis.fetch; +const 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(); +}); + +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("apiFetch while impersonating", () => { + beforeEach(() => { + AuthService.storeTokens({ + accessToken: "operator-access-token", + refreshToken: "operator-refresh-token", + userEmail: "operator@vortex.fi", + userId: "operator-1" + }); + AuthService.storeImpersonationSession({ + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123" + }); + }); + + it("authorizes requests with the impersonation token, not the operator's token", async () => { + let authorization: string | undefined; + globalThis.fetch = (async (_input, init) => { + authorization = (init?.headers as Record).Authorization; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/ping"); + + assert.equal(authorization, "Bearer vtx_imp_abc123"); + }); + + it("does not attempt a token refresh on 401 and clears the impersonation session instead", async () => { + let fetchCalls = 0; + globalThis.fetch = (async (input) => { + fetchCalls += 1; + // A refresh attempt would hit /v1/auth/refresh — assert it never happens. + assert.doesNotMatch(String(input), /\/auth\/refresh/); + return new Response(null, { status: 401 }); + }) as typeof fetch; + + await assert.rejects(() => apiClient.get("/ping"), error => isApiError(error) && error.status === 401); + + assert.equal(fetchCalls, 1); + assert.equal(AuthService.getImpersonationSession(), null); + // The operator's own tokens must stay untouched. + assert.equal(AuthService.getTokens()?.accessToken, "operator-access-token"); + }); +}); + +describe("apiFetch without impersonation", () => { + beforeEach(() => { + AuthService.storeTokens({ + accessToken: "expired-access-token", + refreshToken: "refresh-token", + userEmail: "e2e@vortex.local", + userId: "user-1" + }); + }); + + it("still retries once via token refresh on a 401", async () => { + let refreshCalled = false; + let secondRequestToken: string | undefined; + let call = 0; + + globalThis.fetch = (async (input, init) => { + call += 1; + if (String(input).includes("/auth/refresh")) { + refreshCalled = true; + return new Response( + JSON.stringify({ access_token: "rotated-access-token", refresh_token: "rotated-refresh-token" }), + { headers: { "Content-Type": "application/json" }, status: 200 } + ); + } + if (call === 1) { + return new Response(null, { status: 401 }); + } + secondRequestToken = (init?.headers as Record).Authorization; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/ping"); + + assert.equal(refreshCalled, true); + assert.equal(secondRequestToken, "Bearer rotated-access-token"); + }); +}); diff --git a/apps/dashboard/src/services/api/api-client.ts b/apps/dashboard/src/services/api/api-client.ts index 4fd8c8d99..7408b8d28 100644 --- a/apps/dashboard/src/services/api/api-client.ts +++ b/apps/dashboard/src/services/api/api-client.ts @@ -59,13 +59,22 @@ async function apiFetch( signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) }); + const impersonation = AuthService.getImpersonationSession(); const initialTokens = AuthService.getTokens(); - let response = await doFetch(initialTokens?.accessToken); + let response = await doFetch(AuthService.getEffectiveAccessToken() ?? undefined); - if (response.status === 401 && initialTokens?.accessToken) { - const refreshed = await refreshTokenOnce(); - if (refreshed?.accessToken && refreshed.userId === initialTokens.userId) { - response = await doFetch(refreshed.accessToken); + if (response.status === 401) { + if (impersonation) { + // Impersonation tokens are opaque and non-renewable — there is no refresh path. + // Drop back to the operator's own (untouched) session instead of retrying. + AuthService.clearImpersonationSession(); + throw new ApiError(401, {}, "Your impersonation session has expired. You're back in your own session."); + } + if (initialTokens?.accessToken) { + const refreshed = await refreshTokenOnce(); + if (refreshed?.accessToken && refreshed.userId === initialTokens.userId) { + response = await doFetch(refreshed.accessToken); + } } } diff --git a/apps/dashboard/src/services/auth.test.ts b/apps/dashboard/src/services/auth.test.ts index dc25ac34b..c4864806e 100644 --- a/apps/dashboard/src/services/auth.test.ts +++ b/apps/dashboard/src/services/auth.test.ts @@ -229,3 +229,45 @@ describe("AuthService", () => { }); }); }); + +describe("AuthService impersonation session", () => { + it("prefers the impersonation token over the operator's own access token", () => { + assert.equal(AuthService.getEffectiveAccessToken(), "expired-access-token"); + + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + assert.equal(AuthService.getEffectiveAccessToken(), "vtx_imp_abc123"); + assert.deepEqual(AuthService.getImpersonationSession(), { + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + }); + + it("falls back to the operator's own token once the impersonation session is cleared", () => { + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + AuthService.clearImpersonationSession(); + + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(AuthService.getEffectiveAccessToken(), "expired-access-token"); + // The operator's own session must be untouched by entering/exiting impersonation. + assert.deepEqual(AuthService.getTokens(), { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + userEmail: "e2e@vortex.local", + userId: "user-1", + }); + }); +}); diff --git a/apps/dashboard/src/services/auth.ts b/apps/dashboard/src/services/auth.ts index 6a0ca9fe8..b616ff298 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -7,6 +7,14 @@ export interface AuthTokens { userEmail?: string; } +/** An active "log in as" session — opaque, non-renewable, valid 30 minutes. */ +export interface ImpersonationSession { + token: string; + sessionId: string; + expiresAt: string; + targetEmail: string; +} + /** * Session storage + refresh, ported from the widget's AuthService. Keys are * dashboard-scoped so a widget session on the same origin is never reused. @@ -16,6 +24,12 @@ 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"; + // Separate keys so an active impersonation session never touches the operator's own + // Supabase tokens above — Exit just drops these and the operator's session is already there. + private static readonly IMPERSONATION_TOKEN_KEY = "vortex_dashboard_impersonation_token"; + private static readonly IMPERSONATION_SESSION_ID_KEY = "vortex_dashboard_impersonation_session_id"; + private static readonly IMPERSONATION_EXPIRES_AT_KEY = "vortex_dashboard_impersonation_expires_at"; + private static readonly IMPERSONATION_TARGET_EMAIL_KEY = "vortex_dashboard_impersonation_target_email"; private static sessionGeneration = 0; private static refreshFlight: { generation: number; @@ -53,6 +67,40 @@ export class AuthService { localStorage.removeItem(this.USER_EMAIL_KEY); } + static storeImpersonationSession(session: ImpersonationSession): void { + localStorage.setItem(this.IMPERSONATION_TOKEN_KEY, session.token); + localStorage.setItem(this.IMPERSONATION_SESSION_ID_KEY, session.sessionId); + localStorage.setItem(this.IMPERSONATION_EXPIRES_AT_KEY, session.expiresAt); + localStorage.setItem(this.IMPERSONATION_TARGET_EMAIL_KEY, session.targetEmail); + } + + static getImpersonationSession(): ImpersonationSession | null { + const token = localStorage.getItem(this.IMPERSONATION_TOKEN_KEY); + const sessionId = localStorage.getItem(this.IMPERSONATION_SESSION_ID_KEY); + const expiresAt = localStorage.getItem(this.IMPERSONATION_EXPIRES_AT_KEY); + const targetEmail = localStorage.getItem(this.IMPERSONATION_TARGET_EMAIL_KEY); + if (!token || !sessionId || !expiresAt || !targetEmail) { + return null; + } + return { expiresAt, sessionId, targetEmail, token }; + } + + static clearImpersonationSession(): void { + localStorage.removeItem(this.IMPERSONATION_TOKEN_KEY); + localStorage.removeItem(this.IMPERSONATION_SESSION_ID_KEY); + localStorage.removeItem(this.IMPERSONATION_EXPIRES_AT_KEY); + localStorage.removeItem(this.IMPERSONATION_TARGET_EMAIL_KEY); + } + + /** The bearer token requests should use: the impersonation token takes priority when active. */ + static getEffectiveAccessToken(): string | null { + const impersonation = this.getImpersonationSession(); + if (impersonation) { + return impersonation.token; + } + return this.getTokens()?.accessToken ?? null; + } + static isAuthenticated(): boolean { const tokens = this.getTokens(); if (!tokens) { diff --git a/apps/dashboard/src/stores/auth.store.ts b/apps/dashboard/src/stores/auth.store.ts index 4184b9e9b..10260c924 100644 --- a/apps/dashboard/src/stores/auth.store.ts +++ b/apps/dashboard/src/stores/auth.store.ts @@ -45,7 +45,7 @@ function userFromTokens(tokens: AuthTokens): AuthUser { return { email, name: displayNameFromEmail(email), userId: tokens.userId }; } -function clearAccountState(): void { +export function clearAccountState(): void { queryClient.clear(); useNotificationsStore.getState().clear(); resetTransferState(); diff --git a/apps/dashboard/src/stores/impersonation.store.ts b/apps/dashboard/src/stores/impersonation.store.ts new file mode 100644 index 000000000..053b105ec --- /dev/null +++ b/apps/dashboard/src/stores/impersonation.store.ts @@ -0,0 +1,48 @@ +import { create } from "zustand"; +import { AdminConsoleService } from "@/services/api/admin-console.service"; +import { AuthService, type ImpersonationSession } from "@/services/auth"; +import { clearAccountState } from "./auth.store"; + +interface ImpersonationState { + session: ImpersonationSession | null; + enter: (session: ImpersonationSession) => void; + exit: () => Promise; + /** Reconcile with storage — picks up a session cleared elsewhere (e.g. api-client on a 401). */ + syncFromStorage: () => void; +} + +/** "Log in as" session: entering/exiting always clears query cache and client state so no + * data from one identity leaks into the other. */ +export const useImpersonationStore = create()((set, get) => ({ + enter: session => { + clearAccountState(); + AuthService.storeImpersonationSession(session); + set({ session }); + }, + exit: async () => { + const { session } = get(); + if (session) { + try { + await AdminConsoleService.endImpersonation(session.sessionId); + } catch { + // Never strand the operator in someone else's session over a failed network call. + } + } + AuthService.clearImpersonationSession(); + clearAccountState(); + set({ session: null }); + }, + session: AuthService.getImpersonationSession(), + syncFromStorage: () => { + const stored = AuthService.getImpersonationSession(); + const current = get().session; + if (!stored && current) { + clearAccountState(); + set({ session: null }); + return; + } + if (stored && stored.token !== current?.token) { + set({ session: stored }); + } + } +})); From 4505c5fd8661de7c5f5125ee5141178e21910808 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:02:02 +0200 Subject: [PATCH 07/29] feat(dashboard): add admin console with impersonation controls --- .../components/admin/AdminAccountsTable.tsx | 96 ++++++++++++ .../components/admin/ImpersonateDialog.tsx | 78 ++++++++++ .../src/components/layout/AppSidebar.tsx | 13 +- .../components/layout/ImpersonationBanner.tsx | 65 ++++++++ apps/dashboard/src/hooks/useAdminConsole.ts | 41 +++++ apps/dashboard/src/routeTree.gen.ts | 52 +++++++ apps/dashboard/src/routes/_app.tsx | 2 + .../src/routes/_app/admin.$profileId.tsx | 137 +++++++++++++++++ apps/dashboard/src/routes/_app/admin.tsx | 140 ++++++++++++++++++ .../src/services/api/admin-console.service.ts | 123 +++++++++++++++ 10 files changed, 745 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/src/components/admin/AdminAccountsTable.tsx create mode 100644 apps/dashboard/src/components/admin/ImpersonateDialog.tsx create mode 100644 apps/dashboard/src/components/layout/ImpersonationBanner.tsx create mode 100644 apps/dashboard/src/hooks/useAdminConsole.ts create mode 100644 apps/dashboard/src/routes/_app/admin.$profileId.tsx create mode 100644 apps/dashboard/src/routes/_app/admin.tsx create mode 100644 apps/dashboard/src/services/api/admin-console.service.ts diff --git a/apps/dashboard/src/components/admin/AdminAccountsTable.tsx b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx new file mode 100644 index 000000000..7c60c0b46 --- /dev/null +++ b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx @@ -0,0 +1,96 @@ +import { Link } from "@tanstack/react-router"; +import { LogIn, Users } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { AdminAccountSummary } from "@/services/api/admin-console.service"; +import { ImpersonateDialog } from "./ImpersonateDialog"; + +function formatDate(value: string): string { + return new Date(value).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); +} + +function verificationEntries(summary: AdminAccountSummary["verificationSummary"]) { + return Object.entries(summary).filter(([, count]) => count > 0); +} + +export function AdminAccountsTable({ accounts }: { accounts: AdminAccountSummary[] }) { + const [target, setTarget] = useState<{ id: string; email: string } | null>(null); + + return ( + <> + + + + Account + Entities + Verification + Pricing partner + Created + Action + + + + {accounts.map(account => ( + + + + {account.email} + + + +
+ {account.entities.length === 0 ? ( + None + ) : ( + account.entities.map(entity => ( + + {entity.type} · {entity.status} + + )) + )} +
+
+ +
+ {verificationEntries(account.verificationSummary).length === 0 ? ( + None + ) : ( + verificationEntries(account.verificationSummary).map(([status, count]) => ( + + {count} {status.replace("_", " ")} + + )) + )} +
+
+ {account.activePartnerName ?? "—"} + {formatDate(account.createdAt)} + + + +
+ ))} +
+
+ {accounts.length === 0 && ( +
+ + + +

No accounts found

+
+ )} + !open && setTarget(null)} target={target} /> + + ); +} diff --git a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx new file mode 100644 index 000000000..93ddce7e3 --- /dev/null +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -0,0 +1,78 @@ +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 { useImpersonationStore } from "@/stores/impersonation.store"; + +/** + * "Log in as" confirmation: swaps the active session to the returned impersonation token + * and lands on Overview. The session itself is audited server-side against the operator. + */ +export function ImpersonateDialog({ + onOpenChange, + target +}: { + onOpenChange: (open: boolean) => void; + target: { id: string; email: string } | null; +}) { + const navigate = useNavigate(); + const enterImpersonation = useImpersonationStore(state => state.enter); + const startImpersonation = useStartImpersonation(); + + function handleOpenChange(open: boolean) { + onOpenChange(open); + if (!open) { + startImpersonation.reset(); + } + } + + function onConfirm() { + if (!target) return; + startImpersonation.mutate( + { targetProfileId: target.id }, + { + onError: error => { + toast.error("Could not start the impersonation session", { + description: error instanceof Error ? error.message : undefined + }); + }, + onSuccess: response => { + enterImpersonation({ + expiresAt: response.expiresAt, + sessionId: response.sessionId, + targetEmail: response.target.email, + token: response.token + }); + handleOpenChange(false); + navigate({ to: "/overview" }); + } + } + ); + } + + if (!target) return null; + + return ( + + + + Log in as {target.email}? + + You'll act as this customer until the session expires in 30 minutes. This is logged against your account. + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index d6c54e869..f41d7a4bb 100644 --- a/apps/dashboard/src/components/layout/AppSidebar.tsx +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -1,5 +1,5 @@ import { Link, useRouterState } from "@tanstack/react-router"; -import { ArrowLeftRight, Calculator, Gauge, KeyRound, Send, Settings, ShieldCheck, Users } from "lucide-react"; +import { ArrowLeftRight, Calculator, Gauge, KeyRound, Send, Settings, ShieldCheck, UserCog, Users } from "lucide-react"; import { Sidebar, SidebarContent, @@ -11,6 +11,8 @@ import { SidebarMenuItem, SidebarRail } from "@/components/ui/sidebar"; +import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; +import { useImpersonationStore } from "@/stores/impersonation.store"; import { VortexLogo } from "./VortexLogo"; const NAV_ITEMS = [ @@ -24,8 +26,15 @@ const NAV_ITEMS = [ { icon: Settings, label: "Settings", to: "/settings" } ] as const; +const ADMIN_NAV_ITEM = { icon: UserCog, label: "Admin", to: "/admin" } as const; + export function AppSidebar() { const pathname = useRouterState({ select: state => state.location.pathname }); + const { data: onboardingStatus } = useOnboardingStatusQuery(); + const isImpersonating = useImpersonationStore(state => state.session !== null); + const isAdmin = onboardingStatus?.roles.includes("vortex_admin") ?? false; + // An operator acting as a customer must see exactly the customer's navigation. + const navItems = isAdmin && !isImpersonating ? [...NAV_ITEMS, ADMIN_NAV_ITEM] : NAV_ITEMS; return ( @@ -38,7 +47,7 @@ export function AppSidebar() { - {NAV_ITEMS.map(item => ( + {navItems.map(item => ( diff --git a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx new file mode 100644 index 000000000..b0f84f0d2 --- /dev/null +++ b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx @@ -0,0 +1,65 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useImpersonationStore } 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. Ticks every second both to show time remaining and to notice + * a session api-client already cleared (expired token on a 401) so the banner drops itself. + */ +export function ImpersonationBanner() { + const session = useImpersonationStore(state => state.session); + const exit = useImpersonationStore(state => state.exit); + const syncFromStorage = useImpersonationStore(state => state.syncFromStorage); + const navigate = useNavigate(); + const [remainingMs, setRemainingMs] = useState(null); + const [exiting, setExiting] = useState(false); + + useEffect(() => { + if (!session) { + setRemainingMs(null); + return; + } + const tick = () => { + syncFromStorage(); + setRemainingMs(new Date(session.expiresAt).getTime() - Date.now()); + }; + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [session, syncFromStorage]); + + if (!session) { + return null; + } + + async function handleExit() { + setExiting(true); + try { + await exit(); + navigate({ to: "/admin" }); + } finally { + setExiting(false); + } + } + + return ( +
+ + You are acting as {session.targetEmail} + {remainingMs !== null && <> · {formatRemaining(remainingMs)} remaining} + + +
+ ); +} diff --git a/apps/dashboard/src/hooks/useAdminConsole.ts b/apps/dashboard/src/hooks/useAdminConsole.ts new file mode 100644 index 000000000..e1ffd7eaf --- /dev/null +++ b/apps/dashboard/src/hooks/useAdminConsole.ts @@ -0,0 +1,41 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AdminConsoleService, + type ListAdminAccountsParams, + type StartImpersonationRequest +} from "@/services/api/admin-console.service"; + +export const ADMIN_ACCOUNTS_QUERY_KEY = "admin-accounts"; +export const ADMIN_ACCOUNT_QUERY_KEY = "admin-account"; +export const ADMIN_IMPERSONATION_SESSIONS_QUERY_KEY = "admin-impersonation-sessions"; + +export function useAdminAccounts(params: ListAdminAccountsParams) { + return useQuery({ + placeholderData: keepPreviousData, + queryFn: ({ signal }) => AdminConsoleService.listAccounts(params, signal), + queryKey: [ADMIN_ACCOUNTS_QUERY_KEY, params] + }); +} + +export function useAdminAccount(profileId: string) { + return useQuery({ + enabled: !!profileId, + queryFn: ({ signal }) => AdminConsoleService.getAccount(profileId, signal), + queryKey: [ADMIN_ACCOUNT_QUERY_KEY, profileId] + }); +} + +export function useAdminImpersonationSessions() { + return useQuery({ + queryFn: ({ signal }) => AdminConsoleService.listImpersonationSessions(signal), + queryKey: [ADMIN_IMPERSONATION_SESSIONS_QUERY_KEY] + }); +} + +export function useStartImpersonation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: StartImpersonationRequest) => AdminConsoleService.startImpersonation(request), + onSuccess: () => queryClient.invalidateQueries({ queryKey: [ADMIN_IMPERSONATION_SESSIONS_QUERY_KEY] }) + }); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index a2d339a04..1f54638f4 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -22,6 +22,8 @@ import { Route as AppQuoteRouteImport } from './routes/_app/quote' import { Route as AppOverviewRouteImport } from './routes/_app/overview' import { Route as AppLimitsRouteImport } from './routes/_app/limits' import { Route as AppApiKeysRouteImport } from './routes/_app/api-keys' +import { Route as AppAdminRouteImport } from './routes/_app/admin' +import { Route as AppAdminProfileIdRouteImport } from './routes/_app/admin.$profileId' const LoginRoute = LoginRouteImport.update({ id: '/login', @@ -87,10 +89,21 @@ const AppApiKeysRoute = AppApiKeysRouteImport.update({ path: '/api-keys', getParentRoute: () => AppRoute, } as any) +const AppAdminRoute = AppAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => AppRoute, +} as any) +const AppAdminProfileIdRoute = AppAdminProfileIdRouteImport.update({ + id: '/$profileId', + path: '/$profileId', + getParentRoute: () => AppAdminRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/admin': typeof AppAdminRouteWithChildren '/api-keys': typeof AppApiKeysRoute '/limits': typeof AppLimitsRoute '/overview': typeof AppOverviewRoute @@ -101,10 +114,12 @@ export interface FileRoutesByFullPath { '/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute + '/admin/$profileId': typeof AppAdminProfileIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute + '/admin': typeof AppAdminRouteWithChildren '/api-keys': typeof AppApiKeysRoute '/limits': typeof AppLimitsRoute '/overview': typeof AppOverviewRoute @@ -115,12 +130,14 @@ export interface FileRoutesByTo { '/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute + '/admin/$profileId': typeof AppAdminProfileIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_app': typeof AppRouteWithChildren '/login': typeof LoginRoute + '/_app/admin': typeof AppAdminRouteWithChildren '/_app/api-keys': typeof AppApiKeysRoute '/_app/limits': typeof AppLimitsRoute '/_app/overview': typeof AppOverviewRoute @@ -131,12 +148,14 @@ export interface FileRoutesById { '/_app/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute + '/_app/admin/$profileId': typeof AppAdminProfileIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/login' + | '/admin' | '/api-keys' | '/limits' | '/overview' @@ -147,10 +166,12 @@ export interface FileRouteTypes { | '/transfer' | '/invite/$token' | '/monerium/callback' + | '/admin/$profileId' fileRoutesByTo: FileRoutesByTo to: | '/' | '/login' + | '/admin' | '/api-keys' | '/limits' | '/overview' @@ -161,11 +182,13 @@ export interface FileRouteTypes { | '/transfer' | '/invite/$token' | '/monerium/callback' + | '/admin/$profileId' id: | '__root__' | '/' | '/_app' | '/login' + | '/_app/admin' | '/_app/api-keys' | '/_app/limits' | '/_app/overview' @@ -176,6 +199,7 @@ export interface FileRouteTypes { | '/_app/transfer' | '/invite/$token' | '/monerium/callback' + | '/_app/admin/$profileId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -279,10 +303,37 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppApiKeysRouteImport parentRoute: typeof AppRoute } + '/_app/admin': { + id: '/_app/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AppAdminRouteImport + parentRoute: typeof AppRoute + } + '/_app/admin/$profileId': { + id: '/_app/admin/$profileId' + path: '/$profileId' + fullPath: '/admin/$profileId' + preLoaderRoute: typeof AppAdminProfileIdRouteImport + parentRoute: typeof AppAdminRoute + } } } +interface AppAdminRouteChildren { + AppAdminProfileIdRoute: typeof AppAdminProfileIdRoute +} + +const AppAdminRouteChildren: AppAdminRouteChildren = { + AppAdminProfileIdRoute: AppAdminProfileIdRoute, +} + +const AppAdminRouteWithChildren = AppAdminRoute._addFileChildren( + AppAdminRouteChildren, +) + interface AppRouteChildren { + AppAdminRoute: typeof AppAdminRouteWithChildren AppApiKeysRoute: typeof AppApiKeysRoute AppLimitsRoute: typeof AppLimitsRoute AppOverviewRoute: typeof AppOverviewRoute @@ -294,6 +345,7 @@ interface AppRouteChildren { } const AppRouteChildren: AppRouteChildren = { + AppAdminRoute: AppAdminRouteWithChildren, AppApiKeysRoute: AppApiKeysRoute, AppLimitsRoute: AppLimitsRoute, AppOverviewRoute: AppOverviewRoute, diff --git a/apps/dashboard/src/routes/_app.tsx b/apps/dashboard/src/routes/_app.tsx index 72785cf36..71803e632 100644 --- a/apps/dashboard/src/routes/_app.tsx +++ b/apps/dashboard/src/routes/_app.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Navigate, Outlet, useRouterState } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AppSidebar } from "@/components/layout/AppSidebar"; +import { ImpersonationBanner } from "@/components/layout/ImpersonationBanner"; import { Topbar } from "@/components/layout/Topbar"; import { AccountTypeSelector } from "@/components/onboarding/AccountTypeSelector"; import { Button } from "@/components/ui/button"; @@ -49,6 +50,7 @@ function AppLayout() { + {/* Re-key on pathname so each navigation cross-fades the page content in. */} ; + } + if (!isAdmin) { + return ; + } + return ; +} + +function AccountDetail() { + const { profileId } = Route.useParams(); + const account = useAdminAccount(profileId); + const [impersonateTarget, setImpersonateTarget] = useState<{ id: string; email: string } | null>(null); + + if (account.isLoading) { + return ; + } + + if (account.isError || !account.data) { + return ( +
+

Could not load this account

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

{data.email}

+

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

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

No customer entities.

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

No provider accounts.

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

No impersonation sessions yet.

+ ) : ( + data.impersonationSessions.map(session => ( +
+ + {session.actor.email ?? session.actor.id} ·{" "} + {new Date(session.createdAt).toLocaleString()} + + {session.active ? "Active" : "Ended"} +
+ )) + )} +
+
+
+ + !open && setImpersonateTarget(null)} target={impersonateTarget} /> +
+ ); +} diff --git a/apps/dashboard/src/routes/_app/admin.tsx b/apps/dashboard/src/routes/_app/admin.tsx new file mode 100644 index 000000000..ab220f141 --- /dev/null +++ b/apps/dashboard/src/routes/_app/admin.tsx @@ -0,0 +1,140 @@ +import { createFileRoute, Navigate } 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 { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; + +const PAGE_LIMIT = 20; + +export const Route = createFileRoute("/_app/admin")({ + component: AdminPage +}); + +function AdminPage() { + const onboardingStatus = useOnboardingStatusQuery(); + const isAdmin = onboardingStatus.data?.roles.includes("vortex_admin") ?? false; + + if (onboardingStatus.isLoading) { + return ; + } + if (!isAdmin) { + return ; + } + return ; +} + +function AdminAccountsPage() { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + const [cursorStack, setCursorStack] = useState([]); + const cursor = cursorStack.at(-1); + + const accounts = useAdminAccounts({ cursor, limit: PAGE_LIMIT, search: debouncedSearch || undefined }); + const sessions = useAdminImpersonationSessions(); + + return ( + + +

Admin

+

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

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

Could not load accounts.

+ +
+ ) : ( + <> + +
+ + +
+ + )} +
+
+
+ + + + + Recent impersonation activity + + + {sessions.isLoading ? ( + + ) : sessions.isError || !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/services/api/admin-console.service.ts b/apps/dashboard/src/services/api/admin-console.service.ts new file mode 100644 index 000000000..fc439f3fb --- /dev/null +++ b/apps/dashboard/src/services/api/admin-console.service.ts @@ -0,0 +1,123 @@ +import { apiClient } from "./api-client"; + +/** Mirrors `VerificationStatus` in the API — raw enum values, used as summary keys. */ +export type AdminVerificationStatus = "pending" | "started" | "in_review" | "approved" | "rejected"; + +export interface AdminCustomerEntity { + id: string; + type: string; + status: string; +} + +/** One row of GET /admin-console/accounts. */ +export interface AdminAccountSummary { + id: string; + email: string; + createdAt: string; + entities: AdminCustomerEntity[]; + /** Provider-customer counts per verification status, across all of the account's entities. */ + verificationSummary: Record; + activePartnerName: string | null; +} + +export interface AdminAccountsPage { + accounts: AdminAccountSummary[]; + limit: number; + nextCursor: string | null; + total: number; +} + +export interface AdminKycCase { + id: string; + type: string; + level: string | null; + status: string; + statusExternal: string | null; + failureReasons: string[] | null; + submittedAt: string | null; + approvedAt: string | null; + rejectedAt: string | null; +} + +export interface AdminProviderCustomer { + id: string; + provider: string; + rail: string | null; + status: AdminVerificationStatus; + statusExternal: string | null; + customerType: string; + companyName: string | null; + country: string | null; + createdAt: string; + updatedAt: string; + kycCase: AdminKycCase | null; +} + +/** Detail nests provider customers under their entity, matching the onboarding endpoint. */ +export interface AdminCustomerEntityDetail extends AdminCustomerEntity { + country: string | null; + providerCustomers: AdminProviderCustomer[]; +} + +export interface AdminSessionParty { + id: string; + email: string | null; +} + +/** Sessions returned by the account-detail endpoint, all targeting that account. */ +export interface AdminImpersonationSessionSummary { + id: string; + actor: AdminSessionParty; + createdAt: string; + expiresAt: string; + revokedAt: string | null; + revokedReason: string | null; + active: boolean; +} + +/** The audit list additionally names the target, since it spans accounts. */ +export interface AdminImpersonationSessionRecord extends AdminImpersonationSessionSummary { + target: AdminSessionParty; +} + +export interface AdminAccountDetail { + id: string; + email: string; + createdAt: string; + activeEntityId: string | null; + entities: AdminCustomerEntityDetail[]; + impersonationSessions: AdminImpersonationSessionSummary[]; +} + +export interface ListAdminAccountsParams extends Record { + search?: string; + cursor?: string; + limit?: number; +} + +export interface StartImpersonationRequest { + targetProfileId: string; +} + +export interface StartImpersonationResponse { + token: string; + sessionId: string; + expiresAt: string; + target: { id: string; email: string }; +} + +export interface ListImpersonationSessionsResponse { + sessions: AdminImpersonationSessionRecord[]; +} + +export const AdminConsoleService = { + endImpersonation: (sessionId: string) => apiClient.delete(`/admin-console/impersonation/${sessionId}`), + getAccount: (profileId: string, signal?: AbortSignal) => + apiClient.get(`/admin-console/accounts/${profileId}`, { signal }), + listAccounts: (params: ListAdminAccountsParams, signal?: AbortSignal) => + apiClient.get("/admin-console/accounts", { params, signal }), + listImpersonationSessions: (signal?: AbortSignal) => + apiClient.get("/admin-console/impersonation", { signal }), + startImpersonation: (request: StartImpersonationRequest) => + apiClient.post("/admin-console/impersonation", request) +}; From be0c582689900da833fb8f26c9f5b12de58959ec Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:02:17 +0200 Subject: [PATCH 08/29] docs: document admin impersonation trust chain and residual risk --- docs/architecture-identity-model.md | 26 +- docs/product-dashboard.md | 49 ++++ docs/security-spec/01-auth/admin-auth.md | 16 +- .../01-auth/admin-impersonation.md | 252 ++++++++++++++++++ docs/security-spec/README.md | 1 + docs/security-spec/RISK-REGISTER.md | 3 +- 6 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 docs/security-spec/01-auth/admin-impersonation.md diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index 8aad0ff06..9084b2ea9 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -1,7 +1,8 @@ # Identity, Customer, and Partner Model Status: current architecture. Last reconciled with migrations 038–054 and the API models -on 2026-07-31. +on 2026-07-31, plus the admin-impersonation principal-resolution seam (migrations 059–060) +on 2026-08-05. This document explains the implemented identity model across authentication, compliance customers, provider accounts, partner pricing, and recipients. Security invariants remain @@ -99,14 +100,30 @@ Current product behavior and acknowledged gaps are in ## Authentication and ownership flow 1. `requirePartnerOrUserAuth()` accepts a valid secret API key or Supabase bearer token. -2. `getEffectiveUserId()` prefers the Supabase user and otherwise uses the user linked to - the validated secret key. + Any presented bearer token — on this path or on the Supabase-only `requireAuth`/ + `optionalAuth` middleware — is first resolved by `resolveBearerPrincipal()` + (`bearerPrincipal.ts`). This is the one place a request's principal can become someone + other than the credential holder: a token prefixed `vtx_imp_` resolves against a live + row in `admin_impersonation_sessions` and, if found, the principal returned is the + **target** profile (its `userId` and `userEmail`), not the `vortex_admin` operator who + holds the token. An ordinary Supabase token resolves unchanged. The operator's own + identity is preserved separately on `req.impersonation` for audit; it does not + participate in ownership resolution. +2. `getEffectiveUserId()` prefers `req.userId` and otherwise uses the user linked to + the validated secret key. It is unmodified by impersonation — by the time it runs, + `req.userId` already reflects step 1's substitution, so every step below scopes to the + target profile exactly as it would for that profile's own session. 3. Ownership middleware scopes quotes, ramps, provider accounts, recipients, and history to that effective user and their customer entities. 4. At ramp registration, the server resolves the provider account for the effective user. Client-supplied provider identifiers are either ignored or accepted only when they match the server-derived identity. +Impersonation is a substitution at step 1, not a parallel authorization path — nothing from +step 2 onward changes. Its session lifecycle, controls, and audit trail are normative in +[`security-spec/01-auth/admin-impersonation.md`](security-spec/01-auth/admin-impersonation.md); +this document only reflects where the seam sits in principal resolution. + Quotes remain available before login where the public API permits rate discovery. An authenticated user may claim an anonymous quote at registration; an already user-owned quote cannot be claimed by another user. @@ -114,7 +131,8 @@ quote cannot be claimed by another user. ## Implementation map - Sequelize models: `apps/api/src/models/{user,customerEntity,providerCustomer,kycCase,partner,partnerPricingConfig,apiKey,recipientInvitation,senderRecipient,recipientPayoutReference}.model.ts` -- Principal resolution: `apps/api/src/api/middlewares/{dualAuth,effectiveUser,ownershipAuth}.ts` +- Principal resolution: `apps/api/src/api/middlewares/{bearerPrincipal,dualAuth,effectiveUser,ownershipAuth}.ts` +- Impersonation session lifecycle: `apps/api/src/api/services/impersonation.service.ts` - Provider ownership resolution: `apps/api/src/api/services/avenia-account.ts` and provider controllers/services - Schema history: `apps/api/src/database/migrations/038-*` onward - Security details: `docs/security-spec/01-auth/`, `03-ramp-engine/recipient-transfers.md`, and the provider specs under `05-integrations/` diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 2a6b380ba..0b167091e 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -289,6 +289,55 @@ provider-shaped rather than UI-shaped. `getOrCreateCustomerEntityForProfile`. Whether users will ever be able to *switch* the active entity (individual ↔ company) remains open. +## Admin console (operator surface) + +A separate, operator-facing surface exists alongside the customer-facing dashboard described +above: profiles holding the `vortex_admin` role reach `/v1/admin-console/*` and can act on a +customer's behalf. It is documented here as the product-level counterpart to the customer +surface; its security controls are normative in +[`security-spec/01-auth/admin-impersonation.md`](security-spec/01-auth/admin-impersonation.md). + +**What v1 lets an operator do:** + +- Look up an account: `GET /v1/admin-console/accounts` (list/search) and + `GET /v1/admin-console/accounts/:profileId` (single account). +- Start impersonating a customer: `POST /v1/admin-console/impersonation` with the target + profile id, returning a 30-minute, non-renewable session token. +- See active and recent impersonation sessions: `GET /v1/admin-console/impersonation`. +- End a session immediately: `DELETE /v1/admin-console/impersonation/:sessionId`. + +**Depth is FULL, not scoped.** Once impersonating, the operator acts with the target account's +complete rights, including money movement — there is no read-only or reduced-capability +impersonation mode in v1. An impersonated request cannot mint a durable API credential or +re-enter the admin console (no privilege re-escalation, no chaining), with one narrow exception +so an operator can end its own session. + +**v1 scope is Vortex → main-account only.** There is no parent/child account table. The +main-account → sub-account delegation layer, modelled on Avenia's subaccount API, is explicitly +v2 — not present, not planned for this iteration. + +**Operator surface in this app.** The `/v1/admin-console/*` layer is implemented and covered by +tests, and the frontend that consumes it ships here: `/admin` (searchable, paginated account +table with a "Log in as" action behind a confirmation dialog) and `/admin/$profileId` +(entities, their provider accounts and KYC cases, plus recent sessions against that account). +Both redirect to `/overview` unless `roles` from `GET /v1/onboarding/status` contains +`vortex_admin`, and the sidebar's Admin item follows the same gate. While a session is live, +`ImpersonationBanner` is rendered above the topbar on every `_app` route — non-dismissible, +naming the impersonated account and offering "Exit". Because the operator's own Supabase tokens +are kept beside the impersonation token rather than replaced, exiting is local and instant. + +**Verified against a running stack.** Migrations 059 and 060 apply and revert cleanly, and the +manual flow (grant the role, log in, list accounts, impersonate, exit) has been exercised +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 +session self-revokes on exit, and a revoked token is rejected on its next use. + +Exiting revokes the session server-side on a best-effort basis: the banner clears and the +operator returns to their own session even if that `DELETE` fails, so a failed network call can +never strand them in someone else's account. When it does fail, the server-side row stays live +until the 30-minute TTL expires — the local UI state is not proof the session is closed. The +audit view (`GET /v1/admin-console/impersonation`) is authoritative. + --- Architecture: [`docs/architecture-identity-model.md`](architecture-identity-model.md). diff --git a/docs/security-spec/01-auth/admin-auth.md b/docs/security-spec/01-auth/admin-auth.md index 3f6db8813..aff84378e 100644 --- a/docs/security-spec/01-auth/admin-auth.md +++ b/docs/security-spec/01-auth/admin-auth.md @@ -2,6 +2,15 @@ ## What This Does +This document is scoped to the shared-secret `/v1/admin/*` surface. A second, independent admin +surface exists at `/v1/admin-console/*`: it is Supabase-authenticated and gated by the +`vortex_admin` profile role rather than a shared secret, is identity-bearing by design, and +includes the ability for an operator to impersonate a customer profile. That surface is +documented separately in [`admin-impersonation.md`](admin-impersonation.md) — everything below +does not apply to it. The two surfaces are independent: an admin-console operator's Supabase +session does not grant `/v1/admin/*` access, and possession of `ADMIN_SECRET` does not by itself +grant `/v1/admin-console/*` access (Invariant 8). + Admin authentication protects internal/operational endpoints that can mutate system state or manage partners. It uses a single shared secret (`ADMIN_SECRET` env var) compared via Bearer token. Read-only access to client observability endpoints uses a separate `METRICS_DASHBOARD_SECRET` so a metrics token compromise does not grant broader admin access. The flow: @@ -26,7 +35,8 @@ the shared credential; individual admin identities are out of scope for this cha 4. **Admin endpoints MUST be limited in scope** — Admin auth grants access to operational endpoints only. It MUST NOT grant the ability to initiate ramps, access user funds, or sign transactions. 5. **Error responses MUST distinguish between missing auth (401) and invalid auth (403)** — This is the current behavior: missing header → 401, invalid token → 403. 6. **The `Authorization` header MUST use the `Bearer` scheme** — Other schemes (Basic, etc.) must be rejected. -7. **Admin auth MUST NOT attach any identity to the request** — Unlike Supabase auth (which sets `userId`) or API key auth (which sets `authenticatedPartner`), admin auth is identity-less. No `req.adminUser` or similar should exist. +7. **Admin auth on `/v1/admin/*` MUST NOT attach any identity to the request** — Unlike Supabase auth (which sets `userId`) or API key auth (which sets `authenticatedPartner`), admin auth on this surface is identity-less. No `req.adminUser` or similar should exist. This invariant is scoped to `/v1/admin/*`: the separate `/v1/admin-console/*` surface is intentionally identity-bearing — it authenticates via Supabase and carries the operator's profile ID — by design; see [`admin-impersonation.md`](admin-impersonation.md). +8. **`vortex_admin` MUST NOT be grantable through `POST /v1/admin/profile-roles`** — that route is guarded only by `ADMIN_SECRET`, and `vortex_admin` grants access to `/v1/admin-console/*` including FULL-depth customer impersonation ([`admin-impersonation.md`](admin-impersonation.md)). If the shared secret could grant that role, it would be sufficient by itself to gain money-movement rights over any customer, collapsing the separation this document's "What This Does" section describes. Granting `vortex_admin` must go through an out-of-band operator process outside this route. **Enforced**: `profileRole.model.ts` exports `HTTP_GRANTABLE_PROFILE_ROLES = ["discount_manager"]`; `addProfileRole` (`profileRoles.controller.ts`) returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` (verified in `profileRoles.controller.test.ts`). `removeProfileRole` deliberately remains exempt — it can still revoke `vortex_admin` as a safety valve. The sanctioned grant path is `apps/api/scripts/grant-vortex-admin.ts`, run as `bun run grant:vortex-admin `. ## Threat Vectors & Mitigations @@ -36,6 +46,7 @@ the shared credential; individual admin identities are out of scope for this cha | **Timing leak on length mismatch** | A naive comparison returns immediately when lengths differ | `safeCompare` performs a dummy `timingSafeEqual` operation before rejecting a different-length token; equal-length values use `crypto.timingSafeEqual`. | | **ADMIN_SECRET in logs** | Secret accidentally logged via request logging middleware | Auth header should be excluded from request logging; verify no middleware logs full headers | | **Shared secret rotation** | Need to rotate ADMIN_SECRET without downtime | Currently no dual-secret or graceful rotation — changing the env var immediately invalidates all admin sessions | +| **ADMIN_SECRET escalates to customer impersonation** | Holder of `ADMIN_SECRET` calls `POST /v1/admin/profile-roles` to grant themselves (or a colluding profile) `vortex_admin`, then impersonates any customer via `/v1/admin-console/*` | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES`; the route returns `403 ROLE_NOT_HTTP_GRANTABLE` for it (Invariant 8). The only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access. | | **No individual administrative principal** | A privileged change cannot be attributed to, selectively revoked from, or constrained to one operator | **ACCEPTED RISK.** Retain the shared `ADMIN_SECRET` model for now; protect and rotate it operationally. Individual identities and role separation require a later architectural change. | | **Brute force** | Attacker iterates possible ADMIN_SECRET values | Rate limiting on admin endpoints; sufficiently long secret (recommended: 64+ chars) | | **Unauthorized admin endpoint discovery** | Attacker probes for admin routes | Admin routes should not be documented in public API docs; return 401 for unrecognized routes (not 404) | @@ -46,9 +57,10 @@ the shared credential; individual admin identities are out of scope for this cha - [x] `safeCompare()` is the only comparison used for the admin secret — no `===` or `==` anywhere — **PASS** - [x] `safeCompare()` uses `crypto.timingSafeEqual` for equal-length values and performs a dummy constant-time comparison before rejecting a different length. **PASS** - [x] `config.adminSecret` is validated at production startup, and the middleware also fails closed at runtime if absent. **PASS** -- [x] No admin endpoint also accepts Supabase auth or API key auth as a fallback (admin is the only auth layer) — **PASS** +- [x] No `/v1/admin/*` endpoint also accepts Supabase auth or API key auth as a fallback (`adminAuth` is the only auth layer on this surface) — **PASS**. (`/v1/admin-console/*` is a separate, intentionally Supabase-authenticated surface by design — see [`admin-impersonation.md`](admin-impersonation.md) — and is out of scope for this check.) - [x] Admin endpoints are not reachable from the public frontend (verify CORS, route prefix separation) — **PASS (CORS allows all origins to all routes, but auth middleware protects)** - [ ] `ADMIN_SECRET` is at least 32 characters in production — **N/A: Deployment config, not verifiable from code** - [x] No logging middleware captures the full `Authorization` header for admin requests — **PASS** - [x] Error response for invalid admin token does not include the expected token or any hint about the secret — **PASS** - [x] Missing and invalid admin-auth attempts are logged with request IP/path; secret values are not logged. **PASS** +- [x] `vortex_admin` cannot be granted via `POST /v1/admin/profile-roles` — **PASS**: `addProfileRole` returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` (`profileRoles.controller.test.ts`); the sanctioned grant path is `scripts/grant-vortex-admin.ts`. See Invariant 8. diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md new file mode 100644 index 000000000..e38d24fde --- /dev/null +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -0,0 +1,252 @@ +# Admin Impersonation + +## What This Does + +`vortex_admin` operators can act as a customer's profile through the `/v1/admin-console/*` +surface — the per-operator, Supabase-identity-bearing counterpart to the shared-secret +`/v1/admin/*` surface documented in [`admin-auth.md`](admin-auth.md). v1 scope is Vortex → +main-account only: there is no parent/child account table, and the sub-account layer +modelled on Avenia's subaccount API is deferred to v2. + +Depth is **FULL**: while impersonating, the operator acts with the target profile's complete +rights, including money movement. There is no reduced-scope or read-only impersonation mode in +v1; this is the primary residual risk this document exists to bound (see the risk register, +RISK-017). + +### Routes + +All routes live under `/v1/admin-console/*` (`accounts.route.ts`, `impersonation.route.ts`). + +| Route | Guard | Success | Notable errors | +|---|---|---|---| +| `GET /accounts?search=&cursor=&limit=` | `requireVortexAdmin` | `200` — paginated, email-`search`-filtered account list | — | +| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — entities, provider customers, KYC cases, recent impersonation sessions targeting this profile | `404 USER_NOT_FOUND` | +| `POST /impersonation` `{ targetProfileId }` | `requireVortexAdmin` | `201 { token, sessionId, expiresAt, target: { id, email } }` | `400 INVALID_IMPERSONATION_INPUT` (missing `targetProfileId`); `400 IMPERSONATION_TARGET_INVALID` (self-target, unknown target — from `ImpersonationTargetError`); `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 | — | +| `DELETE /impersonation/:sessionId` | see Invariant 12 | `204` | `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"`). It sets `req.adminProfileId` 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. +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 returns an `ImpersonationContext`. `resolveBearerPrincipal()` + then sets `userId` to the **target's** profile ID and `userEmail` to the **target's** email — + not the operator's. `bearerPrincipal.ts` is the only substitution point: `getEffectiveUserId()` + (`req.userId ?? req.credential?.profileId`), ownership middleware, and every controller + downstream run unmodified against the target. See + [`architecture-identity-model.md`](../../architecture-identity-model.md) for how this seam fits + the rest of principal resolution. +4. `req.impersonation` (`{ sessionId, actorProfileId, targetProfileId, targetEmail, expiresAt }`) + carries the operator's identity alongside the substituted principal, for audit and for + `rejectImpersonation` to gate on. +5. `GET /v1/admin-console/impersonation` lists sessions for audit (active first, then recent); + `DELETE /v1/admin-console/impersonation/:sessionId` revokes one immediately. + +Both `requireAuth`/`optionalAuth` (`supabaseAuth.ts`) and the dual-auth handlers +(`dualAuth.ts`) call `resolveBearerPrincipal()`, so an impersonation token is honored on any +route reachable by a Supabase bearer token — not only a dedicated impersonation-only path. The +`X-API-Key` credential path is a distinct credential type and is not affected. + +### Granting `vortex_admin` + +`vortex_admin` is not grantable through `POST /v1/admin/profile-roles` — that route is guarded +only by the shared `ADMIN_SECRET`, and holding `vortex_admin` is sufficient to impersonate any +customer at FULL depth, so that secret must never be sufficient by itself to grant it. +`HTTP_GRANTABLE_PROFILE_ROLES` (`profileRole.model.ts`) lists only `discount_manager`; +`addProfileRole` returns `403 ROLE_NOT_HTTP_GRANTABLE` for anything else. `removeProfileRole` +deliberately still revokes any role, including `vortex_admin`, as a safety valve. 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 and a real, distinct target** — + `createSession()` throws `ImpersonationDisabledError` when `config.impersonationEnabled` is + false, and `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 on every use, not cache a prior verdict** — + `resolveSession()` re-reads `revokedAt` and `expiresAt` from the database on each call and + returns `null` for anything not currently live (unknown, expired, revoked, 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 enforced in the application layer only; the supporting partial index + (`idx_admin_impersonation_sessions_active`) accelerates the lookup but is not a `UNIQUE` + constraint, so it does not by itself prevent two concurrent `createSession()` calls from both + succeeding in a narrow race (see Audit Checklist). +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`. +9. **The substituted principal MUST be the target on every field a controller can observe** — + `resolveBearerPrincipal()` sets both `userId` and `userEmail` to the target's values. This + matters concretely: controllers that key provider enrollment (Mykobo/Alfredpay/Monerium) off + `req.userEmail` must observe the target's email, never the operator's. +10. **`req.impersonation` MUST be set only by `resolveBearerPrincipal()`**, mirroring the + single-writer invariant Supabase auth already holds for `req.userId` + ([`supabase-otp.md`](supabase-otp.md) invariant 3) — no controller or service sets it + directly. +11. **An impersonated request MUST NOT be able to mint durable credentials** — + `rejectImpersonation` is applied ahead of `/v1/api-credentials` (`api-credentials.route.ts`): + a credential minted while acting as someone else would outlive the 30-minute session and + become a standing backdoor into the target's account. +12. **An impersonated request MUST NOT be able to reach the admin console, except to end its own + session** — There is exactly one carve-out, and it is narrow by construction: + `DELETE /v1/admin-console/impersonation/:sessionId` is mounted behind `requireAuth` only, not + the shared `requireVortexAdmin` chain every other admin-console route uses. Inside + `deleteImpersonationSession`, a request is treated as ending its own session only when + `req.impersonation.sessionId === req.params.sessionId` — i.e., the `:sessionId` path + parameter names exactly the session the caller's own bearer token resolved to. That case + skips both the `rejectImpersonation` check and the `vortex_admin` role check and proceeds + straight to revocation. Any other impersonated request to that same route — a different + `sessionId`, including a different session belonging to the same operator — is rejected with + `403 IMPERSONATION_NOT_ALLOWED` before any role check runs. Every other route (`GET + /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, `GET /impersonation`) sits + behind `requireVortexAdmin` = [`requireAuth`, `rejectImpersonation`, role check], so an + impersonated caller is refused at the `rejectImpersonation` step, before role or business + logic runs at all. **Verified**: `admin-console.route.test.ts` — an impersonated caller can + end its own session (`204`), is refused ending a different session (`403 + IMPERSONATION_NOT_ALLOWED`), and is refused `GET /accounts` and `POST /impersonation` + (`403`). +13. **Every `api_client_events` row raised during an impersonated request MUST carry both + identities** — `buildApiClientRequestMetadata()` stamps `metadata.impersonationSessionId` and + `metadata.impersonatorProfileId` whenever `req.impersonation` is set, while the event's own + `userId` field is the effective (target) user. An action is therefore always attributable to + the operator even though it is recorded against the target's account. +14. **`vortex_admin` MUST NOT be grantable through the `ADMIN_SECRET`-guarded + `POST /v1/admin/profile-roles` route** — that shared secret must not, by itself, be sufficient + to gain FULL-depth impersonation rights (i.e., money movement) over any customer; granting + `vortex_admin` requires an out-of-band operator process outside the shared-secret surface. + **Enforced**: `HTTP_GRANTABLE_PROFILE_ROLES = ["discount_manager"]` in `profileRole.model.ts`; + `addProfileRole` checks membership and returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` + (verified in `profileRoles.controller.test.ts`, "rejects granting vortex_admin via HTTP but + still allows discount_manager"). `removeProfileRole` is intentionally exempt from this list — + revocation of any role, including `vortex_admin`, remains available via that route as a safety + valve (verified: "still allows revoking vortex_admin even though it cannot be granted via + HTTP"). See [`admin-auth.md`](admin-auth.md) Invariant 8. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| Database dump exposes usable tokens | Attacker reads `admin_impersonation_sessions` from a backup or replica | Only a SHA-256 hash is stored; the raw token is never persisted (Invariant 2) | +| Stolen or leaked impersonation token replayed after the operator's intent has ended | Token captured via logs, browser history, or a compromised operator device | 30-minute non-renewable TTL (Invariant 4); instant hash-based revocation via `DELETE /impersonation/:sessionId` (Invariant 8); re-checked liveness on every use (Invariant 5) | +| Impersonation used to mint a permanent backdoor | Operator (or an attacker who obtained an operator's token) mints an API secret key while impersonating, which outlives the session | `rejectImpersonation` on `/v1/api-credentials` (Invariant 11) | +| Privilege re-escalation / impersonation chaining | An impersonated request is used to start a second impersonation session, list sessions, or browse accounts | `requireVortexAdmin`'s `rejectImpersonation` step refuses `GET /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation` outright (Invariant 12) | +| Impersonated caller abuses the self-revoke carve-out to end someone else's session | Operator impersonating profile A presents that token against profile B's `sessionId` | Rejected with `403 IMPERSONATION_NOT_ALLOWED`: the carve-out only matches when the path `:sessionId` equals the caller's own `req.impersonation.sessionId` (Invariant 12) | +| Unattributed money movement | Operator disputes having performed an action while impersonating | Per-operator Supabase identity recorded as `actorProfileId` on the session row (Invariant 3); `impersonationSessionId`/`impersonatorProfileId` on every `api_client_events` row raised during the request (Invariant 13) | +| Self-impersonation used to launder attribution | Operator targets their own profile to blur operator/target identity | Rejected at both the application layer and a database `CHECK` constraint (Invariant 3) | +| Stale sessions surviving an incident response kill switch | Operator response to a suspected compromise is "disable impersonation", but existing tokens keep working | `IMPERSONATION_ENABLED=false` invalidates all live sessions on next resolution, not just new mints (Invariant 6) | +| Token brute force / guessing | Attacker attempts to guess a valid `vtx_imp_*` value | 256 bits of randomness in the token; lookup requires an exact SHA-256 hash match | +| Shared-secret surface used to self-grant impersonation rights | An operator (or anyone) with `ADMIN_SECRET` calls `POST /v1/admin/profile-roles` to grant themselves `vortex_admin`, turning a shared secret into money-movement rights over any customer | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` (Invariant 14); the only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access, not an HTTP credential | +| Concurrent session creation races the supersession check | Two near-simultaneous `POST /impersonation` calls for the same (actor, target) both read "no active session" before either writes | Bounded impact: both sessions are minted by the same actor for the same target with independent 30-minute TTLs and are each individually revocable; this does not grant a *different* actor or target any rights. Not database-enforced (Invariant 7) | + +## Gaps Identified During This Review + +- FULL-depth impersonation (Invariant 3 does not restrict scope, only identity and target) is + a deliberate v1 design decision, not an oversight, but it remains the primary residual risk: + any compromised operator account or misused session can move a customer's funds. There is no + read-only or reduced-scope impersonation mode. Tracked as an accepted risk in the risk register + (RISK-017), not as an open implementation gap. +- Starting two sessions for the same (actor, target) pair in rapid succession is not + database-serialized (Invariant 7); the application-layer supersession check can race. Impact is + bounded — see the Threat table — and this is not considered a blocking finding. +- 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's "Exit" clears the banner + even when its `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, 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, and revoked 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` — **PASS**. No `UNIQUE` database constraint backs this; a narrow concurrent-create + race is possible (Threat table, bounded impact) — **NOTED, not a blocking finding**. +- [x] `revokeSession()` is a single scoped, idempotent update — **PASS**. +- [x] `resolveBearerPrincipal()` sets `userId`/`userEmail` to the target's values for a resolved + impersonation token, and leaves them and `impersonation` untouched for an ordinary Supabase + token — **PASS**. +- [x] `req.impersonation` is set only within `resolveBearerPrincipal()`, consumed by + `supabaseAuth.ts` and `dualAuth.ts` — **PASS**. +- [x] `rejectImpersonation` blocks `/v1/api-credentials` (credential minting) — **PASS**. +- [x] `requireVortexAdmin` (`requireAuth → rejectImpersonation → role check`) gates `GET + /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation`; an + impersonated caller is refused all four — **PASS** (`admin-console.route.test.ts`, "refuses + an impersonated caller from reaching GET /accounts or POST /impersonation"). +- [x] `DELETE /impersonation/:sessionId` allows an impersonated caller to end only its own session + (`req.impersonation.sessionId === :sessionId`) and rejects any other target with `403 + IMPERSONATION_NOT_ALLOWED`, while a non-impersonated caller still needs `vortex_admin` to + revoke any session — **PASS** (`admin-console.route.test.ts`, all four cases under "DELETE + /impersonation/:sessionId while impersonating"). +- [x] Every `api_client_events` row raised while `req.impersonation` is set carries + `impersonationSessionId` and `impersonatorProfileId` in `metadata` — **PASS**. +- [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] An out-of-band, idempotent operator process for granting `vortex_admin` exists and is + documented — **PASS** (`scripts/grant-vortex-admin.ts`, `bun run grant:vortex-admin + `). +- [x] The operator-facing frontend that consumes `/v1/admin-console/*` presents a + non-dismissible banner naming the impersonated account while a session is active — + **PASS** (`apps/dashboard/src/components/layout/ImpersonationBanner.tsx`, rendered from + `routes/_app.tsx`); behavior tracked in `docs/product-dashboard.md`. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index da78f4017..b7d92a665 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 Key Auth | `01-auth/api-keys.md` | Dual-key system (pk\_/sk\_), validation, partner matching | | 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 c96b7a03f..3cb99b6e3 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-017) 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. | @@ -37,6 +37,7 @@ register and the owning module specification. | RISK-014 | Accepted | Medium | Pricing + Treasury | CoinGecko’s `usd-coin` price is used as a USD/fiat fallback or sanity reference, so a USDC depeg can distort the reference. | FastForex/Binance primary routes, sanity bands, short cache TTL, fail-closed when no valid provider remains, operational depeg monitoring. | Replace with an independent fiat FX reference before raising depeg-sensitive exposure. | | 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 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer profile at FULL depth — the operator acts with the target's complete rights, including money movement. v1 has no reduced-scope or read-only impersonation mode. | Per-operator Supabase identity plus `vortex_admin` role gate; 30-minute non-renewable TTL; one active session per (actor, target), a new one supersedes the old; hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks credential minting and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request. | Revisit before scoping impersonation depth down (e.g., a read-only investigate mode) or before v2 sub-account delegation reuses this mechanism; see `01-auth/admin-impersonation.md`. | ## Review cadence From 47f548f457958eb7284d0d53ed64170e2d37a184 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 6 Aug 2026 13:04:21 +0200 Subject: [PATCH 09/29] test(dashboard): cover impersonation store state transitions --- .../src/stores/impersonation.store.test.ts | 154 ++++++++++++++++++ apps/dashboard/src/types/bun-test.d.ts | 10 ++ 2 files changed, 164 insertions(+) create mode 100644 apps/dashboard/src/stores/impersonation.store.test.ts create mode 100644 apps/dashboard/src/types/bun-test.d.ts 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..ec281c324 --- /dev/null +++ b/apps/dashboard/src/stores/impersonation.store.test.ts @@ -0,0 +1,154 @@ +import { mock } from "bun:test"; +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import type { ImpersonationSession } from "@/services/auth"; + +const originalFetch = globalThis.fetch; +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); +const values = new Map(); + +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" } } +}); + +// auth.store transitively boots wagmi -> appkit -> lit-html -> sonner, none of which survive a +// stubbed DOM. Only `clearAccountState` matters here, and counting its calls is precisely the +// invariant under test: no cached data from one identity may survive into the other. +let accountStateClears = 0; +mock.module("@/stores/auth.store", () => ({ + clearAccountState: () => { + accountStateClears += 1; + } +})); + +// Imported only after the shims above: the store reads storage at module-evaluation time. +const { AuthService } = await import("@/services/auth"); +const { useImpersonationStore } = await import("./impersonation.store"); + +function session(overrides: Partial = {}): ImpersonationSession { + return { + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "session-1", + targetEmail: "target@example.com", + token: "vtx_imp_token-1", + ...overrides + }; +} + +beforeEach(() => { + values.clear(); + accountStateClears = 0; + useImpersonationStore.setState({ session: null }); + globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 204 }))) as typeof fetch; +}); + +after(() => { + globalThis.fetch = originalFetch; + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } + if (originalWindow) { + Object.defineProperty(globalThis, "window", originalWindow); + } else { + Reflect.deleteProperty(globalThis, "window"); + } +}); + +describe("useImpersonationStore", () => { + it("enter() persists the session so api-client picks it up on the next request", () => { + const entered = session(); + + useImpersonationStore.getState().enter(entered); + + assert.deepEqual(useImpersonationStore.getState().session, entered); + assert.deepEqual(AuthService.getImpersonationSession(), entered); + assert.equal(accountStateClears, 1); + }); + + it("exit() revokes the session server-side and clears it locally", async () => { + 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 Promise.resolve(new Response(null, { status: 204 })); + }) as typeof fetch; + + useImpersonationStore.getState().enter(session()); + await useImpersonationStore.getState().exit(); + + assert.equal(requestCount, 1); + assert.match(lastRequest, /^DELETE .*\/admin-console\/impersonation\/session-1$/); + assert.equal(useImpersonationStore.getState().session, null); + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 2); + }); + + it("exit() still drops the operator back into their own session when the revoke call fails", async () => { + globalThis.fetch = (() => Promise.reject(new Error("network down"))) as typeof fetch; + + useImpersonationStore.getState().enter(session()); + await useImpersonationStore.getState().exit(); + + // Best-effort by design: never strand the operator in someone else's session. The + // server-side row may outlive this call, bounded by the 30-minute TTL. + assert.equal(useImpersonationStore.getState().session, null); + assert.equal(AuthService.getImpersonationSession(), null); + }); + + it("exit() is a no-op against the server when there is no active session", async () => { + let called = false; + globalThis.fetch = (() => { + called = true; + return Promise.resolve(new Response(null, { status: 204 })); + }) as typeof fetch; + + await useImpersonationStore.getState().exit(); + + assert.equal(called, false); + assert.equal(useImpersonationStore.getState().session, null); + }); + + it("syncFromStorage() clears the store when api-client dropped the session on a 401", () => { + useImpersonationStore.getState().enter(session()); + AuthService.clearImpersonationSession(); + + useImpersonationStore.getState().syncFromStorage(); + + assert.equal(useImpersonationStore.getState().session, null); + assert.equal(accountStateClears, 2); + }); + + it("syncFromStorage() adopts a session started in another tab", () => { + const fromOtherTab = session({ sessionId: "session-2", token: "vtx_imp_token-2" }); + AuthService.storeImpersonationSession(fromOtherTab); + + useImpersonationStore.getState().syncFromStorage(); + + assert.deepEqual(useImpersonationStore.getState().session, fromOtherTab); + }); + + it("syncFromStorage() keeps the current session when storage still holds the same token", () => { + const current = session(); + useImpersonationStore.getState().enter(current); + const before = useImpersonationStore.getState().session; + + useImpersonationStore.getState().syncFromStorage(); + + assert.equal(useImpersonationStore.getState().session, before); + }); +}); 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; + }; +} From e9eeae1d956f52c242607d8eca1f2c50bcd0933f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 7 Aug 2026 10:41:47 +0200 Subject: [PATCH 10/29] fix(api): harden impersonation session lifecycle --- .../admin-console/accounts.controller.ts | 8 ++ .../admin-console/impersonation.controller.ts | 31 ++++++- .../admin/profileRoles.controller.test.ts | 30 ++++++ .../admin/profileRoles.controller.ts | 18 +++- .../admin-console/admin-console.route.test.ts | 49 ++++++++++ .../services/impersonation.service.test.ts | 93 ++++++++++++++++--- .../src/api/services/impersonation.service.ts | 87 ++++++++++++----- ...063-create-admin-impersonation-sessions.ts | 7 +- .../models/adminImpersonationSession.model.ts | 10 +- 9 files changed, 285 insertions(+), 48 deletions(-) diff --git a/apps/api/src/api/controllers/admin-console/accounts.controller.ts b/apps/api/src/api/controllers/admin-console/accounts.controller.ts index e9587eb6c..8d5f09b3d 100644 --- a/apps/api/src/api/controllers/admin-console/accounts.controller.ts +++ b/apps/api/src/api/controllers/admin-console/accounts.controller.ts @@ -12,6 +12,7 @@ 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; @@ -117,6 +118,13 @@ export async function listAccounts(req: Request, res: Response): Promise { 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({ diff --git a/apps/api/src/api/controllers/admin-console/impersonation.controller.ts b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts index 162ad6236..6d9aa3ab0 100644 --- a/apps/api/src/api/controllers/admin-console/impersonation.controller.ts +++ b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts @@ -9,6 +9,7 @@ import { buildApiClientRequestMetadata, observeApiClientEvent } from "../../obse import { getRequestDurationMs } from "../../observability/requestContext"; import { createSession, + ImpersonationActorError, ImpersonationDisabledError, ImpersonationTargetError, isSessionActive, @@ -16,6 +17,8 @@ import { 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: @@ -26,9 +29,13 @@ export async function createImpersonationSession(req: Request, res: Response): P const actorProfileId = req.userId as string; const { targetProfileId } = req.body ?? {}; - if (typeof targetProfileId !== "string" || !targetProfileId) { + if (typeof targetProfileId !== "string" || !UUID_PATTERN.test(targetProfileId)) { res.status(httpStatus.BAD_REQUEST).json({ - error: { code: "INVALID_IMPERSONATION_INPUT", message: "targetProfileId is required", status: httpStatus.BAD_REQUEST } + error: { + code: "INVALID_IMPERSONATION_INPUT", + message: "targetProfileId must be a valid UUID", + status: httpStatus.BAD_REQUEST + } }); return; } @@ -56,6 +63,10 @@ export async function createImpersonationSession(req: Request, res: Response): P token }); } catch (error) { + if (error instanceof ImpersonationActorError) { + vortexAdminRequiredResponse(res); + return; + } if (error instanceof ImpersonationDisabledError) { observeApiClientEvent({ durationMs: getRequestDurationMs(req), @@ -96,8 +107,9 @@ export async function createImpersonationSession(req: Request, res: Response): P */ export async function listImpersonationSessions(req: Request, res: Response): Promise { try { - const limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined; - const sessions = await listSessions({ limit: Number.isFinite(limit) ? limit : undefined }); + 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 => { @@ -139,6 +151,17 @@ export async function listImpersonationSessions(req: Request, res: Response): Pr 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) { 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 10b3aec10..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" }; @@ -92,6 +95,33 @@ describe("profile roles admin routes", () => { 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 b59831fe0..9fbc82447 100644 --- a/apps/api/src/api/controllers/admin/profileRoles.controller.ts +++ b/apps/api/src/api/controllers/admin/profileRoles.controller.ts @@ -1,6 +1,8 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; +import sequelize from "../../../config/database"; import logger from "../../../config/logger"; +import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; import ProfileRole, { HTTP_GRANTABLE_PROFILE_ROLES, PROFILE_ROLE_NAMES, @@ -100,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/routes/v1/admin-console/admin-console.route.test.ts b/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts index 5e1c49863..c5a77201a 100644 --- 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 @@ -98,6 +98,16 @@ describe("admin-console routes", () => { 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", () => { @@ -141,6 +151,35 @@ describe("admin-console routes", () => { 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", () => { @@ -208,5 +247,15 @@ describe("admin-console routes", () => { 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/services/impersonation.service.test.ts b/apps/api/src/api/services/impersonation.service.test.ts index ee1d63edb..f7cce1fc3 100644 --- a/apps/api/src/api/services/impersonation.service.test.ts +++ b/apps/api/src/api/services/impersonation.service.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn 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 { @@ -34,8 +35,14 @@ describe("impersonation.service", () => { config.impersonationEnabled = originalImpersonationEnabled; }); - it("persists only the SHA-256 hash of the token, never the raw value", async () => { + 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 }); @@ -49,7 +56,7 @@ describe("impersonation.service", () => { }); it("resolves a live token to the target's principal context", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); const target = await createTestUser({ email: "target@example.com" }); const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); @@ -65,7 +72,7 @@ describe("impersonation.service", () => { }); it("returns null for an expired session", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); const target = await createTestUser(); const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); @@ -75,7 +82,7 @@ describe("impersonation.service", () => { }); it("returns null for a revoked session", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); const target = await createTestUser(); const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); @@ -88,6 +95,16 @@ describe("impersonation.service", () => { 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"); @@ -96,7 +113,7 @@ describe("impersonation.service", () => { }); it("revokes the prior session with 'superseded' when a second session starts for the same actor and target", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); const target = await createTestUser(); const first = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); @@ -110,8 +127,35 @@ describe("impersonation.service", () => { expect(reloadedSecond?.revokedAt).toBeNull(); }); - it("rejects an actor impersonating themselves", async () => { + 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 @@ -119,7 +163,7 @@ describe("impersonation.service", () => { }); it("rejects a non-existent target", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); await expect( createSession({ actorProfileId: actor.id, targetProfileId: crypto.randomUUID() }) @@ -127,7 +171,7 @@ describe("impersonation.service", () => { }); it("kill switch: disables new sessions and revokes resolution of already-live tokens", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); const target = await createTestUser(); const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); @@ -142,7 +186,7 @@ describe("impersonation.service", () => { }); it("writes last_used_at on first use and does not rewrite it within the throttle window", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); const target = await createTestUser(); const { token, session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); @@ -158,7 +202,7 @@ describe("impersonation.service", () => { }); it("does not overwrite the original revoked_at when revoking an already-revoked session", async () => { - const actor = await createTestUser(); + const actor = await createAdmin(); const target = await createTestUser(); const { session } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); @@ -172,14 +216,35 @@ describe("impersonation.service", () => { 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 createTestUser(); + 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 createTestUser(); + 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"); @@ -191,11 +256,11 @@ describe("impersonation.service", () => { }); it("lists expired sessions after live ones", async () => { - const liveActor = await createTestUser(); + const liveActor = await createAdmin(); const liveTarget = await createTestUser(); const { session: live } = await createSession({ actorProfileId: liveActor.id, targetProfileId: liveTarget.id }); - const expiredActor = await createTestUser(); + const expiredActor = await createAdmin(); const expiredTarget = await createTestUser(); const { session: expired } = await createSession({ actorProfileId: expiredActor.id, diff --git a/apps/api/src/api/services/impersonation.service.ts b/apps/api/src/api/services/impersonation.service.ts index 7f4c1b820..66a5bd4aa 100644 --- a/apps/api/src/api/services/impersonation.service.ts +++ b/apps/api/src/api/services/impersonation.service.ts @@ -1,7 +1,9 @@ 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. */ @@ -38,6 +40,12 @@ export class ImpersonationTargetError extends Error { } } +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); } @@ -62,30 +70,55 @@ export async function createSession(input: { throw new ImpersonationTargetError("An admin cannot impersonate themselves"); } - const target = await User.findByPk(input.targetProfileId); - if (!target) { - throw new ImpersonationTargetError("Target profile was not found"); - } + 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"); + } - // 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" }, - { - where: { - actorProfileId: input.actorProfileId, - revokedAt: null, - targetProfileId: input.targetProfileId - } + const [target, actorRole] = await Promise.all([ + User.findByPk(input.targetProfileId, { transaction }), + ProfileRole.findOne({ transaction, where: { role: "vortex_admin", userId: input.actorProfileId } }) + ]); + if (!target) { + throw new ImpersonationTargetError("Target profile was not found"); + } + if (!actorRole) { + throw new ImpersonationActorError(); } - ); - const token = `${IMPERSONATION_TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`; - const session = await AdminImpersonationSession.create({ - actorProfileId: input.actorProfileId, - expiresAt: new Date(Date.now() + IMPERSONATION_TTL_MS), - targetProfileId: input.targetProfileId, - tokenHash: hashToken(token) + // One active session per (actor, target): starting a new one closes the old one, so a + // forgotten tab can never hold rights alongside a fresh session. + await AdminImpersonationSession.update( + { revokedAt: new Date(), revokedReason: "superseded" }, + { + transaction, + where: { + actorProfileId: input.actorProfileId, + revokedAt: null, + targetProfileId: input.targetProfileId + } + } + ); + + const session = await AdminImpersonationSession.create( + { + actorProfileId: input.actorProfileId, + expiresAt: new Date(Date.now() + IMPERSONATION_TTL_MS), + targetProfileId: input.targetProfileId, + tokenHash: hashToken(token) + }, + { transaction } + ); + + return { session, target }; }); return { session, target, token }; @@ -105,8 +138,11 @@ export async function resolveSession(token: string): 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: Math.min(input.limit ?? 50, 200), + limit, order: [ // Mirrors isSessionActive() in SQL so live sessions sort above closed ones. [ diff --git a/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts b/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts index 01e3ed73d..3323c5364 100644 --- a/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts +++ b/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts @@ -40,7 +40,8 @@ export async function up(queryInterface: QueryInterface): Promise { }, target_profile_id: { allowNull: false, - onDelete: "CASCADE", + // 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 @@ -66,9 +67,9 @@ export async function up(queryInterface: QueryInterface): Promise { await queryInterface.addIndex("admin_impersonation_sessions", ["actor_profile_id", "created_at"], { name: "idx_admin_impersonation_sessions_actor_created" }); - // Supports "does this actor already hold a live session on this target?" without a scan. + // Enforces one non-revoked session per actor/target even if application locking regresses. await queryInterface.sequelize.query( - `CREATE INDEX "idx_admin_impersonation_sessions_active" + `CREATE UNIQUE INDEX "uq_admin_impersonation_sessions_active" ON "admin_impersonation_sessions" ("actor_profile_id", "target_profile_id") WHERE "revoked_at" IS NULL;` ); diff --git a/apps/api/src/models/adminImpersonationSession.model.ts b/apps/api/src/models/adminImpersonationSession.model.ts index 8d32043c2..97787e45e 100644 --- a/apps/api/src/models/adminImpersonationSession.model.ts +++ b/apps/api/src/models/adminImpersonationSession.model.ts @@ -56,7 +56,7 @@ AdminImpersonationSession.init( targetProfileId: { allowNull: false, field: "target_profile_id", - onDelete: "CASCADE", + onDelete: "RESTRICT", onUpdate: "CASCADE", references: { key: "id", model: "profiles" }, type: DataTypes.UUID @@ -68,7 +68,13 @@ AdminImpersonationSession.init( 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", "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, From 0556d65f5b6ce570f9201859c485439634fe8942 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 7 Aug 2026 10:44:56 +0200 Subject: [PATCH 11/29] fix(api): preserve impersonation audit attribution --- .../api/controllers/quote.controller.test.ts | 33 +++++++++++++++++++ .../src/api/controllers/quote.controller.ts | 6 +++- .../api/controllers/ramp.controller.test.ts | 32 +++++++++++++++++- .../src/api/controllers/ramp.controller.ts | 4 ++- .../api/middlewares/maintenanceGuard.test.ts | 9 +++-- .../src/api/middlewares/maintenanceGuard.ts | 3 +- 6 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/api/controllers/quote.controller.test.ts diff --git a/apps/api/src/api/controllers/quote.controller.test.ts b/apps/api/src/api/controllers/quote.controller.test.ts new file mode 100644 index 000000000..a1b9965ea --- /dev/null +++ b/apps/api/src/api/controllers/quote.controller.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { buildQuoteRequestMetadata } from "./quote.controller"; + +describe("buildQuoteRequestMetadata", () => { + it("attributes successful quote events to the impersonation session", () => { + const metadata = buildQuoteRequestMetadata( + { + body: { inputAmount: "100", inputCurrency: "USDC", outputCurrency: "BRL", rampType: "SELL" }, + impersonation: { + actorProfileId: "actor-1", + expiresAt: new Date("2026-08-07T12:00:00.000Z"), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-1" + }, + method: "POST", + path: "/v1/quotes" + }, + "quote_create" + ); + + expect(metadata).toEqual({ + impersonationSessionId: "session-1", + impersonatorProfileId: "actor-1", + requestBodyInputAmount: "100", + requestBodyInputCurrency: "USDC", + requestBodyOutputCurrency: "BRL", + requestBodyRampType: "SELL", + requestMethod: "POST", + requestPath: "/v1/quotes" + }); + }); +}); diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index 6206af397..b96c78eea 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -65,6 +65,7 @@ export const createQuote = async ( apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]), durationMs: getRequestDurationMs(req), httpStatus: httpStatus.CREATED, + metadata: buildQuoteRequestMetadata(req, "quote_create"), network, operation: "quote_create", partnerId: req.credential?.partnerId || null, @@ -128,6 +129,7 @@ export const createBestQuote = async ( apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]), durationMs: getRequestDurationMs(req), httpStatus: httpStatus.CREATED, + metadata: buildQuoteRequestMetadata(req, "quote_create_best"), network: quote.network, operation: "quote_create_best", partnerId: req.credential?.partnerId || null, @@ -177,6 +179,7 @@ export const getQuote = async ( observeApiClientEvent({ durationMs: getRequestDurationMs(req), httpStatus: httpStatus.OK, + metadata: buildQuoteRequestMetadata(req, "quote_get"), network: quote.network, operation: "quote_get", paymentMethod: quote.paymentMethod, @@ -205,6 +208,7 @@ interface ObservedQuoteRequest { query?: unknown; requestId?: string; requestStartedAt?: number; + impersonation?: Request["impersonation"]; userId?: string; } @@ -237,7 +241,7 @@ function observeQuoteFailure( }); } -function buildQuoteRequestMetadata(req: ObservedQuoteRequest, operation: QuoteOperation): Record { +export function buildQuoteRequestMetadata(req: ObservedQuoteRequest, operation: QuoteOperation): Record { if (operation === "quote_get") { return buildApiClientRequestMetadata(req, { paramKeys: ["id"] }); } diff --git a/apps/api/src/api/controllers/ramp.controller.test.ts b/apps/api/src/api/controllers/ramp.controller.test.ts index f78d4cfa9..2de2f3829 100644 --- a/apps/api/src/api/controllers/ramp.controller.test.ts +++ b/apps/api/src/api/controllers/ramp.controller.test.ts @@ -3,7 +3,37 @@ import { describe, expect, it } from "bun:test"; import httpStatus from "http-status"; import { APIError } from "../errors/api-error"; import { classifyApiClientError } from "../observability/errorClassifier"; -import { formatProviderContext, mapProviderFailure } from "./ramp.controller"; +import { buildRampRequestMetadata, formatProviderContext, mapProviderFailure } from "./ramp.controller"; + +describe("buildRampRequestMetadata", () => { + it("attributes successful money-movement events to the impersonation session", () => { + const metadata = buildRampRequestMetadata( + { + body: { additionalData: { taxId: "sensitive" }, quoteId: "quote-1", signingAccounts: ["account-1"] }, + impersonation: { + actorProfileId: "actor-1", + expiresAt: new Date("2026-08-07T12:00:00.000Z"), + sessionId: "session-1", + targetEmail: "target@example.com", + targetProfileId: "target-1" + }, + method: "POST", + path: "/v1/ramp/register" + }, + "ramp_register" + ); + + expect(metadata).toEqual({ + hasRequestBodyAdditionalData: true, + impersonationSessionId: "session-1", + impersonatorProfileId: "actor-1", + requestBodyQuoteId: "quote-1", + requestBodySigningAccountsCount: 1, + requestMethod: "POST", + requestPath: "/v1/ramp/register" + }); + }); +}); describe("mapProviderFailure", () => { it("maps a 4xx Avenia rejection (e.g. blocked user) to a 422 with a sanitized public message", () => { diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index c8d2ffea4..69d118b66 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -410,6 +410,7 @@ interface ObservedRampRequest { requestId?: string; requestStartedAt?: number; credential?: Request["credential"]; + impersonation?: Request["impersonation"]; userId?: string; } @@ -423,6 +424,7 @@ function observeRampSuccess( ...context, durationMs: getRequestDurationMs(req), httpStatus: status, + metadata: buildRampRequestMetadata(req, operation), operation, partnerId: req.credential?.partnerId || null, partnerName: req.authenticatedPartner?.name || null, @@ -455,7 +457,7 @@ function observeRampFailure( }); } -function buildRampRequestMetadata(req: ObservedRampRequest, operation: RampObservedOperation): Record { +export function buildRampRequestMetadata(req: ObservedRampRequest, operation: RampObservedOperation): Record { if (operation === "ramp_register") { return buildApiClientRequestMetadata(req, { bodyKeys: ["quoteId", "signingAccounts", "additionalData"] }); } diff --git a/apps/api/src/api/middlewares/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 From 2ba5490ff25c4f2dd005d36d47c8e8fb58ff8ca2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 7 Aug 2026 10:50:40 +0200 Subject: [PATCH 12/29] fix(dashboard): make impersonation identity atomic --- .../components/admin/ImpersonateDialog.tsx | 3 +- .../src/components/layout/AppSidebar.tsx | 4 +- .../components/layout/ImpersonationBanner.tsx | 45 +++---- .../src/services/api/api-client.test.ts | 22 ++++ apps/dashboard/src/services/api/api-client.ts | 5 +- apps/dashboard/src/services/auth.test.ts | 81 ++++++++++++ apps/dashboard/src/services/auth.ts | 124 +++++++++++++++--- apps/dashboard/src/stores/auth.store.ts | 1 + .../src/stores/impersonation.store.test.ts | 106 +++++++++------ .../src/stores/impersonation.store.ts | 91 +++++++------ 10 files changed, 347 insertions(+), 135 deletions(-) diff --git a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx index 93ddce7e3..805c12f7c 100644 --- a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -4,7 +4,7 @@ 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 { useImpersonationStore } from "@/stores/impersonation.store"; +import { enterImpersonation } from "@/stores/impersonation.store"; /** * "Log in as" confirmation: swaps the active session to the returned impersonation token @@ -18,7 +18,6 @@ export function ImpersonateDialog({ target: { id: string; email: string } | null; }) { const navigate = useNavigate(); - const enterImpersonation = useImpersonationStore(state => state.enter); const startImpersonation = useStartImpersonation(); function handleOpenChange(open: boolean) { diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index f41d7a4bb..6efb96fe1 100644 --- a/apps/dashboard/src/components/layout/AppSidebar.tsx +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -12,7 +12,7 @@ import { SidebarRail } from "@/components/ui/sidebar"; import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; -import { useImpersonationStore } from "@/stores/impersonation.store"; +import { useImpersonationSession } from "@/stores/impersonation.store"; import { VortexLogo } from "./VortexLogo"; const NAV_ITEMS = [ @@ -31,7 +31,7 @@ const ADMIN_NAV_ITEM = { icon: UserCog, label: "Admin", to: "/admin" } as const; export function AppSidebar() { const pathname = useRouterState({ select: state => state.location.pathname }); const { data: onboardingStatus } = useOnboardingStatusQuery(); - const isImpersonating = useImpersonationStore(state => state.session !== null); + const isImpersonating = useImpersonationSession() !== null; const isAdmin = onboardingStatus?.roles.includes("vortex_admin") ?? false; // An operator acting as a customer must see exactly the customer's navigation. const navItems = isAdmin && !isImpersonating ? [...NAV_ITEMS, ADMIN_NAV_ITEM] : NAV_ITEMS; diff --git a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx index b0f84f0d2..606c1a79a 100644 --- a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx +++ b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { useImpersonationStore } from "@/stores/impersonation.store"; +import { exitImpersonation, useImpersonationSession } from "@/stores/impersonation.store"; function formatRemaining(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); @@ -12,53 +12,42 @@ function formatRemaining(ms: number): string { /** * Sticky, non-dismissible: an operator forgetting they are impersonating is the failure - * mode this guards against. Ticks every second both to show time remaining and to notice - * a session api-client already cleared (expired token on a 401) so the banner drops itself. + * mode this guards against. The timer only renders the remaining duration; storage changes + * are subscribed through `useImpersonationSession`. */ export function ImpersonationBanner() { - const session = useImpersonationStore(state => state.session); - const exit = useImpersonationStore(state => state.exit); - const syncFromStorage = useImpersonationStore(state => state.syncFromStorage); + const session = useImpersonationSession(); const navigate = useNavigate(); - const [remainingMs, setRemainingMs] = useState(null); - const [exiting, setExiting] = useState(false); + const expiresAt = session?.expiresAt; + const [now, setNow] = useState(Date.now); useEffect(() => { - if (!session) { - setRemainingMs(null); - return; - } - const tick = () => { - syncFromStorage(); - setRemainingMs(new Date(session.expiresAt).getTime() - Date.now()); - }; + if (!expiresAt) return; + const tick = () => setNow(Date.now()); tick(); const interval = setInterval(tick, 1000); return () => clearInterval(interval); - }, [session, syncFromStorage]); + }, [expiresAt]); if (!session) { return null; } - async function handleExit() { - setExiting(true); - try { - await exit(); - navigate({ to: "/admin" }); - } finally { - setExiting(false); - } + function handleExit() { + exitImpersonation(); + navigate({ to: "/admin" }); } + const remainingMs = new Date(session.expiresAt).getTime() - now; + return (
You are acting as {session.targetEmail} - {remainingMs !== null && <> · {formatRemaining(remainingMs)} remaining} + <> · {formatRemaining(remainingMs)} remaining -
); diff --git a/apps/dashboard/src/services/api/api-client.test.ts b/apps/dashboard/src/services/api/api-client.test.ts index 907eac7db..820749eaf 100644 --- a/apps/dashboard/src/services/api/api-client.test.ts +++ b/apps/dashboard/src/services/api/api-client.test.ts @@ -4,6 +4,7 @@ import { AuthService } from "@/services/auth"; import { apiClient, isApiError } from "./api-client"; const originalFetch = globalThis.fetch; +const originalGetImpersonationSession = AuthService.getImpersonationSession; const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const values = new Map(); @@ -25,10 +26,12 @@ Object.defineProperty(globalThis, "window", { beforeEach(() => { values.clear(); + AuthService.getImpersonationSession = originalGetImpersonationSession; }); after(() => { globalThis.fetch = originalFetch; + AuthService.getImpersonationSession = originalGetImpersonationSession; if (originalLocalStorage) { Object.defineProperty(globalThis, "localStorage", originalLocalStorage); } else { @@ -69,6 +72,25 @@ describe("apiFetch while impersonating", () => { assert.equal(authorization, "Bearer vtx_imp_abc123"); }); + it("uses one impersonation snapshot for authorization and 401 handling", async () => { + const activeSession = AuthService.getImpersonationSession(); + let snapshotReads = 0; + AuthService.getImpersonationSession = (() => { + snapshotReads += 1; + return snapshotReads === 1 ? activeSession : null; + }) as typeof AuthService.getImpersonationSession; + let authorization: string | undefined; + globalThis.fetch = (async (_input, init) => { + authorization = (init?.headers as Record).Authorization; + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, status: 200 }); + }) as typeof fetch; + + await apiClient.get("/ping"); + + assert.equal(snapshotReads, 1); + assert.equal(authorization, "Bearer vtx_imp_abc123"); + }); + it("does not attempt a token refresh on 401 and clears the impersonation session instead", async () => { let fetchCalls = 0; globalThis.fetch = (async (input) => { diff --git a/apps/dashboard/src/services/api/api-client.ts b/apps/dashboard/src/services/api/api-client.ts index c6e1581ca..ed550ffcf 100644 --- a/apps/dashboard/src/services/api/api-client.ts +++ b/apps/dashboard/src/services/api/api-client.ts @@ -61,7 +61,10 @@ async function apiFetch( const impersonation = AuthService.getImpersonationSession(); const initialTokens = AuthService.getTokens(); - let response = await doFetch(AuthService.getEffectiveAccessToken() ?? undefined); + // Capture one coherent identity snapshot. Reading impersonation again here could pair the + // operator's token with impersonation-specific 401 handling during a cross-tab transition. + const initialAccessToken = impersonation?.token ?? initialTokens?.accessToken; + let response = await doFetch(initialAccessToken); if (response.status === 401) { if (impersonation) { diff --git a/apps/dashboard/src/services/auth.test.ts b/apps/dashboard/src/services/auth.test.ts index c4864806e..5f3433bee 100644 --- a/apps/dashboard/src/services/auth.test.ts +++ b/apps/dashboard/src/services/auth.test.ts @@ -231,6 +231,73 @@ describe("AuthService", () => { }); describe("AuthService impersonation session", () => { + it("stores the complete session in one atomic dashboard key", () => { + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + const impersonationKeys = [...values.keys()].filter((key) => + key.includes("impersonation"), + ); + assert.deepEqual(impersonationKeys, [ + AuthService.IMPERSONATION_STORAGE_KEY, + ]); + }); + + it("rejects malformed or incomplete atomic session records", () => { + values.set(AuthService.IMPERSONATION_STORAGE_KEY, "not-json"); + assert.equal(AuthService.getImpersonationSession(), null); + + values.set( + AuthService.IMPERSONATION_STORAGE_KEY, + JSON.stringify({ sessionId: "session-1", token: "vtx_imp_abc123" }), + ); + assert.equal(AuthService.getImpersonationSession(), null); + }); + + it("reads a complete legacy session and removes legacy keys on the next write", () => { + values.set("vortex_dashboard_impersonation_token", "vtx_imp_legacy"); + values.set("vortex_dashboard_impersonation_session_id", "legacy-session"); + values.set( + "vortex_dashboard_impersonation_expires_at", + "2026-01-01T00:00:00.000Z", + ); + values.set( + "vortex_dashboard_impersonation_target_email", + "legacy@example.com", + ); + + assert.deepEqual(AuthService.getImpersonationSession(), { + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "legacy-session", + targetEmail: "legacy@example.com", + token: "vtx_imp_legacy", + }); + + AuthService.storeImpersonationSession({ + expiresAt: "2026-02-01T00:00:00.000Z", + sessionId: "session-2", + targetEmail: "current@example.com", + token: "vtx_imp_current", + }); + assert.equal(values.has("vortex_dashboard_impersonation_token"), false); + assert.equal( + values.has("vortex_dashboard_impersonation_session_id"), + false, + ); + assert.equal( + values.has("vortex_dashboard_impersonation_expires_at"), + false, + ); + assert.equal( + values.has("vortex_dashboard_impersonation_target_email"), + false, + ); + }); + it("prefers the impersonation token over the operator's own access token", () => { assert.equal(AuthService.getEffectiveAccessToken(), "expired-access-token"); @@ -270,4 +337,18 @@ describe("AuthService impersonation session", () => { userId: "user-1", }); }); + + it("clears both operator and impersonation credentials on sign-out", () => { + AuthService.storeImpersonationSession({ + expiresAt: "2026-01-01T00:00:00.000Z", + sessionId: "session-1", + targetEmail: "customer@example.com", + token: "vtx_imp_abc123", + }); + + AuthService.signOut(); + + assert.equal(AuthService.getTokens(), null); + assert.equal(AuthService.getImpersonationSession(), null); + }); }); diff --git a/apps/dashboard/src/services/auth.ts b/apps/dashboard/src/services/auth.ts index b616ff298..cb9fda4e6 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -24,12 +24,13 @@ export class AuthService { private static readonly REFRESH_TOKEN_KEY = "vortex_dashboard_refresh_token"; private static readonly USER_ID_KEY = "vortex_dashboard_user_id"; private static readonly USER_EMAIL_KEY = "vortex_dashboard_user_email"; - // Separate keys so an active impersonation session never touches the operator's own - // Supabase tokens above — Exit just drops these and the operator's session is already there. - private static readonly IMPERSONATION_TOKEN_KEY = "vortex_dashboard_impersonation_token"; - private static readonly IMPERSONATION_SESSION_ID_KEY = "vortex_dashboard_impersonation_session_id"; - private static readonly IMPERSONATION_EXPIRES_AT_KEY = "vortex_dashboard_impersonation_expires_at"; - private static readonly IMPERSONATION_TARGET_EMAIL_KEY = "vortex_dashboard_impersonation_target_email"; + // One atomic record prevents readers from combining fields from different cross-tab writes. + static readonly IMPERSONATION_STORAGE_KEY = "vortex_dashboard_impersonation_session"; + private static readonly LEGACY_IMPERSONATION_TOKEN_KEY = "vortex_dashboard_impersonation_token"; + private static readonly LEGACY_IMPERSONATION_SESSION_ID_KEY = "vortex_dashboard_impersonation_session_id"; + private static readonly LEGACY_IMPERSONATION_EXPIRES_AT_KEY = "vortex_dashboard_impersonation_expires_at"; + private static readonly LEGACY_IMPERSONATION_TARGET_EMAIL_KEY = "vortex_dashboard_impersonation_target_email"; + private static readonly impersonationListeners = new Set<() => void>(); private static sessionGeneration = 0; private static refreshFlight: { generation: number; @@ -68,28 +69,86 @@ export class AuthService { } static storeImpersonationSession(session: ImpersonationSession): void { - localStorage.setItem(this.IMPERSONATION_TOKEN_KEY, session.token); - localStorage.setItem(this.IMPERSONATION_SESSION_ID_KEY, session.sessionId); - localStorage.setItem(this.IMPERSONATION_EXPIRES_AT_KEY, session.expiresAt); - localStorage.setItem(this.IMPERSONATION_TARGET_EMAIL_KEY, session.targetEmail); + const previousSnapshot = this.getImpersonationSessionSnapshot(); + localStorage.setItem( + this.IMPERSONATION_STORAGE_KEY, + JSON.stringify({ + expiresAt: session.expiresAt, + sessionId: session.sessionId, + targetEmail: session.targetEmail, + token: session.token + }) + ); + this.clearLegacyImpersonationKeys(); + this.notifyImpersonationListeners(previousSnapshot); } static getImpersonationSession(): ImpersonationSession | null { - const token = localStorage.getItem(this.IMPERSONATION_TOKEN_KEY); - const sessionId = localStorage.getItem(this.IMPERSONATION_SESSION_ID_KEY); - const expiresAt = localStorage.getItem(this.IMPERSONATION_EXPIRES_AT_KEY); - const targetEmail = localStorage.getItem(this.IMPERSONATION_TARGET_EMAIL_KEY); - if (!token || !sessionId || !expiresAt || !targetEmail) { + return this.parseImpersonationSessionSnapshot(this.getImpersonationSessionSnapshot()); + } + + /** Stable serialized snapshot for `useSyncExternalStore`. Also reads complete legacy data. */ + static getImpersonationSessionSnapshot(): string | null { + const current = localStorage.getItem(this.IMPERSONATION_STORAGE_KEY); + if (current !== null) { + return current; + } + + const token = localStorage.getItem(this.LEGACY_IMPERSONATION_TOKEN_KEY); + const sessionId = localStorage.getItem(this.LEGACY_IMPERSONATION_SESSION_ID_KEY); + const expiresAt = localStorage.getItem(this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY); + const targetEmail = localStorage.getItem(this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY); + return token && sessionId && expiresAt && targetEmail ? JSON.stringify({ expiresAt, sessionId, targetEmail, token }) : null; + } + + static parseImpersonationSessionSnapshot(snapshot: string | null): ImpersonationSession | null { + if (!snapshot) return null; + try { + const parsed = JSON.parse(snapshot) as Partial; + if ( + typeof parsed.token !== "string" || + typeof parsed.sessionId !== "string" || + typeof parsed.expiresAt !== "string" || + !Number.isFinite(Date.parse(parsed.expiresAt)) || + typeof parsed.targetEmail !== "string" + ) { + return null; + } + return { + expiresAt: parsed.expiresAt, + sessionId: parsed.sessionId, + targetEmail: parsed.targetEmail, + token: parsed.token + }; + } catch { return null; } - return { expiresAt, sessionId, targetEmail, token }; + } + + /** Same-tab writes notify directly; cross-tab writes arrive through the storage event. */ + static subscribeImpersonationSession(listener: () => void): () => void { + this.impersonationListeners.add(listener); + const handleStorage = (event: StorageEvent) => { + if (event.key === null || this.isImpersonationStorageKey(event.key)) { + listener(); + } + }; + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("storage", handleStorage); + } + return () => { + this.impersonationListeners.delete(listener); + if (typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("storage", handleStorage); + } + }; } static clearImpersonationSession(): void { - localStorage.removeItem(this.IMPERSONATION_TOKEN_KEY); - localStorage.removeItem(this.IMPERSONATION_SESSION_ID_KEY); - localStorage.removeItem(this.IMPERSONATION_EXPIRES_AT_KEY); - localStorage.removeItem(this.IMPERSONATION_TARGET_EMAIL_KEY); + const previousSnapshot = this.getImpersonationSessionSnapshot(); + localStorage.removeItem(this.IMPERSONATION_STORAGE_KEY); + this.clearLegacyImpersonationKeys(); + this.notifyImpersonationListeners(previousSnapshot); } /** The bearer token requests should use: the impersonation token takes priority when active. */ @@ -197,6 +256,31 @@ export class AuthService { } static signOut(): void { + this.clearImpersonationSession(); this.clearTokens(); } + + private static clearLegacyImpersonationKeys(): void { + localStorage.removeItem(this.LEGACY_IMPERSONATION_TOKEN_KEY); + localStorage.removeItem(this.LEGACY_IMPERSONATION_SESSION_ID_KEY); + localStorage.removeItem(this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY); + localStorage.removeItem(this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY); + } + + private static isImpersonationStorageKey(key: string): boolean { + return [ + this.IMPERSONATION_STORAGE_KEY, + this.LEGACY_IMPERSONATION_TOKEN_KEY, + this.LEGACY_IMPERSONATION_SESSION_ID_KEY, + this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY, + this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY + ].includes(key); + } + + private static notifyImpersonationListeners(previousSnapshot: string | null): void { + if (this.getImpersonationSessionSnapshot() === previousSnapshot) return; + for (const listener of this.impersonationListeners) { + listener(); + } + } } diff --git a/apps/dashboard/src/stores/auth.store.ts b/apps/dashboard/src/stores/auth.store.ts index 10260c924..04d860759 100644 --- a/apps/dashboard/src/stores/auth.store.ts +++ b/apps/dashboard/src/stores/auth.store.ts @@ -74,6 +74,7 @@ export const useAuthStore = create()(set => ({ verifyOtp: async (email, code) => { const result = await AuthAPI.verifyOTP(email, code); clearAccountState(); + AuthService.clearImpersonationSession(); AuthService.storeTokens({ accessToken: result.accessToken, refreshToken: result.refreshToken, diff --git a/apps/dashboard/src/stores/impersonation.store.test.ts b/apps/dashboard/src/stores/impersonation.store.test.ts index ec281c324..4a727f326 100644 --- a/apps/dashboard/src/stores/impersonation.store.test.ts +++ b/apps/dashboard/src/stores/impersonation.store.test.ts @@ -7,6 +7,7 @@ const originalFetch = globalThis.fetch; const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const values = new Map(); +const storageListeners = new Set<(event: { key: string | null }) => void>(); Object.defineProperty(globalThis, "localStorage", { configurable: true, @@ -17,15 +18,19 @@ Object.defineProperty(globalThis, "localStorage", { } }); -// 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" } } + value: { + addEventListener: (type: string, listener: (event: { key: string | null }) => void) => { + if (type === "storage") storageListeners.add(listener); + }, + location: { origin: "http://localhost" }, + removeEventListener: (type: string, listener: (event: { key: string | null }) => void) => { + if (type === "storage") storageListeners.delete(listener); + } + } }); -// auth.store transitively boots wagmi -> appkit -> lit-html -> sonner, none of which survive a -// stubbed DOM. Only `clearAccountState` matters here, and counting its calls is precisely the -// invariant under test: no cached data from one identity may survive into the other. let accountStateClears = 0; mock.module("@/stores/auth.store", () => ({ clearAccountState: () => { @@ -33,9 +38,8 @@ mock.module("@/stores/auth.store", () => ({ } })); -// Imported only after the shims above: the store reads storage at module-evaluation time. const { AuthService } = await import("@/services/auth"); -const { useImpersonationStore } = await import("./impersonation.store"); +const { enterImpersonation, exitImpersonation } = await import("./impersonation.store"); function session(overrides: Partial = {}): ImpersonationSession { return { @@ -47,10 +51,14 @@ function session(overrides: Partial = {}): ImpersonationSe }; } +function dispatchStorage(key: string | null): void { + for (const listener of storageListeners) listener({ key }); +} + beforeEach(() => { + AuthService.clearImpersonationSession(); values.clear(); accountStateClears = 0; - useImpersonationStore.setState({ session: null }); globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 204 }))) as typeof fetch; }); @@ -68,87 +76,103 @@ after(() => { } }); -describe("useImpersonationStore", () => { - it("enter() persists the session so api-client picks it up on the next request", () => { +describe("impersonation session transitions", () => { + it("persists an entered identity and clears account-scoped state", () => { const entered = session(); - useImpersonationStore.getState().enter(entered); + enterImpersonation(entered); - assert.deepEqual(useImpersonationStore.getState().session, entered); assert.deepEqual(AuthService.getImpersonationSession(), entered); assert.equal(accountStateClears, 1); }); - it("exit() revokes the session server-side and clears it locally", async () => { + 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 Promise.resolve(new Response(null, { status: 204 })); + return new Promise(resolve => { + releaseRequest = () => resolve(new Response(null, { status: 204 })); + }); }) as typeof fetch; - useImpersonationStore.getState().enter(session()); - await useImpersonationStore.getState().exit(); + enterImpersonation(session()); + exitImpersonation(); assert.equal(requestCount, 1); assert.match(lastRequest, /^DELETE .*\/admin-console\/impersonation\/session-1$/); - assert.equal(useImpersonationStore.getState().session, null); assert.equal(AuthService.getImpersonationSession(), null); assert.equal(accountStateClears, 2); + + releaseRequest?.(); + await Promise.resolve(); }); - it("exit() still drops the operator back into their own session when the revoke call fails", async () => { + it("still exits locally when the revocation request fails", async () => { globalThis.fetch = (() => Promise.reject(new Error("network down"))) as typeof fetch; - useImpersonationStore.getState().enter(session()); - await useImpersonationStore.getState().exit(); + enterImpersonation(session()); + exitImpersonation(); + await Promise.resolve(); - // Best-effort by design: never strand the operator in someone else's session. The - // server-side row may outlive this call, bounded by the 30-minute TTL. - assert.equal(useImpersonationStore.getState().session, null); assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 2); }); - it("exit() is a no-op against the server when there is no active session", async () => { + 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; - await useImpersonationStore.getState().exit(); + exitImpersonation(); assert.equal(called, false); - assert.equal(useImpersonationStore.getState().session, null); + assert.equal(accountStateClears, 0); }); - it("syncFromStorage() clears the store when api-client dropped the session on a 401", () => { - useImpersonationStore.getState().enter(session()); - AuthService.clearImpersonationSession(); + it("clears account state when the API client drops a rejected session", () => { + enterImpersonation(session()); + accountStateClears = 0; - useImpersonationStore.getState().syncFromStorage(); + AuthService.clearImpersonationSession(); - assert.equal(useImpersonationStore.getState().session, null); - assert.equal(accountStateClears, 2); + assert.equal(AuthService.getImpersonationSession(), null); + assert.equal(accountStateClears, 1); }); - it("syncFromStorage() adopts a session started in another tab", () => { + it("adopts another tab's session and clears the prior account cache", () => { const fromOtherTab = session({ sessionId: "session-2", token: "vtx_imp_token-2" }); - AuthService.storeImpersonationSession(fromOtherTab); + values.set(AuthService.IMPERSONATION_STORAGE_KEY, JSON.stringify(fromOtherTab)); + + dispatchStorage(AuthService.IMPERSONATION_STORAGE_KEY); - useImpersonationStore.getState().syncFromStorage(); + assert.deepEqual(AuthService.getImpersonationSession(), fromOtherTab); + assert.equal(accountStateClears, 1); + }); - assert.deepEqual(useImpersonationStore.getState().session, fromOtherTab); + 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("syncFromStorage() keeps the current session when storage still holds the same token", () => { + it("does not clear account state for an unchanged storage event", () => { const current = session(); - useImpersonationStore.getState().enter(current); - const before = useImpersonationStore.getState().session; + enterImpersonation(current); + accountStateClears = 0; - useImpersonationStore.getState().syncFromStorage(); + dispatchStorage(AuthService.IMPERSONATION_STORAGE_KEY); - assert.equal(useImpersonationStore.getState().session, before); + 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 index 053b105ec..dddf1f741 100644 --- a/apps/dashboard/src/stores/impersonation.store.ts +++ b/apps/dashboard/src/stores/impersonation.store.ts @@ -1,48 +1,57 @@ -import { create } from "zustand"; +import { useSyncExternalStore } from "react"; import { AdminConsoleService } from "@/services/api/admin-console.service"; import { AuthService, type ImpersonationSession } from "@/services/auth"; import { clearAccountState } from "./auth.store"; -interface ImpersonationState { - session: ImpersonationSession | null; - enter: (session: ImpersonationSession) => void; - exit: () => Promise; - /** Reconcile with storage — picks up a session cleared elsewhere (e.g. api-client on a 401). */ - syncFromStorage: () => void; +let currentSnapshot = AuthService.getImpersonationSessionSnapshot(); +const reactListeners = new Set<() => void>(); + +function applyStoredIdentity(): void { + const nextSnapshot = AuthService.getImpersonationSessionSnapshot(); + if (nextSnapshot === currentSnapshot) return; + + currentSnapshot = nextSnapshot; + clearAccountState(); + for (const listener of reactListeners) { + listener(); + } } -/** "Log in as" session: entering/exiting always clears query cache and client state so no - * data from one identity leaks into the other. */ -export const useImpersonationStore = create()((set, get) => ({ - enter: session => { - clearAccountState(); - AuthService.storeImpersonationSession(session); - set({ session }); - }, - exit: async () => { - const { session } = get(); - if (session) { - try { - await AdminConsoleService.endImpersonation(session.sessionId); - } catch { - // Never strand the operator in someone else's session over a failed network call. - } - } - AuthService.clearImpersonationSession(); - clearAccountState(); - set({ session: null }); - }, - session: AuthService.getImpersonationSession(), - syncFromStorage: () => { - const stored = AuthService.getImpersonationSession(); - const current = get().session; - if (!stored && current) { - clearAccountState(); - set({ session: null }); - return; - } - if (stored && stored.token !== current?.token) { - set({ session: stored }); - } +// One bridge owns cross-tab and same-tab storage notifications for the app lifetime. React +// consumers subscribe to the cached snapshot below, so multiple components never duplicate +// account-state cleanup for one identity transition. +AuthService.subscribeImpersonationSession(applyStoredIdentity); + +function subscribe(listener: () => void): () => void { + reactListeners.add(listener); + return () => reactListeners.delete(listener); +} + +function getSnapshot(): string | null { + return currentSnapshot; +} + +/** `localStorage` is the single source of truth, including changes made in another tab. */ +export function useImpersonationSession(): ImpersonationSession | null { + const snapshot = useSyncExternalStore(subscribe, getSnapshot, () => null); + return AuthService.parseImpersonationSessionSnapshot(snapshot); +} + +/** Entering a new identity synchronously clears every account-scoped client cache. */ +export function enterImpersonation(session: ImpersonationSession): void { + AuthService.storeImpersonationSession(session); +} + +/** + * Exit locally first. The revocation request already captured the session token when this + * function clears storage, and is allowed to finish best-effort without blocking the UI. + */ +export function exitImpersonation(): void { + const session = AuthService.getImpersonationSession(); + if (session) { + void AdminConsoleService.endImpersonation(session.sessionId).catch(() => { + // The server session remains bounded by its non-renewable 30-minute TTL. + }); } -})); + AuthService.clearImpersonationSession(); +} From dff0f5985ce985544f4718fd5bc9a3f8e831bc52 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 7 Aug 2026 10:52:33 +0200 Subject: [PATCH 13/29] fix(dashboard): repair nested admin routes --- apps/dashboard/src/routeTree.gen.ts | 23 ++- .../src/routes/_app/admin.$profileId.tsx | 18 +-- .../dashboard/src/routes/_app/admin.index.tsx | 133 ++++++++++++++++++ apps/dashboard/src/routes/_app/admin.tsx | 129 +---------------- 4 files changed, 161 insertions(+), 142 deletions(-) create mode 100644 apps/dashboard/src/routes/_app/admin.index.tsx diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 1f54638f4..72566e558 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as AppOverviewRouteImport } from './routes/_app/overview' import { Route as AppLimitsRouteImport } from './routes/_app/limits' import { Route as AppApiKeysRouteImport } from './routes/_app/api-keys' import { Route as AppAdminRouteImport } from './routes/_app/admin' +import { Route as AppAdminIndexRouteImport } from './routes/_app/admin.index' import { Route as AppAdminProfileIdRouteImport } from './routes/_app/admin.$profileId' const LoginRoute = LoginRouteImport.update({ @@ -94,6 +95,11 @@ const AppAdminRoute = AppAdminRouteImport.update({ path: '/admin', getParentRoute: () => AppRoute, } as any) +const AppAdminIndexRoute = AppAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppAdminRoute, +} as any) const AppAdminProfileIdRoute = AppAdminProfileIdRouteImport.update({ id: '/$profileId', path: '/$profileId', @@ -115,11 +121,11 @@ export interface FileRoutesByFullPath { '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute '/admin/$profileId': typeof AppAdminProfileIdRoute + '/admin/': typeof AppAdminIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute - '/admin': typeof AppAdminRouteWithChildren '/api-keys': typeof AppApiKeysRoute '/limits': typeof AppLimitsRoute '/overview': typeof AppOverviewRoute @@ -131,6 +137,7 @@ export interface FileRoutesByTo { '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute '/admin/$profileId': typeof AppAdminProfileIdRoute + '/admin': typeof AppAdminIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -149,6 +156,7 @@ export interface FileRoutesById { '/invite/$token': typeof InviteTokenRoute '/monerium/callback': typeof MoneriumCallbackRoute '/_app/admin/$profileId': typeof AppAdminProfileIdRoute + '/_app/admin/': typeof AppAdminIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -167,11 +175,11 @@ export interface FileRouteTypes { | '/invite/$token' | '/monerium/callback' | '/admin/$profileId' + | '/admin/' fileRoutesByTo: FileRoutesByTo to: | '/' | '/login' - | '/admin' | '/api-keys' | '/limits' | '/overview' @@ -183,6 +191,7 @@ export interface FileRouteTypes { | '/invite/$token' | '/monerium/callback' | '/admin/$profileId' + | '/admin' id: | '__root__' | '/' @@ -200,6 +209,7 @@ export interface FileRouteTypes { | '/invite/$token' | '/monerium/callback' | '/_app/admin/$profileId' + | '/_app/admin/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -310,6 +320,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminRouteImport parentRoute: typeof AppRoute } + '/_app/admin/': { + id: '/_app/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AppAdminIndexRouteImport + parentRoute: typeof AppAdminRoute + } '/_app/admin/$profileId': { id: '/_app/admin/$profileId' path: '/$profileId' @@ -322,10 +339,12 @@ declare module '@tanstack/react-router' { interface AppAdminRouteChildren { AppAdminProfileIdRoute: typeof AppAdminProfileIdRoute + AppAdminIndexRoute: typeof AppAdminIndexRoute } const AppAdminRouteChildren: AppAdminRouteChildren = { AppAdminProfileIdRoute: AppAdminProfileIdRoute, + AppAdminIndexRoute: AppAdminIndexRoute, } const AppAdminRouteWithChildren = AppAdminRoute._addFileChildren( diff --git a/apps/dashboard/src/routes/_app/admin.$profileId.tsx b/apps/dashboard/src/routes/_app/admin.$profileId.tsx index 6859645d6..b8044747f 100644 --- a/apps/dashboard/src/routes/_app/admin.$profileId.tsx +++ b/apps/dashboard/src/routes/_app/admin.$profileId.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { ImpersonateDialog } from "@/components/admin/ImpersonateDialog"; import { Stagger, StaggerItem } from "@/components/motion/Stagger"; @@ -7,25 +7,11 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAdminAccount } from "@/hooks/useAdminConsole"; -import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; export const Route = createFileRoute("/_app/admin/$profileId")({ - component: AdminAccountDetailPage + component: AccountDetail }); -function AdminAccountDetailPage() { - const onboardingStatus = useOnboardingStatusQuery(); - const isAdmin = onboardingStatus.data?.roles.includes("vortex_admin") ?? false; - - if (onboardingStatus.isLoading) { - return ; - } - if (!isAdmin) { - return ; - } - return ; -} - function AccountDetail() { const { profileId } = Route.useParams(); const account = useAdminAccount(profileId); diff --git a/apps/dashboard/src/routes/_app/admin.index.tsx b/apps/dashboard/src/routes/_app/admin.index.tsx new file mode 100644 index 000000000..8338e0e3a --- /dev/null +++ b/apps/dashboard/src/routes/_app/admin.index.tsx @@ -0,0 +1,133 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { AdminAccountsTable } from "@/components/admin/AdminAccountsTable"; +import { Stagger, StaggerItem } from "@/components/motion/Stagger"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAdminAccounts, useAdminImpersonationSessions } from "@/hooks/useAdminConsole"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; + +const PAGE_LIMIT = 20; + +export const Route = createFileRoute("/_app/admin/")({ + component: AdminAccountsPage +}); + +function AdminAccountsPage() { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + const [cursorStack, setCursorStack] = useState([]); + const cursor = cursorStack.at(-1); + + const accounts = useAdminAccounts({ cursor, limit: PAGE_LIMIT, search: debouncedSearch || undefined }); + const sessions = useAdminImpersonationSessions(); + + return ( + + +

Admin

+

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

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

Could not load accounts.

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

Could not load impersonation activity.

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

No impersonation sessions yet.

+ ) : ( +
    + {sessions.data.sessions.map(session => ( +
  • + + {session.actor.email ?? session.actor.id} acting as{" "} + {session.target.email ?? session.target.id} + + {new Date(session.createdAt).toLocaleString()} + + + {session.active ? "Active" : "Ended"} +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/routes/_app/admin.tsx b/apps/dashboard/src/routes/_app/admin.tsx index ab220f141..839a2c695 100644 --- a/apps/dashboard/src/routes/_app/admin.tsx +++ b/apps/dashboard/src/routes/_app/admin.tsx @@ -1,23 +1,13 @@ -import { createFileRoute, Navigate } 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 { createFileRoute, Navigate, Outlet } from "@tanstack/react-router"; import { Skeleton } from "@/components/ui/skeleton"; -import { useAdminAccounts, useAdminImpersonationSessions } from "@/hooks/useAdminConsole"; import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; -import { useDebouncedValue } from "@/hooks/useDebouncedValue"; - -const PAGE_LIMIT = 20; export const Route = createFileRoute("/_app/admin")({ - component: AdminPage + component: AdminLayout }); -function AdminPage() { +/** 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; @@ -27,114 +17,5 @@ function AdminPage() { if (!isAdmin) { return ; } - return ; -} - -function AdminAccountsPage() { - const [search, setSearch] = useState(""); - const debouncedSearch = useDebouncedValue(search, 300); - const [cursorStack, setCursorStack] = useState([]); - const cursor = cursorStack.at(-1); - - const accounts = useAdminAccounts({ cursor, limit: PAGE_LIMIT, search: debouncedSearch || undefined }); - const sessions = useAdminImpersonationSessions(); - - return ( - - -

Admin

-

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

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

Could not load accounts.

- -
- ) : ( - <> - -
- - -
- - )} -
-
-
- - - - - Recent impersonation activity - - - {sessions.isLoading ? ( - - ) : sessions.isError || !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"} -
  • - ))} -
- )} -
-
-
-
- ); + return ; } From 94c4210051f3eff00f19218d77240f09f81b2010 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 7 Aug 2026 10:57:49 +0200 Subject: [PATCH 14/29] docs(repo): align impersonation contracts with implementation --- docs/product-dashboard.md | 17 ++-- docs/security-spec/01-auth/admin-auth.md | 15 ++- .../01-auth/admin-impersonation.md | 92 +++++++++++-------- docs/security-spec/RISK-REGISTER.md | 2 +- 4 files changed, 80 insertions(+), 46 deletions(-) diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index a8a00b37e..32f9a29b0 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -321,17 +321,20 @@ v2 — not present, not planned for this iteration. 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 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, +Both inherit the `/admin` parent route's redirect to `/overview` unless `roles` from +`GET /v1/onboarding/status` contains `vortex_admin`, and the sidebar's Admin item follows the +same gate. While a session is live, `ImpersonationBanner` is rendered above the topbar on every `_app` route — non-dismissible, naming the impersonated account and offering "Exit". Because the operator's own Supabase tokens -are kept beside the impersonation token rather than replaced, exiting is local and instant. +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 059 and 060 apply and revert cleanly, and the -manual flow (grant the role, log in, list accounts, impersonate, exit) has been exercised -against a local API with Supabase auth: the impersonated principal resolves to the target, +**Verified against a running stack.** Migrations 062 and 063 apply from a clean schema, and the +flow (grant the role, log in, list accounts, impersonate, exit) is covered against a local API +with Supabase auth: the impersonated principal resolves to the target, `/v1/admin-console/*` and API-credential minting refuse an impersonated caller with 403, the -session self-revokes on exit, and a revoked token is rejected on its next use. +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 diff --git a/docs/security-spec/01-auth/admin-auth.md b/docs/security-spec/01-auth/admin-auth.md index aff84378e..5d9368f68 100644 --- a/docs/security-spec/01-auth/admin-auth.md +++ b/docs/security-spec/01-auth/admin-auth.md @@ -36,7 +36,20 @@ the shared credential; individual admin identities are out of scope for this cha 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 on `/v1/admin/*` MUST NOT attach any identity to the request** — Unlike Supabase auth (which sets `userId`) or API key auth (which sets `authenticatedPartner`), admin auth on this surface is identity-less. No `req.adminUser` or similar should exist. This invariant is scoped to `/v1/admin/*`: the separate `/v1/admin-console/*` surface is intentionally identity-bearing — it authenticates via Supabase and carries the operator's profile ID — by design; see [`admin-impersonation.md`](admin-impersonation.md). -8. **`vortex_admin` MUST NOT be grantable through `POST /v1/admin/profile-roles`** — that route is guarded only by `ADMIN_SECRET`, and `vortex_admin` grants access to `/v1/admin-console/*` including FULL-depth customer impersonation ([`admin-impersonation.md`](admin-impersonation.md)). If the shared secret could grant that role, it would be sufficient by itself to gain money-movement rights over any customer, collapsing the separation this document's "What This Does" section describes. Granting `vortex_admin` must go through an out-of-band operator process outside this route. **Enforced**: `profileRole.model.ts` exports `HTTP_GRANTABLE_PROFILE_ROLES = ["discount_manager"]`; `addProfileRole` (`profileRoles.controller.ts`) returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` (verified in `profileRoles.controller.test.ts`). `removeProfileRole` deliberately remains exempt — it can still revoke `vortex_admin` as a safety valve. The sanctioned grant path is `apps/api/scripts/grant-vortex-admin.ts`, run as `bun run grant:vortex-admin `. +8. **`vortex_admin` MUST NOT be grantable through `POST /v1/admin/profile-roles`** — that + route is guarded only by `ADMIN_SECRET`, and `vortex_admin` grants access to + `/v1/admin-console/*` including FULL-depth customer impersonation + ([`admin-impersonation.md`](admin-impersonation.md)). If the shared secret could grant that + role, it would be sufficient by itself to gain money-movement rights over any customer, + collapsing the separation this document's "What This Does" section describes. Granting + `vortex_admin` must go through an out-of-band operator process outside this route. + **Enforced**: `profileRole.model.ts` exports + `HTTP_GRANTABLE_PROFILE_ROLES = ["discount_manager"]`; `addProfileRole` + (`profileRoles.controller.ts`) returns `403 ROLE_NOT_HTTP_GRANTABLE` for `vortex_admin` + (verified in `profileRoles.controller.test.ts`). `removeProfileRole` deliberately remains + exempt as a safety valve; removing `vortex_admin` atomically revokes every live + impersonation session owned by that profile. The sanctioned grant path is + `apps/api/scripts/grant-vortex-admin.ts`, run as `bun run grant:vortex-admin `. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md index 05d4173f2..b87be2f0a 100644 --- a/docs/security-spec/01-auth/admin-impersonation.md +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -20,17 +20,17 @@ All routes live under `/v1/admin-console/*` (`accounts.route.ts`, `impersonation | Route | Guard | Success | Notable errors | |---|---|---|---| | `GET /accounts?search=&cursor=&limit=` | `requireVortexAdmin` | `200` — paginated, email-`search`-filtered account list | — | -| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — entities, provider customers, KYC cases, recent impersonation sessions targeting this profile | `404 USER_NOT_FOUND` | -| `POST /impersonation` `{ targetProfileId }` | `requireVortexAdmin` | `201 { token, sessionId, expiresAt, target: { id, email } }` | `400 INVALID_IMPERSONATION_INPUT` (missing `targetProfileId`); `400 IMPERSONATION_TARGET_INVALID` (self-target, unknown target — from `ImpersonationTargetError`); `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 | — | -| `DELETE /impersonation/:sessionId` | see Invariant 12 | `204` | `403 IMPERSONATION_NOT_ALLOWED`; `403 VORTEX_ADMIN_REQUIRED`; `404 IMPERSONATION_SESSION_NOT_FOUND` | +| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — entities, provider customers, KYC cases, recent impersonation sessions targeting this profile | `400 INVALID_PROFILE_ID`; `404 USER_NOT_FOUND` | +| `POST /impersonation` `{ targetProfileId }` | `requireVortexAdmin` | `201 { token, sessionId, expiresAt, target: { id, email } }` | `400 INVALID_IMPERSONATION_INPUT` (malformed `targetProfileId`); `400 IMPERSONATION_TARGET_INVALID` (self-target, unknown target — from `ImpersonationTargetError`); `403 VORTEX_ADMIN_REQUIRED` if the role is removed during creation; `503 IMPERSONATION_DISABLED` (kill switch off — the caller is authorized, the capability is off, so this is a capability error, not an auth error) | +| `GET /impersonation?limit=` | `requireVortexAdmin` | `200 { sessions: [...] }` — active-first audit view; a non-positive or malformed limit falls back to the default | — | +| `DELETE /impersonation/:sessionId` | see Invariant 12 | `204` | `400 INVALID_IMPERSONATION_SESSION_ID`; `403 IMPERSONATION_NOT_ALLOWED`; `403 VORTEX_ADMIN_REQUIRED`; `404 IMPERSONATION_SESSION_NOT_FOUND` | `requireVortexAdmin` (`vortexAdminAuth.ts`) is the chain `requireAuth → rejectImpersonation → checkVortexAdminRole`: Supabase auth, then no impersonation chaining, then the `vortex_admin` -capability role (`ProfileRole` with `role = "vortex_admin"`). It sets `req.adminProfileId` 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. +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. @@ -41,13 +41,14 @@ Invariant 12 for the exact self-revoke mechanism this enables. (`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. + 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 returns an `ImpersonationContext`. `resolveBearerPrincipal()` + 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 @@ -72,8 +73,10 @@ only by the shared `ADMIN_SECRET`, and holding `vortex_admin` is sufficient to i customer at FULL depth, so that secret must never be sufficient by itself to grant it. `HTTP_GRANTABLE_PROFILE_ROLES` (`profileRole.model.ts`) lists only `discount_manager`; `addProfileRole` returns `403 ROLE_NOT_HTTP_GRANTABLE` for anything else. `removeProfileRole` -deliberately still revokes any role, including `vortex_admin`, as a safety valve. The sanctioned -grant path is out-of-band: `apps/api/scripts/grant-vortex-admin.ts`, run as +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. @@ -87,9 +90,10 @@ and requires deployment/database access rather than an HTTP credential — see 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 and a real, distinct target** — - `createSession()` throws `ImpersonationDisabledError` when `config.impersonationEnabled` is - false, and `ImpersonationTargetError` for a non-existent target profile or +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 @@ -98,10 +102,11 @@ and requires deployment/database access rather than an HTTP credential — see 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 on every use, not cache a prior verdict** — - `resolveSession()` re-reads `revokedAt` and `expiresAt` from the database on each call and - returns `null` for anything not currently live (unknown, expired, revoked, or minted while the - kill switch was on but resolved after it was flipped off). +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 @@ -109,14 +114,14 @@ and requires deployment/database access rather than an HTTP credential — see 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 enforced in the application layer only; the supporting partial index - (`idx_admin_impersonation_sessions_active`) accelerates the lookup but is not a `UNIQUE` - constraint, so it does not by itself prevent two concurrent `createSession()` calls from both - succeeding in a narrow race (see Audit Checklist). + 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`. + `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 @@ -163,6 +168,10 @@ and requires deployment/database access rather than an HTTP credential — see revocation of any role, including `vortex_admin`, remains available via that route as a safety valve (verified: "still allows revoking vortex_admin even though it cannot be granted via HTTP"). See [`admin-auth.md`](admin-auth.md) Invariant 8. +15. **Session audit history MUST NOT disappear when an actor or target profile is deleted** — + both profile foreign keys in migration 063 use `ON DELETE RESTRICT`. Operators must resolve + retention/deletion policy explicitly instead of erasing security history through a profile + cascade. ## Threat Vectors & Mitigations @@ -176,9 +185,11 @@ and requires deployment/database access rather than an HTTP credential — see | Unattributed money movement | Operator disputes having performed an action while impersonating | Per-operator Supabase identity recorded as `actorProfileId` on the session row (Invariant 3); `impersonationSessionId`/`impersonatorProfileId` on every `api_client_events` row raised during the request (Invariant 13) | | Self-impersonation used to launder attribution | Operator targets their own profile to blur operator/target identity | Rejected at both the application layer and a database `CHECK` constraint (Invariant 3) | | Stale sessions surviving an incident response kill switch | Operator response to a suspected compromise is "disable impersonation", but existing tokens keep working | `IMPERSONATION_ENABLED=false` invalidates all live sessions on next resolution, not just new mints (Invariant 6) | +| Removed operator role leaves previously minted tokens usable | An operator is deprovisioned while one or more impersonation sessions remain live | Role removal atomically revokes all non-revoked sessions, and token resolution independently re-checks `vortex_admin` on every use (Invariants 5 and 8) | | Token brute force / guessing | Attacker attempts to guess a valid `vtx_imp_*` value | 256 bits of randomness in the token; lookup requires an exact SHA-256 hash match | | Shared-secret surface used to self-grant impersonation rights | An operator (or anyone) with `ADMIN_SECRET` calls `POST /v1/admin/profile-roles` to grant themselves `vortex_admin`, turning a shared secret into money-movement rights over any customer | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` (Invariant 14); the only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access, not an HTTP credential | -| Concurrent session creation races the supersession check | Two near-simultaneous `POST /impersonation` calls for the same (actor, target) both read "no active session" before either writes | Bounded impact: both sessions are minted by the same actor for the same target with independent 30-minute TTLs and are each individually revocable; this does not grant a *different* actor or target any rights. Not database-enforced (Invariant 7) | +| 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 @@ -187,9 +198,6 @@ and requires deployment/database access rather than an HTTP credential — see any compromised operator account or misused session can move a customer's funds. There is no read-only or reduced-scope impersonation mode. Tracked as an accepted risk in the risk register (RISK-018), not as an open implementation gap. -- Starting two sessions for the same (actor, target) pair in rapid succession is not - database-serialized (Invariant 7); the application-layer supersession check can race. Impact is - bounded — see the Threat table — and this is not considered a blocking finding. - 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 @@ -198,8 +206,10 @@ and requires deployment/database access rather than an HTTP credential — see 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's "Exit" clears the banner - even when its `DELETE /impersonation/:sessionId` fails, deliberately, so a failed network call +- 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. @@ -211,16 +221,17 @@ and requires deployment/database access rather than an HTTP credential — see 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, distinct actor/target, - and an existing target profile — **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, and revoked tokens, and rejects all tokens the - instant `IMPERSONATION_ENABLED` is false, independent of each session's own state — **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` — **PASS**. No `UNIQUE` database constraint backs this; a narrow concurrent-create - race is possible (Threat table, bounded impact) — **NOTED, not a blocking finding**. + `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 @@ -238,11 +249,18 @@ and requires deployment/database access rather than an HTTP credential — see 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` — **PASS**. + `impersonationSessionId` and `impersonatorProfileId` in `metadata`, including successful + quote/ramp operations and maintenance denials — **PASS** (`quote.controller.test.ts`, + `ramp.controller.test.ts`, `maintenanceGuard.test.ts`). - [x] `vortex_admin` is excluded from grant via `POST /v1/admin/profile-roles` (`403 ROLE_NOT_HTTP_GRANTABLE`), while revocation of any role including `vortex_admin` remains available via `DELETE` on that same route — **PASS** (`profileRoles.controller.test.ts`). +- [x] Removing `vortex_admin` atomically revokes every live session, while `resolveSession()` also + rejects a token after an out-of-band role deletion — **PASS** (`profileRoles.controller.test.ts`, + `impersonation.service.test.ts`). +- [x] Actor and target deletions are `RESTRICT`ed so session audit rows cannot be cascade-deleted — + **PASS** (`impersonation.service.test.ts`). - [x] An out-of-band, idempotent operator process for granting `vortex_admin` exists and is documented — **PASS** (`scripts/grant-vortex-admin.ts`, `bun run grant:vortex-admin `). diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 6fb86d9d1..914f56484 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -38,7 +38,7 @@ register and the owning module specification. | RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | | RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base ramps are not swept by the Base post-process handler, and AssetHub cleanup is a no-op. | Cleanup worker selects terminal ramps; completed Base token cleanup works; AssetHub corridors remain quote-disabled. | Widen safe Base cleanup to failed/timed-out states and implement/remove AssetHub cleanup before enabling AssetHub or relying on automatic failure refunds. | | RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | -| RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer profile at FULL depth — the operator acts with the target's complete rights, including money movement. v1 has no reduced-scope or read-only impersonation mode. | Per-operator Supabase identity plus `vortex_admin` role gate; 30-minute non-renewable TTL; one active session per (actor, target), a new one supersedes the old; hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks credential minting and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request. | Revisit before scoping impersonation depth down (e.g., a read-only investigate mode) or before v2 sub-account delegation reuses this mechanism; see `01-auth/admin-impersonation.md`. | +| RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer profile at FULL depth — the operator acts with the target's complete rights, including money movement. v1 has no reduced-scope or read-only impersonation mode. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks credential minting and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before scoping impersonation depth down (e.g., a read-only investigate mode) or before v2 sub-account delegation reuses this mechanism; see `01-auth/admin-impersonation.md`. | ## Review cadence From ecd0cf8aabb1bc43e21c7e760aeba006ba7ed7e9 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 13 Aug 2026 12:33:16 -0300 Subject: [PATCH 15/29] docs(dashboard): specify managed child selection --- docs/product-dashboard.md | 97 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 8c8235b55..45025d909 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -26,7 +26,9 @@ 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 also implements managed + headless profiles and route-scoped manager delegation. The dashboard experience for selecting and + acting for those profiles is the next accepted feature described below; it is not yet shipped. ## User stories @@ -141,6 +143,75 @@ two people. category — recipient-approval alerts — was dropped for now: no such notification type exists in the backend yet.) +### Managed profiles (accepted next feature) + +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 for**. 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, Recipients, Get a quote, New transfer, Transactions, and +Limits remain available where their API routes support managed-child authorization. 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. + +**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. + ## High-level implementation strategy The dashboard is the same stack as the widget, and reuses its logic wherever the logic is @@ -194,6 +265,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. @@ -275,6 +353,14 @@ provider-shaped rather than UI-shaped. ## Next steps +- Add the managed-profile selector, persisted delegated identity, child-mode banner, and explicit + per-service managed-header handling described above. +- Add a transfer-state identity guard and owner-keyed resumable payment snapshots. Block manager/ + child identity changes during client-owned preparation and signing, allow them after signed state + is durably submitted, and preserve all manager/child ramp ephemerals and backend ramp references + across allowed changes. +- Expose manager-authorized corridors from `GET /v1/managed-profiles` and extend sender-side + recipient routes to managed-child authorization before enabling Recipients in child mode. - Display relationship status and authoritative transfer eligibility, including the reason a recipient is not payable, instead of deriving availability from onboarding status alone. - Connect the dashboard notification feed to the backend. @@ -319,9 +405,12 @@ impersonation mode in v1. An impersonated request cannot mint a durable API cred re-enter the admin console (no privilege re-escalation, no chaining), with one narrow exception so an operator can end its own session. -**v1 scope is Vortex → main-account only.** There is no parent/child account table. The -main-account → sub-account delegation layer, modelled on Avenia's subaccount API, is explicitly -v2 — not present, not planned for this iteration. +**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 selector and child-mode experience are the accepted next feature 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 From 7021224758cf04c08308da94697f4cbc877468da Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 13 Aug 2026 13:44:06 -0300 Subject: [PATCH 16/29] feat(api): enable managed recipient delegation --- .../managedProfiles.controller.test.ts | 14 ++ .../controllers/managedProfiles.controller.ts | 10 + .../api/controllers/recipients.controller.ts | 173 +++++++++++--- .../middlewares/managedProfileAuth.test.ts | 4 +- .../src/api/middlewares/managedProfileAuth.ts | 60 +++-- .../api/routes/v1/recipients.route.test.ts | 5 +- .../api/src/api/routes/v1/recipients.route.ts | 54 ++++- .../src/tests/recipients.integration.test.ts | 222 +++++++++++++++++- docs/adr-0003-managed-headless-profiles.md | 8 +- docs/api/openapi/vortex.openapi.d.ts | 12 +- docs/api/openapi/vortex.openapi.json | 25 +- .../03-authentication-and-partner-keys.md | 6 +- docs/api/scripts/check-openapi.ts | 25 +- docs/architecture-identity-model.md | 7 +- docs/security-spec/01-auth/api-keys.md | 63 ++--- .../03-ramp-engine/recipient-transfers.md | 71 +++--- .../07-operations/api-surface.md | 50 ++-- 17 files changed, 643 insertions(+), 166 deletions(-) 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/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/managedProfileAuth.test.ts b/apps/api/src/api/middlewares/managedProfileAuth.test.ts index 9c8b13733..1b24fa545 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.test.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.test.ts @@ -227,7 +227,7 @@ describe("authorizeManagedProfile", () => { expect(unsupported.statusCode).toBe(403); }); - it("does not apply customer-type narrowing to policy-free reads", async () => { + it("applies current customer-type narrowing to every delegated decision", async () => { allowManagedProfile(); ManagedProfileManager.findByPk = mock(async () => ({ allowedCorridors: ["BR"], @@ -238,7 +238,7 @@ describe("authorizeManagedProfile", () => { await authorizeManagedProfile()(request() as never, response() as never, next); - expect(next).toHaveBeenCalledTimes(1); + expect(next).not.toHaveBeenCalled(); }); it("requires the route customer type to match the immutable child entity type", async () => { diff --git a/apps/api/src/api/middlewares/managedProfileAuth.ts b/apps/api/src/api/middlewares/managedProfileAuth.ts index 471bbdc21..e231aa8b2 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 = @@ -57,15 +58,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 +65,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 +86,10 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) ) { return; } + res.locals.managedProfilePolicy = { + allowedCorridors: directManagedCredential.allowedCorridors, + customerType + }; next(); } catch (error) { next(error); @@ -124,16 +129,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 +141,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 +159,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,10 +213,7 @@ async function authorizeCustomerType( return false; } if ( - ((options.corridor !== undefined || options.customerType !== undefined) && - allowedCustomerTypes !== null && - allowedCustomerTypes !== undefined && - !allowedCustomerTypes.includes(customerType)) || + (allowedCustomerTypes !== null && allowedCustomerTypes !== undefined && !allowedCustomerTypes.includes(customerType)) || corridors.some(corridor => !isCorridorSupportedForCustomerType(corridor, customerType)) ) { sendAccessDenied(res); 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..4870d3121 100644 --- a/apps/api/src/api/routes/v1/recipients.route.ts +++ b/apps/api/src/api/routes/v1/recipients.route.ts @@ -6,57 +6,89 @@ 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(), 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/tests/recipients.integration.test.ts b/apps/api/src/tests/recipients.integration.test.ts index 807ab4840..288d2663b 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( + { 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/docs/adr-0003-managed-headless-profiles.md b/docs/adr-0003-managed-headless-profiles.md index 037bdd91e..e35d7fd31 100644 --- a/docs/adr-0003-managed-headless-profiles.md +++ b/docs/adr-0003-managed-headless-profiles.md @@ -39,9 +39,11 @@ 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. 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 b54cd0d9e..f6165d4a8 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -745,7 +745,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. */ @@ -2418,6 +2418,7 @@ export interface components { credentials: components["schemas"]["ApiCredential"][]; }; ListManagedProfilesResponse: { + manager: components["schemas"]["ManagedProfileManagerPolicy"]; managedProfiles: components["schemas"]["ManagedProfile"][]; pagination: components["schemas"]["ManagedProfilePagination"]; }; @@ -2452,6 +2453,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; @@ -4958,7 +4966,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; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index e8da40a6c..a7e6453bc 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -1276,9 +1276,10 @@ "items": { "$ref": "#/components/schemas/ManagedProfile" }, "type": "array" }, + "manager": { "$ref": "#/components/schemas/ManagedProfileManagerPolicy" }, "pagination": { "$ref": "#/components/schemas/ManagedProfilePagination" } }, - "required": ["managedProfiles", "pagination"], + "required": ["manager", "managedProfiles", "pagination"], "type": "object" }, "ManagedProfile": { @@ -1333,6 +1334,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": { "maximum": 100, "minimum": 1, "type": "integer" }, @@ -3938,7 +3957,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": [ { @@ -3968,7 +3987,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ListManagedProfilesResponse" } } }, - "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": { diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index 23a1dfd76..f1e2d6ca2 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -39,7 +39,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, quote pricing, and ramp history resolve from the child subject. -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. Webhook registration and deletion do not support managed children. `X-Managed-Profile-Id` returns `400 MANAGED_PROFILE_UNSUPPORTED`, and a direct child credential returns `403 MANAGED_PROFILE_ACCESS_DENIED`. Managed-child integrations must poll the child-scoped ramp status/history endpoints. A manager credential without the selector remains manager-owned and therefore cannot register a webhook for a child-owned quote. @@ -52,7 +52,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 | @@ -61,7 +61,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 81a038300..2cb114f1f 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -394,6 +394,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 || @@ -411,6 +412,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 || @@ -422,11 +431,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 3e9d095e9..aff009359 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 diff --git a/docs/security-spec/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index e710e2c1f..d51bc7a8c 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -23,22 +23,22 @@ 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. @@ -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. +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 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. 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/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index d8ec9f606..ca615783d 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -5,6 +5,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api/`): how requests enter the system, what validation is applied, how errors are returned, and what network-level protections exist. **Express configuration** (`config/express.ts`): + - CORS: Explicit origin whitelist — `app.vortexfinance.co`, `dashboard.vortexfinance.co`, `metrics.vortexfinance.co`, staging Netlify (non-production only, gated on `DEPLOYMENT_ENV`), `localhost` (dev only), plus the optional `DASHBOARD_ORIGINS` env var (comma-separated fixed origins for non-production dashboard deployments; resolved once at boot, wildcard entries dropped) and the optional `DASHBOARD_PREVIEW_SITE` env var (a single Netlify site slug; enables the fixed-shape pattern `https://deploy-preview---.netlify.app` for dashboard deploy previews, non-production only; helpers in `config/corsOrigins.ts`) - Rate limiting: 100 requests per minute per IP (global, all endpoints) - Helmet: Standard HTTP security headers @@ -12,11 +13,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,17 +27,20 @@ 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. @@ -45,7 +51,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api ## Security Invariants -1. **CORS MUST only allow explicit origins** — The whitelist is defined in `express.ts` (helpers in `corsOrigins.ts`). No wildcard (`*`) origins. No dynamic origin reflection (echoing back the `Origin` header). The `DASHBOARD_ORIGINS` env var extends the whitelist with additional *fixed* origins only: it is parsed once at boot and entries containing `*` are silently discarded, so it cannot be used to introduce a wildcard. The only permitted pattern-based entry is the dashboard deploy-preview regex: `DASHBOARD_PREVIEW_SITE` supplies a Netlify site slug (validated against `[a-z0-9-]`, so it cannot alter the regex), and the code builds the fixed-shape, fully-anchored pattern `^https://deploy-preview-\d+--\.netlify\.app$`. It is disabled when `DEPLOYMENT_ENV` is `production` (gated on `config.deploymentEnv`, not `NODE_ENV` — staging also runs with `NODE_ENV=production`) — deploy previews must never become CORS-allowed origins of the production API. +1. **CORS MUST only allow explicit origins** — The whitelist is defined in `express.ts` (helpers in `corsOrigins.ts`). No wildcard (`*`) origins. No dynamic origin reflection (echoing back the `Origin` header). The `DASHBOARD_ORIGINS` env var extends the whitelist with additional _fixed_ origins only: it is parsed once at boot and entries containing `*` are silently discarded, so it cannot be used to introduce a wildcard. The only permitted pattern-based entry is the dashboard deploy-preview regex: `DASHBOARD_PREVIEW_SITE` supplies a Netlify site slug (validated against `[a-z0-9-]`, so it cannot alter the regex), and the code builds the fixed-shape, fully-anchored pattern `^https://deploy-preview-\d+--\.netlify\.app$`. It is disabled when `DEPLOYMENT_ENV` is `production` (gated on `config.deploymentEnv`, not `NODE_ENV` — staging also runs with `NODE_ENV=production`) — deploy previews must never become CORS-allowed origins of the production API. 2. **Rate limiting MUST be enforced on all endpoints** — 100 req/min per IP applies globally via `express-rate-limit`. No endpoint should bypass this. 3. **Body size MUST be bounded** — The JSON body parser has a limit. **⚠️ FINDING: The limit is 20MB (`"20mb"`), which is still large for a JSON API.** A typical API allows 1-10MB. 20MB still enables avoidable memory pressure. 4. **All user input MUST be validated before reaching controllers** — Validators run as middleware before the controller function. Missing validation on an endpoint means raw user input reaches business logic. @@ -57,7 +63,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,30 +74,30 @@ 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, the managed child and its single active customer entity, the manager's current customer-type narrowing on every delegated decision, every required corridor, 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. 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 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. **Unsupported managed operations MUST fail explicitly** — 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. ## 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 From 8c9df196630c6488d9dccf0bd8474f070846cc5c Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 13 Aug 2026 13:44:34 -0300 Subject: [PATCH 17/29] feat(dashboard): add managed identity routing --- .../components/admin/ImpersonateDialog.tsx | 7 +- .../components/layout/ImpersonationBanner.tsx | 6 +- .../onboarding/avenia/AveniaKycFlow.tsx | 6 +- .../src/components/transfer/OnrampForm.tsx | 14 +- .../transfer/OnrampPaymentInstructions.tsx | 10 +- .../src/components/transfer/TransferForm.tsx | 9 +- .../src/machines/transfer.machine.test.ts | 202 +++++++++++++++++- .../src/machines/transfer.machine.ts | 58 ++++- .../src/machines/transferActor.test.ts | 170 +++++++++++++++ apps/dashboard/src/machines/transferActor.ts | 150 ++++++++++--- apps/dashboard/src/router.tsx | 12 +- .../src/routes/_app/transactions.tsx | 6 +- .../src/services/api/alfredpay.service.ts | 22 +- .../src/services/api/api-client.test.ts | 200 ++++++++++++++++- apps/dashboard/src/services/api/api-client.ts | 89 ++++++-- .../src/services/api/avenia.service.test.ts | 85 ++++++++ .../src/services/api/avenia.service.ts | 14 ++ .../src/services/api/brla.service.ts | 2 +- .../src/services/api/limits.service.ts | 2 +- .../src/services/api/onboarding.service.ts | 2 +- .../src/services/api/quote.service.ts | 2 +- .../src/services/api/ramp.service.ts | 12 +- .../src/services/api/recipients.service.ts | 16 +- .../src/services/api/transactions.service.ts | 2 +- apps/dashboard/src/services/auth.test.ts | 73 ++++++- apps/dashboard/src/services/auth.ts | 185 +++++++++++++++- apps/dashboard/src/stores/auth.store.ts | 20 +- .../src/stores/impersonation.store.test.ts | 76 ++++--- .../src/stores/impersonation.store.ts | 24 ++- .../src/stores/managed-profile.store.test.ts | 185 ++++++++++++++++ .../src/stores/managed-profile.store.ts | 53 +++++ 31 files changed, 1570 insertions(+), 144 deletions(-) create mode 100644 apps/dashboard/src/machines/transferActor.test.ts create mode 100644 apps/dashboard/src/services/api/avenia.service.test.ts create mode 100644 apps/dashboard/src/services/api/avenia.service.ts create mode 100644 apps/dashboard/src/stores/managed-profile.store.test.ts create mode 100644 apps/dashboard/src/stores/managed-profile.store.ts diff --git a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx index 805c12f7c..b55f2b77d 100644 --- a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -38,12 +38,17 @@ export function ImpersonateDialog({ }); }, onSuccess: response => { - enterImpersonation({ + 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; + } handleOpenChange(false); navigate({ to: "/overview" }); } diff --git a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx index 606c1a79a..6cf12ba31 100644 --- a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx +++ b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx @@ -34,15 +34,15 @@ export function ImpersonationBanner() { } function handleExit() { - exitImpersonation(); + if (!exitImpersonation()) return; navigate({ to: "/admin" }); } const remainingMs = new Date(session.expiresAt).getTime() - now; return ( -
- +
+ You are acting as {session.targetEmail} <> · {formatRemaining(remainingMs)} remaining diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx index ee32fcf94..feefa9877 100644 --- a/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx @@ -1,12 +1,12 @@ import type { AveniaKycFormData, UploadIds } from "@vortexfi/kyc"; -import { createAveniaKycApi, createAveniaKycMachine, KycStatus } from "@vortexfi/kyc"; +import { createAveniaKycMachine, KycStatus } from "@vortexfi/kyc"; import { useMachine } from "@xstate/react"; import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; import { useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { DialogFooter } from "@/components/ui/dialog"; import type { Corridor, OnboardingStatus } from "@/domain/types"; -import { apiClient } from "@/services/api/api-client"; +import { AveniaService } from "@/services/api/avenia.service"; import { AveniaDocumentUploadScreen } from "./AveniaDocumentUploadScreen"; import { AveniaKybFormScreen } from "./AveniaKybFormScreen"; import { AveniaKybHostedStep } from "./AveniaKybHostedStep"; @@ -23,7 +23,7 @@ interface AveniaKycFlowProps { } const aveniaKycMachine = createAveniaKycMachine({ - api: createAveniaKycApi(apiClient) + api: AveniaService }); const STATUS_BY_STATE: Record = { diff --git a/apps/dashboard/src/components/transfer/OnrampForm.tsx b/apps/dashboard/src/components/transfer/OnrampForm.tsx index 22f42e567..473d3db2e 100644 --- a/apps/dashboard/src/components/transfer/OnrampForm.tsx +++ b/apps/dashboard/src/components/transfer/OnrampForm.tsx @@ -109,7 +109,11 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi : null; const { data: quote, error, isFetching } = useQuote(quoteParams); const transferState = useSelector(transferActor, snapshot => snapshot); - const belongsToActiveAccount = transferState.context.meta?.accountId === account.id; + const activeOwnerProfileId = transferState.context.activeOwnerProfileId; + const belongsToActiveOwner = + !!activeOwnerProfileId && + transferState.context.meta?.ownerProfileId === activeOwnerProfileId && + transferState.context.meta.accountId === account.id; const activeTransfer = transferState.matches("CheckingQuote") || transferState.matches("CheckingBalance") || @@ -119,11 +123,11 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi transferState.matches("Starting") || transferState.matches("Tracking"); - if (transferState.matches("AwaitingPayment") && transferState.context.ramp && belongsToActiveAccount) { + if (transferState.matches("AwaitingPayment") && transferState.context.ramp && belongsToActiveOwner) { return ; } - if (activeTransfer && !belongsToActiveAccount) { + if (activeTransfer && !belongsToActiveOwner) { return (
@@ -136,7 +140,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi } function submit(values: OnrampFormValues) { - if (!quote || !quoteParams || activeTransfer) { + if (!quote || !quoteParams || !activeOwnerProfileId || activeTransfer) { return; } transferActor.send({ @@ -148,12 +152,14 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi corridorId: values.corridorId as CorridorId, direction: quote.rampType, fiatPayoutAmount: quote.outputAmount, + ownerProfileId: activeOwnerProfileId, payinNetwork: values.network, payoutCurrency: String(quote.outputCurrency), recipientEmail: "Your wallet", recipientId: "", summary: `${formatCurrencyAmount(quote.outputAmount, String(quote.outputCurrency))} ${quote.outputCurrency} to your wallet` }, + ownerProfileId: activeOwnerProfileId, quote, quoteRequest: { kind: "input", params: quoteParams }, type: "START" diff --git a/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx b/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx index 03a541fc1..f05e768a2 100644 --- a/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx +++ b/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx @@ -95,7 +95,15 @@ export function OnrampPaymentInstructions({ ramp }: { ramp: RampProcess }) { }, []); function confirmPayment() { + const ownerProfileId = transferActor.getSnapshot().context.activeOwnerProfileId; + if (!ownerProfileId || transferActor.getSnapshot().context.meta?.ownerProfileId !== ownerProfileId) { + return; + } const subscription = transferActor.subscribe(snapshot => { + if (snapshot.context.activeOwnerProfileId !== ownerProfileId) { + subscription.unsubscribe(); + return; + } if (snapshot.matches("Tracking")) { subscription.unsubscribe(); toast.success("Pay-in initiated", { description: "We’ll update your transaction as the payment settles." }); @@ -106,7 +114,7 @@ export function OnrampPaymentInstructions({ ramp }: { ramp: RampProcess }) { toast.error("Could not start pay-in", { description: snapshot.context.errorMessage ?? undefined }); } }); - transferActor.send({ type: "PAYMENT_CONFIRMED" }); + transferActor.send({ ownerProfileId, type: "PAYMENT_CONFIRMED" }); } function leavePaymentSetup() { diff --git a/apps/dashboard/src/components/transfer/TransferForm.tsx b/apps/dashboard/src/components/transfer/TransferForm.tsx index b0f9fe8d4..699822bda 100644 --- a/apps/dashboard/src/components/transfer/TransferForm.tsx +++ b/apps/dashboard/src/components/transfer/TransferForm.tsx @@ -120,6 +120,7 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI snapshot => snapshot.matches("Idle") || snapshot.matches("Done") || snapshot.matches("Failed") ); const signing = useSelector(transferActor, snapshot => snapshot.matches("SigningUserTxs")); + const activeOwnerProfileId = useSelector(transferActor, snapshot => snapshot.context.activeOwnerProfileId); const quoteParams = selected && isSendable && amountReady && token @@ -134,7 +135,7 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI const { data: quote, isFetching, error } = useQuote(quoteParams); function submitTransfer(submit: FundingSubmit) { - if (!selected || !isSendable || !quote || !quoteParams || !canStartTransfer || !pixReady) { + if (!selected || !isSendable || !quote || !quoteParams || !activeOwnerProfileId || !canStartTransfer || !pixReady) { return; } const label = recipientLabel(selected); @@ -143,6 +144,10 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI // One-shot outcome watcher: navigate when tracking begins, surface the error // when any stage fails. The actor keeps polling after this form unmounts. const subscription = transferActor.subscribe(snapshot => { + if (snapshot.context.activeOwnerProfileId !== activeOwnerProfileId) { + subscription.unsubscribe(); + return; + } if (snapshot.matches("Tracking")) { subscription.unsubscribe(); const currentMeta = snapshot.context.meta; @@ -166,12 +171,14 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI corridorId: selected.corridorId, direction: quote.rampType, fiatPayoutAmount: quote.outputAmount, + ownerProfileId: activeOwnerProfileId, payinNetwork: String(quote.network), payoutCurrency: selected.payoutCurrency, recipientEmail: label, recipientId: selected.id, summary }, + ownerProfileId: activeOwnerProfileId, quote, quoteRequest: { kind: "input", params: quoteParams }, type: "START" diff --git a/apps/dashboard/src/machines/transfer.machine.test.ts b/apps/dashboard/src/machines/transfer.machine.test.ts index ad09d4070..c4a0727ab 100644 --- a/apps/dashboard/src/machines/transfer.machine.test.ts +++ b/apps/dashboard/src/machines/transfer.machine.test.ts @@ -31,6 +31,134 @@ const ramp = { } as RampProcess; describe("transferMachine", () => { + it("blocks owner activation only during quote, balance, registration, and user signing", async () => { + let releaseQuote: (() => void) | undefined; + let releaseBalance: (() => void) | undefined; + let releaseRegistration: (() => void) | undefined; + let releaseSigning: (() => void) | undefined; + let releaseStart: (() => void) | undefined; + const sellQuote = { ...quote, rampType: RampDirection.SELL } as QuoteResponse; + const sellRamp = { ...ramp, type: RampDirection.SELL } as RampProcess; + const machine = transferMachine.provide({ + actors: { + checkTransferBalance: fromPromise( + () => new Promise(resolve => (releaseBalance = resolve)) + ), + refreshTransferQuote: fromPromise( + () => new Promise<{ quote: QuoteResponse }>(resolve => (releaseQuote = () => resolve({ quote: sellQuote }))) + ), + registerTransfer: fromPromise( + () => + new Promise<{ ramp: RampProcess; userTxs: UnsignedTx[] }>(resolve => + (releaseRegistration = () => resolve({ ramp: sellRamp, userTxs: [] })) + ) + ), + signUserTransactions: fromPromise( + () => new Promise(resolve => (releaseSigning = () => resolve(sellRamp))) + ), + startRamp: fromPromise(() => new Promise(resolve => (releaseStart = () => resolve(sellRamp)))), + trackRamp: fromPromise(async () => undefined) as never + } + }); + const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); + actor.send({ + additionalData: { walletAddress: "0x1111111111111111111111111111111111111111" }, + meta: { + accountId: "account-1", + amountIn: "100", + amountInToken: "USDC", + corridorId: "MX", + direction: RampDirection.SELL, + fiatPayoutAmount: "5", + ownerProfileId: "profile-1", + payinNetwork: "polygon", + payoutCurrency: "MXN", + recipientEmail: "recipient@example.com", + recipientId: "recipient-1", + summary: "5 MXN to recipient@example.com" + }, + ownerProfileId: "profile-1", + quote: sellQuote, + quoteRequest: { ...quoteRequest, params: { ...quoteRequest.params, direction: RampDirection.SELL } }, + type: "START" + }); + + for (const [state, release] of [ + ["CheckingQuote", () => releaseQuote?.()], + ["CheckingBalance", () => releaseBalance?.()], + ["Registering", () => releaseRegistration?.()], + ["SigningUserTxs", () => releaseSigning?.()] + ] as const) { + await waitFor(actor, snapshot => snapshot.matches(state)); + actor.send({ ownerProfileId: "profile-2", recovery: null, type: "ACTIVATE_OWNER" }); + assert.equal(actor.getSnapshot().context.activeOwnerProfileId, "profile-1"); + assert.equal(actor.getSnapshot().value, state); + release(); + } + + await waitFor(actor, snapshot => snapshot.matches("Starting")); + actor.send({ ownerProfileId: "profile-2", recovery: null, type: "ACTIVATE_OWNER" }); + assert.equal(actor.getSnapshot().context.activeOwnerProfileId, "profile-2"); + assert.equal(actor.getSnapshot().value, "Idle"); + releaseStart?.(); + actor.stop(); + }); + + it("rejects transfer and payment events from a different owner", async () => { + let startCalls = 0; + const machine = transferMachine.provide({ + actors: { + refreshTransferQuote: fromPromise(async ({ input }) => ({ quote: input.quote })), + registerTransfer: fromPromise(async () => ({ ramp, userTxs: [] as UnsignedTx[] })), + startRamp: fromPromise(async () => { + startCalls += 1; + return ramp; + }) + } + }); + const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); + + const meta = { + accountId: "account-1", + amountIn: "100", + amountInToken: "MXN", + corridorId: "MX" as const, + direction: RampDirection.BUY, + fiatPayoutAmount: "5", + ownerProfileId: "profile-1", + payinNetwork: "polygon", + payoutCurrency: "USDC", + recipientEmail: "Your wallet", + recipientId: "", + summary: "5 USDC to your wallet" + }; + actor.send({ + additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, + meta, + ownerProfileId: "profile-2", + quote, + quoteRequest, + type: "START" + }); + assert.equal(actor.getSnapshot().value, "Idle"); + + actor.send({ + additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, + meta, + ownerProfileId: "profile-1", + quote, + quoteRequest, + type: "START" + }); + await waitFor(actor, snapshot => snapshot.matches("AwaitingPayment")); + actor.send({ ownerProfileId: "profile-2", type: "PAYMENT_CONFIRMED" }); + assert.equal(actor.getSnapshot().value, "AwaitingPayment"); + assert.equal(startCalls, 0); + actor.stop(); + }); + it("waits for payment confirmation before starting the ramp", async () => { let startCalls = 0; const machine = transferMachine.provide({ @@ -45,11 +173,13 @@ describe("transferMachine", () => { } }); const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); actor.send({ additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, meta: { accountId: "account-1", + ownerProfileId: "profile-1", amountIn: "100", amountInToken: "MXN", corridorId: "MX", @@ -63,6 +193,7 @@ describe("transferMachine", () => { }, quote, quoteRequest, + ownerProfileId: "profile-1", type: "START" }); @@ -70,7 +201,7 @@ describe("transferMachine", () => { assert.equal(startCalls, 0); assert.equal(actor.getSnapshot().context.ramp?.achPaymentData?.clabe, "646180157000000004"); - actor.send({ type: "PAYMENT_CONFIRMED" }); + actor.send({ ownerProfileId: "profile-1", type: "PAYMENT_CONFIRMED" }); await waitFor(actor, snapshot => snapshot.matches("Tracking")); assert.equal(startCalls, 1); actor.stop(); @@ -93,11 +224,13 @@ describe("transferMachine", () => { } }); const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); actor.send({ additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, meta: { accountId: "account-1", + ownerProfileId: "profile-1", amountIn: "100", amountInToken: "MXN", corridorId: "MX", @@ -111,18 +244,19 @@ describe("transferMachine", () => { }, quote, quoteRequest, + ownerProfileId: "profile-1", type: "START" }); await waitFor(actor, snapshot => snapshot.matches("AwaitingPayment")); - actor.send({ type: "PAYMENT_CONFIRMED" }); + actor.send({ ownerProfileId: "profile-1", type: "PAYMENT_CONFIRMED" }); await waitFor(actor, snapshot => snapshot.matches("AwaitingPayment") && snapshot.context.errorMessage !== null); const failed = actor.getSnapshot(); assert.equal(startCalls, 1); assert.equal(failed.context.errorMessage, "network blip"); assert.equal(failed.context.ramp?.id, "ramp-buy"); - actor.send({ type: "PAYMENT_CONFIRMED" }); + actor.send({ ownerProfileId: "profile-1", type: "PAYMENT_CONFIRMED" }); await waitFor(actor, snapshot => snapshot.matches("Tracking")); assert.equal(startCalls, 2); assert.equal(actor.getSnapshot().context.errorMessage, null); @@ -137,11 +271,13 @@ describe("transferMachine", () => { } }); const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); actor.send({ additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, meta: { accountId: "account-1", + ownerProfileId: "profile-1", amountIn: "100", amountInToken: "MXN", corridorId: "MX", @@ -155,6 +291,7 @@ describe("transferMachine", () => { }, quote, quoteRequest, + ownerProfileId: "profile-1", type: "START" }); await waitFor(actor, snapshot => snapshot.matches("AwaitingPayment")); @@ -170,9 +307,62 @@ describe("transferMachine", () => { assert.deepEqual(snapshot.context.userTxs, []); assert.equal(snapshot.context.lastStatus, null); assert.equal(snapshot.context.errorMessage, null); + assert.equal(snapshot.context.activeOwnerProfileId, "profile-1"); actor.stop(); }); + it("preserves the active owner when resetting terminal states", async () => { + for (const terminalState of ["Done", "Failed"] as const) { + const machine = transferMachine.provide({ + actors: { + refreshTransferQuote: fromPromise(async ({ input }) => { + if (terminalState === "Failed") throw new Error("quote failed"); + return { quote: input.quote }; + }), + registerTransfer: fromPromise(async () => ({ ramp, userTxs: [] as UnsignedTx[] })), + startRamp: fromPromise(async () => ramp), + trackRamp: fromPromise(async () => undefined) as never + } + }); + const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); + actor.send({ + additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, + meta: { + accountId: "account-1", + amountIn: "100", + amountInToken: "MXN", + corridorId: "MX", + direction: RampDirection.BUY, + fiatPayoutAmount: "5", + ownerProfileId: "profile-1", + payinNetwork: "polygon", + payoutCurrency: "USDC", + recipientEmail: "Your wallet", + recipientId: "", + summary: "5 USDC to your wallet" + }, + ownerProfileId: "profile-1", + quote, + quoteRequest, + type: "START" + }); + + if (terminalState === "Done") { + await waitFor(actor, snapshot => snapshot.matches("AwaitingPayment")); + actor.send({ ownerProfileId: "profile-1", type: "PAYMENT_CONFIRMED" }); + await waitFor(actor, snapshot => snapshot.matches("Tracking")); + actor.send({ status: { currentPhase: "complete" } as never, type: "TERMINAL" }); + } + await waitFor(actor, snapshot => snapshot.matches(terminalState)); + actor.send({ type: "RESET" }); + + assert.equal(actor.getSnapshot().value, "Idle"); + assert.equal(actor.getSnapshot().context.activeOwnerProfileId, "profile-1"); + actor.stop(); + } + }); + it("registers with a refreshed quote", async () => { const refreshedQuote = { ...quote, @@ -193,11 +383,13 @@ describe("transferMachine", () => { } }); const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); actor.send({ additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, meta: { accountId: "account-1", + ownerProfileId: "profile-1", amountIn: "100", amountInToken: "MXN", corridorId: "MX", @@ -211,6 +403,7 @@ describe("transferMachine", () => { }, quote, quoteRequest, + ownerProfileId: "profile-1", type: "START" }); @@ -255,11 +448,13 @@ describe("transferMachine", () => { } }); const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); actor.send({ additionalData: { walletAddress: "0x1111111111111111111111111111111111111111" }, meta: { accountId: "account-1", + ownerProfileId: "profile-1", amountIn: sellQuote.inputAmount, amountInToken: "USDC", corridorId: "MX", @@ -273,6 +468,7 @@ describe("transferMachine", () => { }, quote: sellQuote, quoteRequest: sellQuoteRequest, + ownerProfileId: "profile-1", type: "START" }); diff --git a/apps/dashboard/src/machines/transfer.machine.ts b/apps/dashboard/src/machines/transfer.machine.ts index 27fdf6b06..0ca1aace1 100644 --- a/apps/dashboard/src/machines/transfer.machine.ts +++ b/apps/dashboard/src/machines/transfer.machine.ts @@ -22,11 +22,14 @@ import { /** Everything the transactions table needs, captured at submit time. */ export type TransferMeta = Omit & { + /** Effective profile that submitted the transfer. Immutable for the transfer lifetime. */ + ownerProfileId: string; /** Human summary for toasts/notifications, e.g. "1000.00 MXN to maria@…". */ summary: string; }; export interface TransferContext { + activeOwnerProfileId: string | null; quote: QuoteResponse | null; quoteRequest: TransferQuoteRequest | null; additionalData: RegisterTransferInput["additionalData"] | null; @@ -40,6 +43,7 @@ export interface TransferContext { export type TransferEvent = | { type: "START"; + ownerProfileId: string; quote: QuoteResponse; quoteRequest: TransferQuoteRequest; additionalData: RegisterTransferInput["additionalData"]; @@ -47,7 +51,8 @@ export type TransferEvent = } | { type: "STATUS_UPDATE"; status: GetRampStatusResponse } | { type: "TERMINAL"; status: GetRampStatusResponse } - | { type: "PAYMENT_CONFIRMED" } + | { type: "PAYMENT_CONFIRMED"; ownerProfileId: string } + | { type: "ACTIVATE_OWNER"; ownerProfileId: string; recovery: TransferContext | null } | { type: "RESET" }; export type TransferEmitted = @@ -56,6 +61,7 @@ export type TransferEmitted = | { type: "TRANSFER_FAILED"; message: string }; const initialContext: TransferContext = { + activeOwnerProfileId: null, additionalData: null, errorMessage: null, lastStatus: null, @@ -116,7 +122,13 @@ export const transferMachine = setup({ ) }, guards: { - isOnramp: ({ context }) => context.quote?.rampType === RampDirection.BUY + isOnramp: ({ context }) => context.quote?.rampType === RampDirection.BUY, + isOwnerEvent: ({ context, event }) => + "ownerProfileId" in event && + event.ownerProfileId === context.activeOwnerProfileId && + (event.type !== "START" || event.meta.ownerProfileId === event.ownerProfileId) && + (!context.meta || context.meta.ownerProfileId === event.ownerProfileId), + isRecoveryActivation: ({ event }) => event.type === "ACTIVATE_OWNER" && event.recovery !== null }, types: { context: {} as TransferContext, @@ -128,12 +140,30 @@ export const transferMachine = setup({ id: "transfer", initial: "Idle", on: { - RESET: { actions: assign(() => initialContext), target: ".Idle" } + ACTIVATE_OWNER: [ + { + actions: assign(({ event }) => ({ ...event.recovery, activeOwnerProfileId: event.ownerProfileId })), + guard: "isRecoveryActivation", + target: ".AwaitingPayment" + }, + { + actions: assign(({ event }) => ({ ...initialContext, activeOwnerProfileId: event.ownerProfileId })), + target: ".Idle" + } + ], + RESET: { + actions: assign(({ context }) => ({ ...initialContext, activeOwnerProfileId: context.activeOwnerProfileId })), + target: ".Idle" + } }, states: { AwaitingPayment: { on: { - PAYMENT_CONFIRMED: { actions: assign(() => ({ errorMessage: null })), target: "Starting" } + PAYMENT_CONFIRMED: { + actions: assign(() => ({ errorMessage: null })), + guard: "isOwnerEvent", + target: "Starting" + } } }, CheckingBalance: { @@ -153,7 +183,8 @@ export const transferMachine = setup({ target: "Failed" }, src: "checkTransferBalance" - } + }, + on: { ACTIVATE_OWNER: {} } }, CheckingQuote: { invoke: { @@ -178,34 +209,37 @@ export const transferMachine = setup({ target: "Failed" }, src: "refreshTransferQuote" - } + }, + on: { ACTIVATE_OWNER: {} } }, Done: { on: { - RESET: { actions: assign(() => initialContext), target: "Idle" }, START: { actions: assign(({ event }) => ({ ...initialContext, + activeOwnerProfileId: event.ownerProfileId, additionalData: event.additionalData, meta: event.meta, quote: event.quote, quoteRequest: event.quoteRequest })), + guard: "isOwnerEvent", target: "CheckingQuote" } } }, Failed: { on: { - RESET: { actions: assign(() => initialContext), target: "Idle" }, START: { actions: assign(({ event }) => ({ ...initialContext, + activeOwnerProfileId: event.ownerProfileId, additionalData: event.additionalData, meta: event.meta, quote: event.quote, quoteRequest: event.quoteRequest })), + guard: "isOwnerEvent", target: "CheckingQuote" } } @@ -215,11 +249,13 @@ export const transferMachine = setup({ START: { actions: assign(({ event }) => ({ ...initialContext, + activeOwnerProfileId: event.ownerProfileId, additionalData: event.additionalData, meta: event.meta, quote: event.quote, quoteRequest: event.quoteRequest })), + guard: "isOwnerEvent", target: "CheckingQuote" } } @@ -251,7 +287,8 @@ export const transferMachine = setup({ target: "Failed" }, src: "registerTransfer" - } + }, + on: { ACTIVATE_OWNER: {} } }, SigningUserTxs: { invoke: { @@ -273,7 +310,8 @@ export const transferMachine = setup({ target: "Failed" }, src: "signUserTransactions" - } + }, + on: { ACTIVATE_OWNER: {} } }, Starting: { invoke: { diff --git a/apps/dashboard/src/machines/transferActor.test.ts b/apps/dashboard/src/machines/transferActor.test.ts new file mode 100644 index 000000000..ee0f9a1ca --- /dev/null +++ b/apps/dashboard/src/machines/transferActor.test.ts @@ -0,0 +1,170 @@ +import { EvmToken, Networks, type QuoteResponse, RampDirection, type RampProcess, type UnsignedTx } from "@vortexfi/shared"; +import { mock } from "bun:test"; +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import { createActor, fromPromise, waitFor } from "xstate"; +import type { TransferQuoteRequest } from "./transfer.actors"; +import { transferMachine } from "./transfer.machine"; + +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const values = new Map(); +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } satisfies Storage +}); + +mock.module("@/hooks/useTransactions", () => ({ TRANSACTIONS_QUERY_KEY: "transactions" })); +mock.module("@/lib/notify", () => ({ notifyTransferCompleted: () => undefined })); +mock.module("@/lib/queryClient", () => ({ queryClient: { invalidateQueries: () => undefined } })); + +const quote = { id: "quote-buy", rampType: RampDirection.BUY } as QuoteResponse; +const quoteRequest: TransferQuoteRequest = { + kind: "input", + params: { + corridorId: "MX", + direction: RampDirection.BUY, + inputAmount: "100", + network: Networks.Polygon, + token: EvmToken.USDC + } +}; +const ramp = { id: "ramp-buy", inputCurrency: "MXN", type: RampDirection.BUY } as RampProcess; + +async function recoverySnapshot(ownerProfileId: string, accountId: string): Promise { + const machine = transferMachine.provide({ + actors: { + refreshTransferQuote: fromPromise(async ({ input }) => ({ quote: input.quote })), + registerTransfer: fromPromise(async () => ({ ramp: { ...ramp, id: `ramp-${ownerProfileId}` }, userTxs: [] as UnsignedTx[] })) + } + }); + const actor = createActor(machine).start(); + actor.send({ ownerProfileId, recovery: null, type: "ACTIVATE_OWNER" }); + actor.send({ + additionalData: { destinationAddress: "0x1111111111111111111111111111111111111111" }, + meta: { + accountId, + amountIn: "100", + amountInToken: "MXN", + corridorId: "MX", + direction: RampDirection.BUY, + fiatPayoutAmount: "5", + ownerProfileId, + payinNetwork: "polygon", + payoutCurrency: "USDC", + recipientEmail: "Your wallet", + recipientId: "", + summary: "5 USDC to your wallet" + }, + ownerProfileId, + quote, + quoteRequest, + type: "START" + }); + await waitFor(actor, snapshot => snapshot.matches("AwaitingPayment")); + const context = actor.getSnapshot().context; + const persisted = JSON.stringify({ meta: context.meta, ownerProfileId, quote: context.quote, ramp: context.ramp, version: 1 }); + actor.stop(); + return persisted; +} + +values.set("vortex-dashboard-transfer-state", "unowned legacy state"); +const { activateTransferOwner, canChangeEffectiveIdentity, clearAllTransferRecovery, resetTransferState, transferActor } = + await import("./transferActor"); + +after(() => { + transferActor.stop(); + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } +}); + +describe("transferActor owner recovery", () => { + it("rejects legacy state and restores only the selected owner's snapshot", async () => { + assert.equal(values.has("vortex-dashboard-transfer-state"), false); + const ownerOneKey = "vortex-dashboard-transfer-state:owner:profile-1"; + const ownerTwoKey = "vortex-dashboard-transfer-state:owner:profile-2"; + values.set(ownerOneKey, await recoverySnapshot("profile-1", "account-1")); + values.set(ownerTwoKey, await recoverySnapshot("profile-2", "account-2")); + + assert.equal(activateTransferOwner("profile-1"), true); + assert.equal(transferActor.getSnapshot().context.meta?.ownerProfileId, "profile-1"); + assert.equal(transferActor.getSnapshot().context.ramp?.id, "ramp-profile-1"); + const ownerOneRecovery = values.get(ownerOneKey); + + assert.equal(activateTransferOwner("profile-2"), true); + assert.equal(values.get(ownerOneKey), ownerOneRecovery); + assert.equal(transferActor.getSnapshot().context.meta?.ownerProfileId, "profile-2"); + assert.equal(transferActor.getSnapshot().context.ramp?.id, "ramp-profile-2"); + + resetTransferState(); + assert.equal(values.has(ownerTwoKey), false); + assert.equal(values.has(ownerOneKey), true); + clearAllTransferRecovery(); + assert.equal(values.has(ownerOneKey), false); + }); + + it("removes mismatched snapshots instead of migrating them to an owner", async () => { + const key = "vortex-dashboard-transfer-state:owner:profile-3"; + values.set(key, await recoverySnapshot("profile-else", "account-else")); + + assert.equal(activateTransferOwner("profile-3"), true); + assert.equal(values.has(key), false); + assert.equal(transferActor.getSnapshot().value, "Idle"); + assert.equal(transferActor.getSnapshot().context.activeOwnerProfileId, "profile-3"); + }); + + it("removes truncated and unversioned recovery instead of mixing it with the active owner", async () => { + const key = "vortex-dashboard-transfer-state:owner:profile-4"; + const complete = JSON.parse(await recoverySnapshot("profile-4", "account-4")); + const corruptions = [ + { ...complete, version: undefined }, + { ...complete, ramp: undefined }, + { ...complete, meta: { ...complete.meta, ownerProfileId: "profile-else" } } + ]; + + for (const corruption of corruptions) { + assert.equal(activateTransferOwner("profile-existing"), true); + values.set(key, JSON.stringify(corruption)); + + assert.equal(activateTransferOwner("profile-4"), true); + assert.equal(values.has(key), false); + assert.equal(transferActor.getSnapshot().value, "Idle"); + assert.equal(transferActor.getSnapshot().context.activeOwnerProfileId, "profile-4"); + assert.equal(transferActor.getSnapshot().context.meta, null); + assert.equal(transferActor.getSnapshot().context.ramp, null); + } + + values.set(key, "not-json"); + assert.equal(activateTransferOwner("profile-existing"), true); + assert.equal(activateTransferOwner("profile-4"), true); + assert.equal(values.has(key), false); + assert.equal(transferActor.getSnapshot().context.meta, null); + }); + + it("preserves the active owner when reset clears its recovery", async () => { + const key = "vortex-dashboard-transfer-state:owner:profile-5"; + values.set(key, await recoverySnapshot("profile-5", "account-5")); + assert.equal(activateTransferOwner("profile-5"), true); + + resetTransferState(); + + assert.equal(values.has(key), false); + assert.equal(transferActor.getSnapshot().value, "Idle"); + assert.equal(transferActor.getSnapshot().context.activeOwnerProfileId, "profile-5"); + }); + + it("allows identity changes while idle", () => { + assert.equal(canChangeEffectiveIdentity(), true); + }); +}); diff --git a/apps/dashboard/src/machines/transferActor.ts b/apps/dashboard/src/machines/transferActor.ts index 8b3d5f52e..5e2f4ad2f 100644 --- a/apps/dashboard/src/machines/transferActor.ts +++ b/apps/dashboard/src/machines/transferActor.ts @@ -1,43 +1,86 @@ -import { RampDirection } from "@vortexfi/shared"; -import { type Actor, createActor, type Snapshot } from "xstate"; +import { type QuoteResponse, RampDirection, type RampProcess } from "@vortexfi/shared"; +import { type Actor, createActor } from "xstate"; import { TRANSACTIONS_QUERY_KEY } from "@/hooks/useTransactions"; import { notifyTransferCompleted } from "@/lib/notify"; import { queryClient } from "@/lib/queryClient"; -import { transferMachine } from "./transfer.machine"; +import { type TransferContext, type TransferMeta, transferMachine } from "./transfer.machine"; /** * App-lifetime transfer actor: the form only sends START and navigates away — polling * keeps running here after the form unmounts. Transaction rows come from the backend ramp * history, so each status change just invalidates that query to pull the latest. */ -const TRANSFER_STATE_STORAGE_KEY = "vortex-dashboard-transfer-state"; +const LEGACY_TRANSFER_STATE_STORAGE_KEY = "vortex-dashboard-transfer-state"; +const TRANSFER_STATE_STORAGE_PREFIX = `${LEGACY_TRANSFER_STATE_STORAGE_KEY}:owner:`; +const TRANSFER_RECOVERY_VERSION = 1; -function readPersistedTransferState(): Snapshot | undefined { +interface PersistedTransferRecovery { + meta: TransferMeta; + ownerProfileId: string; + quote: QuoteResponse; + ramp: RampProcess; + version: typeof TRANSFER_RECOVERY_VERSION; +} + +function storageKey(ownerProfileId: string): string { + return `${TRANSFER_STATE_STORAGE_PREFIX}${encodeURIComponent(ownerProfileId)}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function recoveryContext(value: Record, ownerProfileId: string): TransferContext | undefined { + const quote = value.quote; + const meta = value.meta; + const ramp = value.ramp; + return isRecord(quote) && + quote.rampType === RampDirection.BUY && + isRecord(meta) && + meta.ownerProfileId === ownerProfileId && + meta.direction === RampDirection.BUY && + isRecord(ramp) && + ramp.type === RampDirection.BUY && + typeof ramp.id === "string" + ? { + activeOwnerProfileId: ownerProfileId, + additionalData: null, + errorMessage: null, + lastStatus: null, + meta: meta as unknown as TransferMeta, + quote: quote as unknown as QuoteResponse, + quoteRequest: null, + ramp: ramp as unknown as RampProcess, + userTxs: [] + } + : undefined; +} + +function readPersistedTransferState(ownerProfileId: string): TransferContext | undefined { + const key = storageKey(ownerProfileId); try { - const raw = localStorage.getItem(TRANSFER_STATE_STORAGE_KEY); + const raw = localStorage.getItem(key); if (!raw) { return undefined; } - // Only AwaitingPayment is safe to resume: restoring an in-flight promise state - // (Registering/SigningUserTxs/Starting) would re-run its side effect on reload. - const parsed = JSON.parse(raw); - return parsed?.status === "active" && parsed?.value === "AwaitingPayment" ? parsed : undefined; + const parsed: unknown = JSON.parse(raw); + if (isRecord(parsed) && parsed.version === TRANSFER_RECOVERY_VERSION && parsed.ownerProfileId === ownerProfileId) { + const context = recoveryContext(parsed, ownerProfileId); + if (context) { + return context; + } + } + localStorage.removeItem(key); + return undefined; } catch { - localStorage.removeItem(TRANSFER_STATE_STORAGE_KEY); + localStorage.removeItem(key); return undefined; } } function startTransferActor(): Actor { - const snapshot = readPersistedTransferState(); - if (snapshot) { - try { - return createActor(transferMachine, { snapshot }).start(); - } catch { - // A snapshot from an older machine shape must not brick the app — drop it. - localStorage.removeItem(TRANSFER_STATE_STORAGE_KEY); - } - } + // Ownerless legacy state cannot be attributed safely and must never be adopted. + localStorage.removeItem(LEGACY_TRANSFER_STATE_STORAGE_KEY); return createActor(transferMachine).start(); } @@ -45,9 +88,53 @@ export const transferActor = startTransferActor(); const notifiedRampIds = new Set(); -export function resetTransferState() { +export function canChangeEffectiveIdentity(): boolean { + const snapshot = transferActor.getSnapshot(); + return !( + snapshot.matches("CheckingQuote") || + snapshot.matches("CheckingBalance") || + snapshot.matches("Registering") || + snapshot.matches("SigningUserTxs") + ); +} + +export function activateTransferOwner(ownerProfileId: string): boolean { + if (!canChangeEffectiveIdentity()) { + return false; + } + + const current = transferActor.getSnapshot(); + if (current.context.activeOwnerProfileId === ownerProfileId) { + return true; + } + + const persisted = readPersistedTransferState(ownerProfileId); + transferActor.send({ + ownerProfileId, + recovery: persisted ?? null, + type: "ACTIVATE_OWNER" + }); + return true; +} + +export function clearAllTransferRecovery(): void { notifiedRampIds.clear(); - localStorage.removeItem(TRANSFER_STATE_STORAGE_KEY); + localStorage.removeItem(LEGACY_TRANSFER_STATE_STORAGE_KEY); + for (let index = localStorage.length - 1; index >= 0; index -= 1) { + const key = localStorage.key(index); + if (key?.startsWith(TRANSFER_STATE_STORAGE_PREFIX)) { + localStorage.removeItem(key); + } + } + transferActor.send({ type: "RESET" }); +} + +export function resetTransferState(): void { + const ownerProfileId = transferActor.getSnapshot().context.activeOwnerProfileId; + notifiedRampIds.clear(); + if (ownerProfileId) { + localStorage.removeItem(storageKey(ownerProfileId)); + } transferActor.send({ type: "RESET" }); } @@ -70,12 +157,27 @@ transferActor.on("STATUS_CHANGED", event => { transferActor.subscribe(snapshot => { try { if (snapshot.matches("AwaitingPayment")) { - localStorage.setItem(TRANSFER_STATE_STORAGE_KEY, JSON.stringify(transferActor.getPersistedSnapshot())); + const ownerProfileId = snapshot.context.activeOwnerProfileId; + const { meta, quote, ramp } = snapshot.context; + if (!ownerProfileId || meta?.ownerProfileId !== ownerProfileId || !quote || !ramp) { + return; + } + const recovery: PersistedTransferRecovery = { + meta, + ownerProfileId, + quote, + ramp, + version: TRANSFER_RECOVERY_VERSION + }; + localStorage.setItem(storageKey(ownerProfileId), JSON.stringify(recovery)); refreshTransactions(); } else if (!snapshot.matches("Starting")) { // Keep the AwaitingPayment snapshot through Starting: the user may already have // paid, and a reload must bring the instructions back so start can be retried. - localStorage.removeItem(TRANSFER_STATE_STORAGE_KEY); + const ownerProfileId = snapshot.context.activeOwnerProfileId; + if (ownerProfileId) { + localStorage.removeItem(storageKey(ownerProfileId)); + } } } catch { // Persistence is a non-critical reload recovery aid. diff --git a/apps/dashboard/src/router.tsx b/apps/dashboard/src/router.tsx index 320bd28db..e87c4cc7f 100644 --- a/apps/dashboard/src/router.tsx +++ b/apps/dashboard/src/router.tsx @@ -1,13 +1,23 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; +import { setManagedProfileAccessDeniedHandler } from "./services/api/api-client"; +import { AuthService } from "./services/auth"; +import { clearManagedProfileSelection } from "./stores/managed-profile.store"; export function getRouter() { - return createTanStackRouter({ + const router = createTanStackRouter({ defaultPreload: "intent", defaultPreloadStaleTime: 0, routeTree, scrollRestoration: true }); + setManagedProfileAccessDeniedHandler(async selectionSnapshot => { + if (!AuthService.isManagedProfileSelectionSnapshotCurrent(selectionSnapshot)) return false; + await router.navigate({ replace: true, to: "/managed-profiles" }); + clearManagedProfileSelection(selectionSnapshot); + return true; + }); + return router; } declare module "@tanstack/react-router" { diff --git a/apps/dashboard/src/routes/_app/transactions.tsx b/apps/dashboard/src/routes/_app/transactions.tsx index 7c8aea2fd..7ac7433ae 100644 --- a/apps/dashboard/src/routes/_app/transactions.tsx +++ b/apps/dashboard/src/routes/_app/transactions.tsx @@ -21,7 +21,11 @@ function TransactionsPage() { const { transactions } = useTransactions(account); const { recipients } = useRecipients(account); const resumableRamp = useSelector(transferActor, snapshot => - 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/services/api/alfredpay.service.ts b/apps/dashboard/src/services/api/alfredpay.service.ts index 120ecd895..92a6012e9 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: AlfredpayAddFiatAccountRequest): 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 index 820749eaf..74212794e 100644 --- a/apps/dashboard/src/services/api/api-client.test.ts +++ b/apps/dashboard/src/services/api/api-client.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { after, beforeEach, describe, it } from "node:test"; import { AuthService } from "@/services/auth"; -import { apiClient, isApiError } from "./api-client"; +import { apiClient, isApiError, setManagedProfileAccessDeniedHandler } from "./api-client"; const originalFetch = globalThis.fetch; -const originalGetImpersonationSession = AuthService.getImpersonationSession; +const originalGetAcceptedImpersonationSessionSnapshot = AuthService.getAcceptedImpersonationSessionSnapshot; const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const values = new Map(); @@ -26,12 +26,15 @@ Object.defineProperty(globalThis, "window", { beforeEach(() => { values.clear(); - AuthService.getImpersonationSession = originalGetImpersonationSession; + AuthService.initializeAcceptedIdentitySnapshots(); + AuthService.getAcceptedImpersonationSessionSnapshot = originalGetAcceptedImpersonationSessionSnapshot; + setManagedProfileAccessDeniedHandler(undefined); }); after(() => { globalThis.fetch = originalFetch; - AuthService.getImpersonationSession = originalGetImpersonationSession; + AuthService.getAcceptedImpersonationSessionSnapshot = originalGetAcceptedImpersonationSessionSnapshot; + setManagedProfileAccessDeniedHandler(undefined); if (originalLocalStorage) { Object.defineProperty(globalThis, "localStorage", originalLocalStorage); } else { @@ -56,6 +59,7 @@ describe("apiFetch while impersonating", () => { expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), sessionId: "session-1", targetEmail: "customer@example.com", + targetProfileId: "customer-1", token: "vtx_imp_abc123" }); }); @@ -72,13 +76,38 @@ describe("apiFetch while impersonating", () => { 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 activeSession = AuthService.getImpersonationSession(); + const activeSnapshot = AuthService.getAcceptedImpersonationSessionSnapshot(); let snapshotReads = 0; - AuthService.getImpersonationSession = (() => { + AuthService.getAcceptedImpersonationSessionSnapshot = (() => { snapshotReads += 1; - return snapshotReads === 1 ? activeSession : null; - }) as typeof AuthService.getImpersonationSession; + return snapshotReads === 1 ? activeSnapshot : null; + }) as typeof AuthService.getAcceptedImpersonationSessionSnapshot; let authorization: string | undefined; globalThis.fetch = (async (_input, init) => { authorization = (init?.headers as Record).Authorization; @@ -107,6 +136,24 @@ describe("apiFetch while impersonating", () => { // 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", () => { @@ -145,4 +192,141 @@ describe("apiFetch without impersonation", () => { 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 ed550ffcf..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,30 +74,40 @@ 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 impersonation = AuthService.getImpersonationSession(); - const initialTokens = AuthService.getTokens(); - // Capture one coherent identity snapshot. Reading impersonation again here could pair the - // operator's token with impersonation-specific 401 handling during a cross-tab transition. - const initialAccessToken = impersonation?.token ?? initialTokens?.accessToken; let response = await doFetch(initialAccessToken); if (response.status === 401) { 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(); + AuthService.clearImpersonationSession(impersonationSnapshot ?? undefined); throw new ApiError(401, {}, "Your impersonation session has expired. You're back in your own session."); } if (initialTokens?.accessToken) { @@ -84,6 +121,7 @@ async function apiFetch( 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; }; @@ -92,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 @@ -108,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 f4408d9ed..9c767c41e 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: AveniaKYCDataUploadRequest): 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/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 5f3433bee..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", @@ -236,6 +237,7 @@ describe("AuthService impersonation session", () => { expiresAt: "2026-01-01T00:00:00.000Z", sessionId: "session-1", targetEmail: "customer@example.com", + targetProfileId: "customer-1", token: "vtx_imp_abc123", }); @@ -258,7 +260,7 @@ describe("AuthService impersonation session", () => { assert.equal(AuthService.getImpersonationSession(), null); }); - it("reads a complete legacy session and removes legacy keys on the next write", () => { + 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( @@ -270,17 +272,13 @@ describe("AuthService impersonation session", () => { "legacy@example.com", ); - assert.deepEqual(AuthService.getImpersonationSession(), { - expiresAt: "2026-01-01T00:00:00.000Z", - sessionId: "legacy-session", - targetEmail: "legacy@example.com", - token: "vtx_imp_legacy", - }); + 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); @@ -305,6 +303,7 @@ describe("AuthService impersonation session", () => { expiresAt: "2026-01-01T00:00:00.000Z", sessionId: "session-1", targetEmail: "customer@example.com", + targetProfileId: "customer-1", token: "vtx_imp_abc123", }); @@ -313,6 +312,7 @@ describe("AuthService impersonation session", () => { expiresAt: "2026-01-01T00:00:00.000Z", sessionId: "session-1", targetEmail: "customer@example.com", + targetProfileId: "customer-1", token: "vtx_imp_abc123", }); }); @@ -322,6 +322,7 @@ describe("AuthService impersonation session", () => { expiresAt: "2026-01-01T00:00:00.000Z", sessionId: "session-1", targetEmail: "customer@example.com", + targetProfileId: "customer-1", token: "vtx_imp_abc123", }); @@ -343,6 +344,7 @@ describe("AuthService impersonation session", () => { expiresAt: "2026-01-01T00:00:00.000Z", sessionId: "session-1", targetEmail: "customer@example.com", + targetProfileId: "customer-1", token: "vtx_imp_abc123", }); @@ -352,3 +354,60 @@ describe("AuthService impersonation session", () => { 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 cb9fda4e6..ba14caea2 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -13,6 +13,25 @@ export interface ImpersonationSession { 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. } /** @@ -26,12 +45,21 @@ export class AuthService { 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; @@ -48,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); @@ -69,22 +116,46 @@ export class AuthService { } static storeImpersonationSession(session: ImpersonationSession): void { - const previousSnapshot = this.getImpersonationSessionSnapshot(); + 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.getImpersonationSessionSnapshot()); + 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. */ @@ -110,7 +181,8 @@ export class AuthService { typeof parsed.sessionId !== "string" || typeof parsed.expiresAt !== "string" || !Number.isFinite(Date.parse(parsed.expiresAt)) || - typeof parsed.targetEmail !== "string" + typeof parsed.targetEmail !== "string" || + typeof parsed.targetProfileId !== "string" ) { return null; } @@ -118,6 +190,7 @@ export class AuthService { expiresAt: parsed.expiresAt, sessionId: parsed.sessionId, targetEmail: parsed.targetEmail, + targetProfileId: parsed.targetProfileId, token: parsed.token }; } catch { @@ -144,11 +217,97 @@ export class AuthService { }; } - static clearImpersonationSession(): void { - const previousSnapshot = this.getImpersonationSessionSnapshot(); + 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. */ @@ -160,6 +319,14 @@ export class AuthService { 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) { @@ -256,6 +423,7 @@ export class AuthService { } static signOut(): void { + this.clearManagedProfileSelection(); this.clearImpersonationSession(); this.clearTokens(); } @@ -278,9 +446,14 @@ export class AuthService { } private static notifyImpersonationListeners(previousSnapshot: string | null): void { - if (this.getImpersonationSessionSnapshot() === previousSnapshot) return; + 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 04d860759..f720c8f08 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"; @@ -48,32 +49,46 @@ function userFromTokens(tokens: AuthTokens): AuthUser { export function clearAccountState(): void { queryClient.clear(); useNotificationsStore.getState().clear(); - resetTransferState(); 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, @@ -81,6 +96,7 @@ export const useAuthStore = create()(set => ({ 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 index 4a727f326..e4fc000bb 100644 --- a/apps/dashboard/src/stores/impersonation.store.test.ts +++ b/apps/dashboard/src/stores/impersonation.store.test.ts @@ -1,13 +1,10 @@ -import { mock } from "bun:test"; import assert from "node:assert/strict"; import { after, beforeEach, describe, it } from "node:test"; import type { ImpersonationSession } from "@/services/auth"; const originalFetch = globalThis.fetch; const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); -const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const values = new Map(); -const storageListeners = new Set<(event: { key: string | null }) => void>(); Object.defineProperty(globalThis, "localStorage", { configurable: true, @@ -18,62 +15,59 @@ Object.defineProperty(globalThis, "localStorage", { } }); -Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - addEventListener: (type: string, listener: (event: { key: string | null }) => void) => { - if (type === "storage") storageListeners.add(listener); - }, - location: { origin: "http://localhost" }, - removeEventListener: (type: string, listener: (event: { key: string | null }) => void) => { - if (type === "storage") storageListeners.delete(listener); - } - } -}); - let accountStateClears = 0; -mock.module("@/stores/auth.store", () => ({ - clearAccountState: () => { - accountStateClears += 1; - } -})); +let identityChangeAllowed = true; +let activatedOwner: string | null = null; const { AuthService } = await import("@/services/auth"); -const { enterImpersonation, exitImpersonation } = await import("./impersonation.store"); +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 { - for (const listener of storageListeners) listener({ key }); + if (key === null || key === AuthService.IMPERSONATION_STORAGE_KEY) applyStoredImpersonationForTests(); } beforeEach(() => { - AuthService.clearImpersonationSession(); + 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"); } - if (originalWindow) { - Object.defineProperty(globalThis, "window", originalWindow); - } else { - Reflect.deleteProperty(globalThis, "window"); - } }); describe("impersonation session transitions", () => { @@ -84,6 +78,16 @@ describe("impersonation session transitions", () => { 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 () => { @@ -154,6 +158,20 @@ describe("impersonation session transitions", () => { 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; diff --git a/apps/dashboard/src/stores/impersonation.store.ts b/apps/dashboard/src/stores/impersonation.store.ts index dddf1f741..bce05a54e 100644 --- a/apps/dashboard/src/stores/impersonation.store.ts +++ b/apps/dashboard/src/stores/impersonation.store.ts @@ -1,22 +1,31 @@ import { useSyncExternalStore } from "react"; import { AdminConsoleService } from "@/services/api/admin-console.service"; import { AuthService, type ImpersonationSession } from "@/services/auth"; -import { clearAccountState } from "./auth.store"; -let currentSnapshot = AuthService.getImpersonationSessionSnapshot(); +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; - clearAccountState(); + 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. @@ -38,15 +47,19 @@ export function useImpersonationSession(): ImpersonationSession | null { } /** Entering a new identity synchronously clears every account-scoped client cache. */ -export function enterImpersonation(session: ImpersonationSession): void { +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(): void { +export function exitImpersonation(): boolean { + if (!AuthService.canChangeEffectiveIdentity()) return false; const session = AuthService.getImpersonationSession(); if (session) { void AdminConsoleService.endImpersonation(session.sessionId).catch(() => { @@ -54,4 +67,5 @@ export function exitImpersonation(): void { }); } 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..7e7f0667a --- /dev/null +++ b/apps/dashboard/src/stores/managed-profile.store.test.ts @@ -0,0 +1,185 @@ +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 { 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("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); +} From f9151a5abc779bcd88cc9a9a3e93492ad1be4bd0 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 13 Aug 2026 13:45:03 -0300 Subject: [PATCH 18/29] feat(dashboard): add managed profile selection --- apps/dashboard/e2e/managed-profiles.spec.ts | 91 +++++++++++++++ apps/dashboard/e2e/support/mockBackend.ts | 26 +++++ .../src/components/layout/AccountSwitcher.tsx | 4 +- .../src/components/layout/AppSidebar.tsx | 44 ++++++- .../components/layout/ConnectWalletButton.tsx | 5 +- .../layout/ManagedProfileBanner.tsx | 44 +++++++ .../src/components/layout/Topbar.tsx | 8 +- .../ActForManagedProfileDialog.tsx | 60 ++++++++++ .../managed-profiles/ManagedProfilesList.tsx | 109 ++++++++++++++++++ .../managed-profile-ui.test.ts | 42 +++++++ .../managed-profiles/managed-profile-ui.ts | 17 +++ apps/dashboard/src/hooks/useActiveAccount.ts | 10 +- .../src/hooks/useManagedProfiles.test.ts | 25 ++++ .../dashboard/src/hooks/useManagedProfiles.ts | 23 ++++ apps/dashboard/src/routeTree.gen.ts | 21 ++++ apps/dashboard/src/routes/_app.tsx | 17 ++- apps/dashboard/src/routes/_app/admin.tsx | 2 +- .../src/routes/_app/managed-profiles.tsx | 80 +++++++++++++ apps/dashboard/src/routes/invite.$token.tsx | 6 +- .../src/routes/monerium.callback.tsx | 14 ++- .../services/api/managed-profiles.service.ts | 38 ++++++ 21 files changed, 665 insertions(+), 21 deletions(-) create mode 100644 apps/dashboard/e2e/managed-profiles.spec.ts create mode 100644 apps/dashboard/src/components/layout/ManagedProfileBanner.tsx create mode 100644 apps/dashboard/src/components/managed-profiles/ActForManagedProfileDialog.tsx create mode 100644 apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx create mode 100644 apps/dashboard/src/components/managed-profiles/managed-profile-ui.test.ts create mode 100644 apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts create mode 100644 apps/dashboard/src/hooks/useManagedProfiles.test.ts create mode 100644 apps/dashboard/src/hooks/useManagedProfiles.ts create mode 100644 apps/dashboard/src/routes/_app/managed-profiles.tsx create mode 100644 apps/dashboard/src/services/api/managed-profiles.service.ts diff --git a/apps/dashboard/e2e/managed-profiles.spec.ts b/apps/dashboard/e2e/managed-profiles.spec.ts new file mode 100644 index 000000000..b62f88693 --- /dev/null +++ b/apps/dashboard/e2e/managed-profiles.spec.ts @@ -0,0 +1,91 @@ +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], 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 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); + + 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("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/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 0c0fe090c..1ff53a9ff 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/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 6efb96fe1..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, UserCog, Users } from "lucide-react"; +import { + ArrowLeftRight, + Calculator, + Gauge, + KeyRound, + RefreshCw, + Send, + Settings, + ShieldCheck, + UserCog, + Users, + UsersRound +} from "lucide-react"; import { Sidebar, SidebarContent, @@ -12,7 +24,9 @@ import { 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 = [ @@ -27,14 +41,27 @@ const NAV_ITEMS = [ ] 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 navItems = isAdmin && !isImpersonating ? [...NAV_ITEMS, ADMIN_NAV_ITEM] : NAV_ITEMS; + 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 ( @@ -57,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/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/hooks/useActiveAccount.ts b/apps/dashboard/src/hooks/useActiveAccount.ts index 1f3a960bb..ed1fc199b 100644 --- a/apps/dashboard/src/hooks/useActiveAccount.ts +++ b/apps/dashboard/src/hooks/useActiveAccount.ts @@ -3,6 +3,7 @@ import type { AccountType, CorridorId, Onboarding, OnboardingStatus, SenderAccou import type { OnboardingEntityDto, OnboardingState } from "@/services/api/onboarding.service"; import { corridorFromProviderAccount } from "@/services/api/recipient.mappers"; import { useAuthStore } from "@/stores/auth.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { useOnboardingStatusQuery } from "./useApprovedCorridors"; const STATE_TO_STATUS: Record = { @@ -57,6 +58,7 @@ function deriveOnboardings(entity: OnboardingEntityDto, type: AccountType): Part */ export function useActiveAccount(): SenderAccount | undefined { const user = useAuthStore(state => state.user); + const managedProfile = useManagedProfileSelection(); const { data } = useOnboardingStatusQuery(!!user); return useMemo(() => { @@ -72,11 +74,11 @@ export function useActiveAccount(): SenderAccount | undefined { const selectedCorridors = Object.keys(onboardings) as CorridorId[]; return { id: entity.id, - identifier: user.email, - name: user.name, + identifier: managedProfile ? managedProfile.externalSubjectId : user.email, + name: managedProfile ? managedProfile.targetEmail || managedProfile.externalSubjectId : user.name, onboardings, selectedCorridors, - type + type: managedProfile ? (managedProfile.customerType === "business" ? "company" : "individual") : type }; - }, [user, data]); + }, [user, data, managedProfile]); } diff --git a/apps/dashboard/src/hooks/useManagedProfiles.test.ts b/apps/dashboard/src/hooks/useManagedProfiles.test.ts new file mode 100644 index 000000000..f3d616413 --- /dev/null +++ b/apps/dashboard/src/hooks/useManagedProfiles.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { ApiError } from "@/services/api/api-client"; +import { isManagedProfilesAccessDenied, shouldRetryManagedProfilesQuery } from "./useManagedProfiles"; + +describe("managed profile capability detection", () => { + it("recognizes only the exact access-denied response as non-manager capability", () => { + const denied = new ApiError(403, { code: "MANAGED_PROFILE_ACCESS_DENIED" }, "Denied"); + const transientForbidden = new ApiError(403, { code: "UPSTREAM_UNAVAILABLE" }, "Unavailable"); + + assert.equal(isManagedProfilesAccessDenied(denied), true); + assert.equal(isManagedProfilesAccessDenied(transientForbidden), false); + assert.equal(isManagedProfilesAccessDenied(new Error("Network failure")), false); + }); + + it("retries transient failures but not definitive access denial", () => { + const denied = new ApiError(403, { code: "MANAGED_PROFILE_ACCESS_DENIED" }, "Denied"); + const serverError = new ApiError(503, {}, "Unavailable"); + + assert.equal(shouldRetryManagedProfilesQuery(0, denied), false); + assert.equal(shouldRetryManagedProfilesQuery(0, serverError), true); + assert.equal(shouldRetryManagedProfilesQuery(1, new Error("Network failure")), true); + assert.equal(shouldRetryManagedProfilesQuery(2, serverError), false); + }); +}); diff --git a/apps/dashboard/src/hooks/useManagedProfiles.ts b/apps/dashboard/src/hooks/useManagedProfiles.ts new file mode 100644 index 000000000..b833a3e1b --- /dev/null +++ b/apps/dashboard/src/hooks/useManagedProfiles.ts @@ -0,0 +1,23 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { isApiError } from "@/services/api/api-client"; +import { type ListManagedProfilesParams, ManagedProfilesService } from "@/services/api/managed-profiles.service"; + +export const MANAGED_PROFILES_QUERY_KEY = "managed-profiles"; + +export function isManagedProfilesAccessDenied(error: unknown): boolean { + return isApiError(error) && error.status === 403 && error.data.code === "MANAGED_PROFILE_ACCESS_DENIED"; +} + +export function shouldRetryManagedProfilesQuery(failureCount: number, error: unknown): boolean { + return !isManagedProfilesAccessDenied(error) && failureCount < 2; +} + +export function useManagedProfiles(params: ListManagedProfilesParams = {}, enabled = true) { + return useQuery({ + enabled, + placeholderData: keepPreviousData, + queryFn: ({ signal }) => ManagedProfilesService.list(params, signal), + queryKey: [MANAGED_PROFILES_QUERY_KEY, params], + retry: shouldRetryManagedProfilesQuery + }); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 72566e558..2e0990e8e 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppRecipientsRouteImport } from './routes/_app/recipients' import { Route as AppQuoteRouteImport } from './routes/_app/quote' import { Route as AppOverviewRouteImport } from './routes/_app/overview' +import { Route as AppManagedProfilesRouteImport } from './routes/_app/managed-profiles' import { Route as AppLimitsRouteImport } from './routes/_app/limits' import { Route as AppApiKeysRouteImport } from './routes/_app/api-keys' import { Route as AppAdminRouteImport } from './routes/_app/admin' @@ -80,6 +81,11 @@ const AppOverviewRoute = AppOverviewRouteImport.update({ path: '/overview', getParentRoute: () => AppRoute, } as any) +const AppManagedProfilesRoute = AppManagedProfilesRouteImport.update({ + id: '/managed-profiles', + path: '/managed-profiles', + getParentRoute: () => AppRoute, +} as any) const AppLimitsRoute = AppLimitsRouteImport.update({ id: '/limits', path: '/limits', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/admin': typeof AppAdminRouteWithChildren '/api-keys': typeof AppApiKeysRoute '/limits': typeof AppLimitsRoute + '/managed-profiles': typeof AppManagedProfilesRoute '/overview': typeof AppOverviewRoute '/quote': typeof AppQuoteRoute '/recipients': typeof AppRecipientsRoute @@ -128,6 +135,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/api-keys': typeof AppApiKeysRoute '/limits': typeof AppLimitsRoute + '/managed-profiles': typeof AppManagedProfilesRoute '/overview': typeof AppOverviewRoute '/quote': typeof AppQuoteRoute '/recipients': typeof AppRecipientsRoute @@ -147,6 +155,7 @@ export interface FileRoutesById { '/_app/admin': typeof AppAdminRouteWithChildren '/_app/api-keys': typeof AppApiKeysRoute '/_app/limits': typeof AppLimitsRoute + '/_app/managed-profiles': typeof AppManagedProfilesRoute '/_app/overview': typeof AppOverviewRoute '/_app/quote': typeof AppQuoteRoute '/_app/recipients': typeof AppRecipientsRoute @@ -166,6 +175,7 @@ export interface FileRouteTypes { | '/admin' | '/api-keys' | '/limits' + | '/managed-profiles' | '/overview' | '/quote' | '/recipients' @@ -182,6 +192,7 @@ export interface FileRouteTypes { | '/login' | '/api-keys' | '/limits' + | '/managed-profiles' | '/overview' | '/quote' | '/recipients' @@ -200,6 +211,7 @@ export interface FileRouteTypes { | '/_app/admin' | '/_app/api-keys' | '/_app/limits' + | '/_app/managed-profiles' | '/_app/overview' | '/_app/quote' | '/_app/recipients' @@ -299,6 +311,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOverviewRouteImport parentRoute: typeof AppRoute } + '/_app/managed-profiles': { + id: '/_app/managed-profiles' + path: '/managed-profiles' + fullPath: '/managed-profiles' + preLoaderRoute: typeof AppManagedProfilesRouteImport + parentRoute: typeof AppRoute + } '/_app/limits': { id: '/_app/limits' path: '/limits' @@ -355,6 +374,7 @@ interface AppRouteChildren { AppAdminRoute: typeof AppAdminRouteWithChildren AppApiKeysRoute: typeof AppApiKeysRoute AppLimitsRoute: typeof AppLimitsRoute + AppManagedProfilesRoute: typeof AppManagedProfilesRoute AppOverviewRoute: typeof AppOverviewRoute AppQuoteRoute: typeof AppQuoteRoute AppRecipientsRoute: typeof AppRecipientsRoute @@ -367,6 +387,7 @@ const AppRouteChildren: AppRouteChildren = { AppAdminRoute: AppAdminRouteWithChildren, AppApiKeysRoute: AppApiKeysRoute, AppLimitsRoute: AppLimitsRoute, + AppManagedProfilesRoute: AppManagedProfilesRoute, AppOverviewRoute: AppOverviewRoute, AppQuoteRoute: AppQuoteRoute, AppRecipientsRoute: AppRecipientsRoute, diff --git a/apps/dashboard/src/routes/_app.tsx b/apps/dashboard/src/routes/_app.tsx index 71803e632..ee5ab0ba3 100644 --- a/apps/dashboard/src/routes/_app.tsx +++ b/apps/dashboard/src/routes/_app.tsx @@ -2,13 +2,16 @@ import { createFileRoute, Navigate, Outlet, useRouterState } from "@tanstack/rea import { motion } from "motion/react"; import { AppSidebar } from "@/components/layout/AppSidebar"; import { ImpersonationBanner } from "@/components/layout/ImpersonationBanner"; +import { ManagedProfileBanner } from "@/components/layout/ManagedProfileBanner"; import { Topbar } from "@/components/layout/Topbar"; +import { isChildModePathForbidden } from "@/components/managed-profiles/managed-profile-ui"; import { AccountTypeSelector } from "@/components/onboarding/AccountTypeSelector"; import { Button } from "@/components/ui/button"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { Skeleton } from "@/components/ui/skeleton"; import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; import { useAuthStore } from "@/stores/auth.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; export const Route = createFileRoute("/_app")({ component: AppLayout @@ -16,11 +19,16 @@ export const Route = createFileRoute("/_app")({ function AppLayout() { const user = useAuthStore(state => state.user); + const managedProfile = useManagedProfileSelection(); const pathname = useRouterState({ select: state => state.location.pathname }); const { data: onboardingStatus, isError, isLoading, refetch } = useOnboardingStatusQuery(!!user); if (!user) { - return ; + return ; + } + + if (managedProfile && isChildModePathForbidden(pathname)) { + return ; } const requiresAccount = ["/overview", "/recipients", "/transfer", "/transactions"].includes(pathname); @@ -50,8 +58,11 @@ function AppLayout() { - - +
+ + + +
{/* Re-key on pathname so each navigation cross-fades the page content in. */} ; } if (!isAdmin) { - return ; + return ; } return ; } 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/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..d7652c444 100644 --- a/apps/dashboard/src/routes/monerium.callback.tsx +++ b/apps/dashboard/src/routes/monerium.callback.tsx @@ -12,6 +12,7 @@ import { queryClient } from "@/lib/queryClient"; import { apiClient } from "@/services/api/api-client"; import { AuthService } from "@/services/auth"; import { useAuthStore } from "@/stores/auth.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; const searchSchema = z.object({ code: z.string().optional(), @@ -41,8 +42,17 @@ function callbackFrom(search: z.infer): MoneriumOAuthCallba } function MoneriumCallbackPage() { - const restoreSession = useAuthStore(state => state.restoreSession); + const managedProfile = useManagedProfileSelection(); const user = useAuthStore(state => state.user); + + if (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 +72,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/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 }); + } +}; From b8ae6099344d0effc3c9f9fa8fed96a6c341c807 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 13 Aug 2026 13:45:20 -0300 Subject: [PATCH 19/29] docs(dashboard): record managed profile support --- docs/operations-testing.md | 11 ++++++++++- docs/product-dashboard.md | 22 +++++++--------------- 2 files changed, 17 insertions(+), 16 deletions(-) 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 45025d909..cfe7b39a6 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -26,9 +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. The API also implements managed - headless profiles and route-scoped manager delegation. The dashboard experience for selecting and - acting for those profiles is the next accepted feature described below; it is not yet shipped. + 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 @@ -143,7 +144,7 @@ two people. category — recipient-approval alerts — was dropped for now: no such notification type exists in the backend yet.) -### Managed profiles (accepted next feature) +### 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 @@ -167,7 +168,7 @@ and customer-type policy on every delegated authorization decision. 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 for**. Stopping clears the selection and returns to `/managed-profiles` under the + **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 @@ -353,14 +354,6 @@ provider-shaped rather than UI-shaped. ## Next steps -- Add the managed-profile selector, persisted delegated identity, child-mode banner, and explicit - per-service managed-header handling described above. -- Add a transfer-state identity guard and owner-keyed resumable payment snapshots. Block manager/ - child identity changes during client-owned preparation and signing, allow them after signed state - is durably submitted, and preserve all manager/child ramp ephemerals and backend ramp references - across allowed changes. -- Expose manager-authorized corridors from `GET /v1/managed-profiles` and extend sender-side - recipient routes to managed-child authorization before enabling Recipients in child mode. - Display relationship status and authoritative transfer eligibility, including the reason a recipient is not payable, instead of deriving availability from onboarding status alone. - Connect the dashboard notification feed to the backend. @@ -409,8 +402,7 @@ so an operator can end its own session. 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 selector and child-mode experience are the accepted next feature described -above. +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 From fc3394db24af77fa54c6ae0bfff5766c0ce2378d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 21 Aug 2026 15:31:51 -0300 Subject: [PATCH 20/29] fix(api): block impersonated ramp mutations --- .../api/controllers/ramp.controller.test.ts | 2 +- apps/api/src/api/routes/v1/ramp.route.test.ts | 86 +++++++++++++++++++ apps/api/src/api/routes/v1/ramp.route.ts | 4 + apps/api/src/models/profileRole.model.ts | 6 +- .../components/admin/ImpersonateDialog.tsx | 3 +- .../components/layout/ImpersonationBanner.tsx | 13 ++- docs/api/openapi/vortex.openapi.d.ts | 8 +- docs/api/openapi/vortex.openapi.json | 8 +- docs/product-dashboard.md | 14 +-- docs/security-spec/01-auth/admin-auth.md | 4 +- .../01-auth/admin-impersonation.md | 44 ++++++---- docs/security-spec/RISK-REGISTER.md | 2 +- 12 files changed, 152 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/api/routes/v1/ramp.route.test.ts diff --git a/apps/api/src/api/controllers/ramp.controller.test.ts b/apps/api/src/api/controllers/ramp.controller.test.ts index 2de2f3829..12bb01715 100644 --- a/apps/api/src/api/controllers/ramp.controller.test.ts +++ b/apps/api/src/api/controllers/ramp.controller.test.ts @@ -6,7 +6,7 @@ import { classifyApiClientError } from "../observability/errorClassifier"; import { buildRampRequestMetadata, formatProviderContext, mapProviderFailure } from "./ramp.controller"; describe("buildRampRequestMetadata", () => { - it("attributes successful money-movement events to the impersonation session", () => { + it("includes impersonation attribution in ramp request metadata", () => { const metadata = buildRampRequestMetadata( { body: { additionalData: { taxId: "sensitive" }, quoteId: "quote-1", signingAccounts: ["account-1"] }, 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/models/profileRole.model.ts b/apps/api/src/models/profileRole.model.ts index 3a444c198..8fed4409f 100644 --- a/apps/api/src/models/profileRole.model.ts +++ b/apps/api/src/models/profileRole.model.ts @@ -9,9 +9,9 @@ export type ProfileRoleName = "discount_manager" | "vortex_admin"; export const PROFILE_ROLE_NAMES: ProfileRoleName[] = ["discount_manager", "vortex_admin"]; // Roles grantable through POST /v1/admin/profile-roles, which is guarded only by the shared -// ADMIN_SECRET. vortex_admin confers the ability to act as any customer — including moving -// their money — so that secret must never be sufficient to grant it; it is granted -// out-of-band instead (see scripts/grant-vortex-admin.ts). Revocation stays available for +// 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"]; diff --git a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx index b55f2b77d..41a4572e0 100644 --- a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -64,7 +64,8 @@ export function ImpersonateDialog({ Log in as {target.email}? - You'll act as this customer until the session expires in 30 minutes. This is logged against your account. + 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/layout/ImpersonationBanner.tsx b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx index 6cf12ba31..48443a84c 100644 --- a/apps/dashboard/src/components/layout/ImpersonationBanner.tsx +++ b/apps/dashboard/src/components/layout/ImpersonationBanner.tsx @@ -42,10 +42,15 @@ export function ImpersonationBanner() { return (
- - You are acting as {session.targetEmail} - <> · {formatRemaining(remainingMs)} remaining - +
+

+ 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/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index f6165d4a8..32a17f2fe 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2470,7 +2470,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; @@ -6081,7 +6081,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; @@ -6230,7 +6230,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; @@ -6392,7 +6392,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 a7e6453bc..326f66c74 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -1373,7 +1373,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": { "type": "string" }, @@ -5669,7 +5669,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } }, - "description": "Quote ownership or managed-profile authorization failed." + "description": "Quote ownership, managed-profile authorization, or impersonation policy failed." }, "500": { "content": { @@ -5912,7 +5912,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } }, - "description": "Ramp ownership or managed-profile authorization failed." + "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed." }, "500": { "content": { @@ -6169,7 +6169,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } }, - "description": "Ramp ownership or managed-profile authorization failed." + "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed." }, "500": { "content": { diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index cfe7b39a6..0011cbaf8 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -392,11 +392,12 @@ surface; its security controls are normative in - See active and recent impersonation sessions: `GET /v1/admin-console/impersonation`. - End a session immediately: `DELETE /v1/admin-console/impersonation/:sessionId`. -**Depth is FULL, not scoped.** Once impersonating, the operator acts with the target account's -complete rights, including money movement — there is no read-only or reduced-capability -impersonation mode in v1. An impersonated request cannot mint a durable API credential or -re-enter the admin console (no privilege re-escalation, no chaining), with one narrow exception -so an operator can end its own session. +**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 @@ -412,7 +413,8 @@ Both inherit the `/admin` parent route's redirect to `/overview` unless `roles` `GET /v1/onboarding/status` contains `vortex_admin`, and the sidebar's Admin item follows the same gate. While a session is live, `ImpersonationBanner` is rendered above the topbar on every `_app` route — non-dismissible, -naming the impersonated account and offering "Exit". Because the operator's own Supabase tokens +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. diff --git a/docs/security-spec/01-auth/admin-auth.md b/docs/security-spec/01-auth/admin-auth.md index c9c3f5fec..33a6a6b63 100644 --- a/docs/security-spec/01-auth/admin-auth.md +++ b/docs/security-spec/01-auth/admin-auth.md @@ -42,9 +42,9 @@ the shared credential; individual admin identities are out of scope for this cha 7. **Admin auth on `/v1/admin/*` MUST NOT attach any identity to the request** — Unlike Supabase auth (which sets `userId`) or API key auth (which sets `authenticatedPartner`), admin auth on this surface is identity-less. No `req.adminUser` or similar should exist. This invariant is scoped to `/v1/admin/*`: the separate `/v1/admin-console/*` surface is intentionally identity-bearing — it authenticates via Supabase and carries the operator's profile ID — by design; see [`admin-impersonation.md`](admin-impersonation.md). 8. **`vortex_admin` MUST NOT be grantable through `POST /v1/admin/profile-roles`** — that route is guarded only by `ADMIN_SECRET`, and `vortex_admin` grants access to - `/v1/admin-console/*` including FULL-depth customer impersonation + `/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 money-movement rights over any customer, + 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 diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md index b87be2f0a..1e0cd57ea 100644 --- a/docs/security-spec/01-auth/admin-impersonation.md +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -8,9 +8,10 @@ surface — the per-operator, Supabase-identity-bearing counterpart to the share main-account only: there is no parent/child account table, and the sub-account layer modelled on Avenia's subaccount API is deferred to v2. -Depth is **FULL**: while impersonating, the operator acts with the target profile's complete -rights, including money movement. There is no reduced-scope or read-only impersonation mode in -v1; this is the primary residual risk this document exists to bound (see the risk register, +Depth is broad but excludes ramp money movement. While impersonating, the operator may create +quotes and inspect ramp status, history, and errors, but `POST /v1/ramp/register`, `POST +/v1/ramp/update`, and `POST /v1/ramp/start` reject the request. Other customer-account mutations +remain available, so this is not a general read-only impersonation mode (see the risk register, RISK-018). ### Routes @@ -70,7 +71,8 @@ route reachable by a Supabase bearer token — not only a dedicated impersonatio `vortex_admin` is not grantable through `POST /v1/admin/profile-roles` — that route is guarded only by the shared `ADMIN_SECRET`, and holding `vortex_admin` is sufficient to impersonate any -customer at FULL depth, so that secret must never be sufficient by itself to grant it. +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 @@ -159,7 +161,7 @@ and requires deployment/database access rather than an HTTP credential — see the operator even though it is recorded against the target's account. 14. **`vortex_admin` MUST NOT be grantable through the `ADMIN_SECRET`-guarded `POST /v1/admin/profile-roles` route** — that shared secret must not, by itself, be sufficient - to gain FULL-depth impersonation rights (i.e., money movement) over any customer; granting + 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` @@ -172,6 +174,13 @@ and requires deployment/database access rather than an HTTP credential — see both profile foreign keys in migration 063 use `ON DELETE RESTRICT`. Operators must resolve retention/deletion policy explicitly instead of erasing security history through a profile cascade. +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. ## Threat Vectors & Mitigations @@ -182,22 +191,21 @@ and requires deployment/database access rather than an HTTP credential — see | Impersonation used to mint a permanent backdoor | Operator (or an attacker who obtained an operator's token) mints an API secret key while impersonating, which outlives the session | `rejectImpersonation` on `/v1/api-credentials` (Invariant 11) | | Privilege re-escalation / impersonation chaining | An impersonated request is used to start a second impersonation session, list sessions, or browse accounts | `requireVortexAdmin`'s `rejectImpersonation` step refuses `GET /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation` outright (Invariant 12) | | Impersonated caller abuses the self-revoke carve-out to end someone else's session | Operator impersonating profile A presents that token against profile B's `sessionId` | Rejected with `403 IMPERSONATION_NOT_ALLOWED`: the carve-out only matches when the path `:sessionId` equals the caller's own `req.impersonation.sessionId` (Invariant 12) | -| Unattributed money movement | Operator disputes having performed an action while impersonating | Per-operator Supabase identity recorded as `actorProfileId` on the session row (Invariant 3); `impersonationSessionId`/`impersonatorProfileId` on every `api_client_events` row raised during the request (Invariant 13) | +| 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 money-movement rights over any customer | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` (Invariant 14); the only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access, not an HTTP credential | +| 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 -- FULL-depth impersonation (Invariant 3 does not restrict scope, only identity and target) is - a deliberate v1 design decision, not an oversight, but it remains the primary residual risk: - any compromised operator account or misused session can move a customer's funds. There is no - read-only or reduced-scope impersonation mode. Tracked as an accepted risk in the risk register - (RISK-018), not as an open implementation gap. +- Ramp money movement is denied, but impersonation is still broader than a read-only support + mode: provider onboarding, KYC/KYB, recipient, active-entity, and notification mutations remain + available. A compromised operator account can therefore still make sensitive changes to a + customer's account. 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 @@ -239,6 +247,9 @@ and requires deployment/database access rather than an HTTP credential — see - [x] `req.impersonation` is set only within `resolveBearerPrincipal()`, consumed by `supabaseAuth.ts` and `dualAuth.ts` — **PASS**. - [x] `rejectImpersonation` blocks `/v1/api-credentials` (credential minting) — **PASS**. +- [x] `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] `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 @@ -249,9 +260,9 @@ and requires deployment/database access rather than an HTTP credential — see revoke any session — **PASS** (`admin-console.route.test.ts`, all four cases under "DELETE /impersonation/:sessionId while impersonating"). - [x] Every `api_client_events` row raised while `req.impersonation` is set carries - `impersonationSessionId` and `impersonatorProfileId` in `metadata`, including successful - quote/ramp operations and maintenance denials — **PASS** (`quote.controller.test.ts`, - `ramp.controller.test.ts`, `maintenanceGuard.test.ts`). + `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** @@ -265,6 +276,7 @@ and requires deployment/database access rather than an HTTP credential — see documented — **PASS** (`scripts/grant-vortex-admin.ts`, `bun run grant:vortex-admin `). - [x] The operator-facing frontend that consumes `/v1/admin-console/*` presents a - non-dismissible banner naming the impersonated account while a session is active — + 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/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 7bfb3922f..145fcea44 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -38,7 +38,7 @@ register and the owning module specification. | RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | | RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base ramps are not swept by the Base post-process handler, and AssetHub cleanup is a no-op. | Cleanup worker selects terminal ramps; completed Base token cleanup works; AssetHub corridors remain quote-disabled. | Widen safe Base cleanup to failed/timed-out states and implement/remove AssetHub cleanup before enabling AssetHub or relying on automatic failure refunds. | | RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | -| RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer profile at FULL depth — the operator acts with the target's complete rights, including money movement. v1 has no reduced-scope or read-only impersonation mode. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks credential minting and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before scoping impersonation depth down (e.g., a read-only investigate mode) or before v2 sub-account delegation reuses this mechanism; see `01-auth/admin-impersonation.md`. | +| 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 are denied, but provider onboarding, KYC/KYB, recipient, active-entity, and notification mutations are not generally read-only. | 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, credential minting, and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before reducing the remaining mutation scope to a read-only investigate mode or before v2 sub-account delegation reuses this mechanism; 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. | ## Review cadence From 85972f1b9ea7b05e21686f6a8dd935cc985ceb64 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 10:16:16 -0300 Subject: [PATCH 21/29] feat(dashboard): add managed profile admin access --- .../admin-console/accounts.controller.ts | 89 ++++++++++++-- .../admin-console/admin-console.route.test.ts | 61 ++++++++++ ...=> 067-allow-vortex-admin-profile-role.ts} | 0 ...68-create-admin-impersonation-sessions.ts} | 0 .../components/admin/AdminAccountsTable.tsx | 113 ++++++++++-------- .../components/admin/ImpersonateDialog.tsx | 45 +++++-- .../components/admin/admin-account-ui.test.ts | 57 +++++++++ .../src/components/admin/admin-account-ui.ts | 44 +++++++ .../src/routes/_app/admin.$profileId.tsx | 22 +++- .../dashboard/src/routes/_app/admin.index.tsx | 2 +- .../src/services/api/admin-console.service.ts | 29 +++-- .../src/stores/managed-profile.store.test.ts | 26 ++++ docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/product-dashboard.md | 6 +- .../01-auth/admin-impersonation.md | 18 ++- 15 files changed, 425 insertions(+), 89 deletions(-) rename apps/api/src/database/migrations/{062-allow-vortex-admin-profile-role.ts => 067-allow-vortex-admin-profile-role.ts} (100%) rename apps/api/src/database/migrations/{063-create-admin-impersonation-sessions.ts => 068-create-admin-impersonation-sessions.ts} (100%) create mode 100644 apps/dashboard/src/components/admin/admin-account-ui.test.ts create mode 100644 apps/dashboard/src/components/admin/admin-account-ui.ts diff --git a/apps/api/src/api/controllers/admin-console/accounts.controller.ts b/apps/api/src/api/controllers/admin-console/accounts.controller.ts index 8d5f09b3d..b985dc0de 100644 --- a/apps/api/src/api/controllers/admin-console/accounts.controller.ts +++ b/apps/api/src/api/controllers/admin-console/accounts.controller.ts @@ -1,10 +1,13 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; -import { Op } from "sequelize"; +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"; @@ -46,20 +49,39 @@ export async function listAccounts(req: Request, res: Response): Promise { 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", "createdAt"], + attributes: ["id", "email", "kind", "createdAt"], limit: limit + 1, offset, order: [["createdAt", "DESC"]], - where: search ? { email: { [Op.iLike]: `%${search}%` } } : {} + 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] = await Promise.all([ + const [entities, activeAssignments, managedRelationships] = await Promise.all([ profileIds.length ? CustomerEntity.findAll({ where: { profileId: profileIds } }) : [], profileIds.length ? ProfilePartnerAssignment.findAll({ @@ -69,8 +91,19 @@ export async function listAccounts(req: Request, res: Response): Promise { 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 @@ -81,6 +114,9 @@ export async function listAccounts(req: Request, res: Response): Promise { 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) { @@ -94,6 +130,21 @@ export async function listAccounts(req: Request, res: Response): Promise { 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 }; }), @@ -133,10 +184,13 @@ export async function getAccount(req: Request<{ profileId: string }>, res: Respo return; } - const entities = await CustomerEntity.findAll({ where: { profileId } }); + 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] = await Promise.all([ + const [providerCustomers, kycCases, impersonationSessions, managerProfile, managerConfig] = await Promise.all([ entityIds.length ? ProviderCustomer.findAll({ order: [["updatedAt", "DESC"]], where: { customerEntityId: entityIds } }) : [], @@ -146,7 +200,9 @@ export async function getAccount(req: Request<{ profileId: string }>, res: Respo 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(); @@ -208,7 +264,22 @@ export async function getAccount(req: Request<{ profileId: string }>, res: Respo 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); 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 index c5a77201a..27a59901d 100644 --- 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 @@ -2,11 +2,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn 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"; @@ -72,6 +74,65 @@ describe("admin-console routes", () => { 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(); diff --git a/apps/api/src/database/migrations/062-allow-vortex-admin-profile-role.ts b/apps/api/src/database/migrations/067-allow-vortex-admin-profile-role.ts similarity index 100% rename from apps/api/src/database/migrations/062-allow-vortex-admin-profile-role.ts rename to apps/api/src/database/migrations/067-allow-vortex-admin-profile-role.ts diff --git a/apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts b/apps/api/src/database/migrations/068-create-admin-impersonation-sessions.ts similarity index 100% rename from apps/api/src/database/migrations/063-create-admin-impersonation-sessions.ts rename to apps/api/src/database/migrations/068-create-admin-impersonation-sessions.ts diff --git a/apps/dashboard/src/components/admin/AdminAccountsTable.tsx b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx index 7c60c0b46..5040d6892 100644 --- a/apps/dashboard/src/components/admin/AdminAccountsTable.tsx +++ b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx @@ -5,6 +5,7 @@ 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 { @@ -16,7 +17,7 @@ function verificationEntries(summary: AdminAccountSummary["verificationSummary"] } export function AdminAccountsTable({ accounts }: { accounts: AdminAccountSummary[] }) { - const [target, setTarget] = useState<{ id: string; email: string } | null>(null); + const [target, setTarget] = useState(null); return ( <> @@ -32,54 +33,68 @@ export function AdminAccountsTable({ accounts }: { accounts: AdminAccountSummary - {accounts.map(account => ( - - - - {account.email} - - - -
- {account.entities.length === 0 ? ( - None - ) : ( - account.entities.map(entity => ( - - {entity.type} · {entity.status} - - )) - )} -
-
- -
- {verificationEntries(account.verificationSummary).length === 0 ? ( - None - ) : ( - verificationEntries(account.verificationSummary).map(([status, count]) => ( - - {count} {status.replace("_", " ")} - - )) - )} -
-
- {account.activePartnerName ?? "—"} - {formatDate(account.createdAt)} - - - -
- ))} + {accounts.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 && ( diff --git a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx index 41a4572e0..93fc30a14 100644 --- a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -4,21 +4,22 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useStartImpersonation } from "@/hooks/useAdminConsole"; -import { enterImpersonation } from "@/stores/impersonation.store"; +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"; -/** - * "Log in as" confirmation: swaps the active session to the returned impersonation token - * and lands on Overview. The session itself is audited server-side against the operator. - */ +/** Confirms an impersonation or a direct managed-profile selection. */ export function ImpersonateDialog({ onOpenChange, target }: { onOpenChange: (open: boolean) => void; - target: { id: string; email: string } | null; + target: AdminImpersonationTarget | null; }) { const navigate = useNavigate(); const startImpersonation = useStartImpersonation(); + const authenticatedProfileId = useAuthStore(state => state.user?.userId); function handleOpenChange(open: boolean) { onOpenChange(open); @@ -29,6 +30,15 @@ export function ImpersonateDialog({ 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 }, { @@ -49,6 +59,11 @@ export function ImpersonateDialog({ 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" }); } @@ -57,15 +72,21 @@ export function ImpersonateDialog({ } if (!target) return null; + const selectsDirectly = canSelectManagedProfileDirectly(target, authenticatedProfileId); return ( - Log in as {target.email}? + {target.managedProfile ? `Act as ${target.label}?` : `Log in as ${target.label}?`} - You'll act as this customer until the session expires in 30 minutes. This is logged against your account, and money - movement is disabled. + {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.`} @@ -74,7 +95,11 @@ export function ImpersonateDialog({ 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/routes/_app/admin.$profileId.tsx b/apps/dashboard/src/routes/_app/admin.$profileId.tsx index b8044747f..23069c7f8 100644 --- a/apps/dashboard/src/routes/_app/admin.$profileId.tsx +++ b/apps/dashboard/src/routes/_app/admin.$profileId.tsx @@ -1,5 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; +import { + type AdminImpersonationTarget, + getAdminAccountLabel, + toAdminImpersonationTarget +} from "@/components/admin/admin-account-ui"; import { ImpersonateDialog } from "@/components/admin/ImpersonateDialog"; import { Stagger, StaggerItem } from "@/components/motion/Stagger"; import { Badge } from "@/components/ui/badge"; @@ -15,7 +20,7 @@ export const Route = createFileRoute("/_app/admin/$profileId")({ function AccountDetail() { const { profileId } = Route.useParams(); const account = useAdminAccount(profileId); - const [impersonateTarget, setImpersonateTarget] = useState<{ id: string; email: string } | null>(null); + const [impersonateTarget, setImpersonateTarget] = useState(null); if (account.isLoading) { return ; @@ -33,16 +38,25 @@ function AccountDetail() { } const data = account.data; + const target = toAdminImpersonationTarget(data); return (
-

{data.email}

+
+

{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()}

-
diff --git a/apps/dashboard/src/routes/_app/admin.index.tsx b/apps/dashboard/src/routes/_app/admin.index.tsx index 8338e0e3a..325dcb6a2 100644 --- a/apps/dashboard/src/routes/_app/admin.index.tsx +++ b/apps/dashboard/src/routes/_app/admin.index.tsx @@ -43,7 +43,7 @@ function AdminAccountsPage() { setSearch(event.target.value); setCursorStack([]); }} - placeholder="Search by email…" + placeholder="Search by email or external ID…" value={search} /> diff --git a/apps/dashboard/src/services/api/admin-console.service.ts b/apps/dashboard/src/services/api/admin-console.service.ts index fc439f3fb..37c8cc0a1 100644 --- a/apps/dashboard/src/services/api/admin-console.service.ts +++ b/apps/dashboard/src/services/api/admin-console.service.ts @@ -5,14 +5,31 @@ export type AdminVerificationStatus = "pending" | "started" | "in_review" | "app export interface AdminCustomerEntity { id: string; - type: string; + type: "business" | "individual"; status: string; } -/** One row of GET /admin-console/accounts. */ -export interface AdminAccountSummary { +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; + 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. */ @@ -80,9 +97,7 @@ export interface AdminImpersonationSessionRecord extends AdminImpersonationSessi target: AdminSessionParty; } -export interface AdminAccountDetail { - id: string; - email: string; +export interface AdminAccountDetail extends AdminAccountIdentity { createdAt: string; activeEntityId: string | null; entities: AdminCustomerEntityDetail[]; diff --git a/apps/dashboard/src/stores/managed-profile.store.test.ts b/apps/dashboard/src/stores/managed-profile.store.test.ts index 7e7f0667a..3c3b739c6 100644 --- a/apps/dashboard/src/stores/managed-profile.store.test.ts +++ b/apps/dashboard/src/stores/managed-profile.store.test.ts @@ -16,6 +16,7 @@ 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 { @@ -68,6 +69,31 @@ describe("managed profile transitions", () => { 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; diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 530bbac4e..a2eb891ac 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2704,8 +2704,8 @@ export interface components { credentials: components["schemas"]["ApiCredential"][]; }; ListManagedProfilesResponse: { - manager: components["schemas"]["ManagedProfileManagerPolicy"]; managedProfiles: components["schemas"]["ManagedProfile"][]; + manager: components["schemas"]["ManagedProfileManagerPolicy"]; pagination: components["schemas"]["ManagedProfilePagination"]; }; MalformedJsonErrorResponse: { diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 0011cbaf8..7e2ad024c 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -211,7 +211,9 @@ implemented; selecting a child does not make recipient-directed ramp registratio 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. +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. ## High-level implementation strategy @@ -419,7 +421,7 @@ are kept beside one atomic impersonation-session record rather than replaced, ex and instant. The record is observed across tabs, and every enter, exit, expiry, or cross-tab replacement clears account-scoped query, notification, transfer, and wallet state. -**Verified against a running stack.** Migrations 062 and 063 apply from a clean schema, and the +**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 diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md index 1e0cd57ea..ad1a2fc62 100644 --- a/docs/security-spec/01-auth/admin-impersonation.md +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -4,9 +4,9 @@ `vortex_admin` operators can act as a customer's profile through the `/v1/admin-console/*` surface — the per-operator, Supabase-identity-bearing counterpart to the shared-secret -`/v1/admin/*` surface documented in [`admin-auth.md`](admin-auth.md). v1 scope is Vortex → -main-account only: there is no parent/child account table, and the sub-account layer -modelled on Avenia's subaccount API is deferred to v2. +`/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. Depth is broad but excludes ramp money movement. While impersonating, the operator may create quotes and inspect ramp status, history, and errors, but `POST /v1/ramp/register`, `POST @@ -20,8 +20,8 @@ All routes live under `/v1/admin-console/*` (`accounts.route.ts`, `impersonation | Route | Guard | Success | Notable errors | |---|---|---|---| -| `GET /accounts?search=&cursor=&limit=` | `requireVortexAdmin` | `200` — paginated, email-`search`-filtered account list | — | -| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — entities, provider customers, KYC cases, recent impersonation sessions targeting this profile | `400 INVALID_PROFILE_ID`; `404 USER_NOT_FOUND` | +| `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` | @@ -62,6 +62,12 @@ Invariant 12 for the exact self-revoke mechanism this enables. 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 @@ -171,7 +177,7 @@ and requires deployment/database access rather than an HTTP credential — see valve (verified: "still allows revoking vortex_admin even though it cannot be granted via HTTP"). See [`admin-auth.md`](admin-auth.md) Invariant 8. 15. **Session audit history MUST NOT disappear when an actor or target profile is deleted** — - both profile foreign keys in migration 063 use `ON DELETE RESTRICT`. Operators must resolve + 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 From 092be0a8c6685c17e15b78b3bc28c6d917a3173d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 10:50:09 -0300 Subject: [PATCH 22/29] fix(dashboard): make acted-for verification read-only --- apps/api/src/api/routes/v1/alfredpay.route.ts | 15 +++ .../api/routes/v1/brla-kyc-import.route.ts | 2 + apps/api/src/api/routes/v1/brla.route.ts | 32 ++++-- apps/api/src/api/routes/v1/monerium.route.ts | 5 +- apps/api/src/api/routes/v1/mykobo.route.ts | 3 +- .../v1/provider-verification.route.test.ts | 106 ++++++++++++++++++ apps/dashboard/e2e/managed-profiles.spec.ts | 32 +++++- .../e2e/onboarding-monerium-eu.spec.ts | 49 +++++++- .../components/onboarding/CorridorCard.tsx | 7 +- apps/dashboard/src/routes/_app/api-keys.tsx | 6 +- apps/dashboard/src/routes/_app/overview.tsx | 25 ++++- .../src/routes/monerium.callback.tsx | 4 +- docs/product-dashboard.md | 22 ++-- .../01-auth/admin-impersonation.md | 26 +++-- .../05-integrations/alfredpay.md | 4 +- docs/security-spec/05-integrations/brla.md | 2 +- .../security-spec/05-integrations/monerium.md | 2 + docs/security-spec/05-integrations/mykobo.md | 2 +- 18 files changed, 306 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/api/routes/v1/provider-verification.route.test.ts 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/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/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/dashboard/e2e/managed-profiles.spec.ts b/apps/dashboard/e2e/managed-profiles.spec.ts index b62f88693..838f78910 100644 --- a/apps/dashboard/e2e/managed-profiles.spec.ts +++ b/apps/dashboard/e2e/managed-profiles.spec.ts @@ -27,7 +27,7 @@ test("ordinary users cannot navigate to managed profiles", async ({ page }) => { }); test("a manager selects and stops acting for a managed profile", async ({ page }) => { - const backend = await mockBackend(page, { managedProfiles: [CHILD], roles: ["vortex_admin"] }); + const backend = await mockBackend(page, { managedProfiles: [CHILD], onboardingState: "started", roles: ["vortex_admin"] }); await seedSession(page); await page.goto("/managed-profiles"); @@ -42,6 +42,10 @@ test("a manager selects and stops acting for a managed profile", async ({ page } 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(); @@ -50,6 +54,7 @@ test("a manager selects and stops acting for a managed profile", async ({ page } 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$/); @@ -69,6 +74,31 @@ test("a manager selects and stops acting for a managed profile", async ({ page } 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] }); 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/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 ? (
+ {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 && ( ): MoneriumOAuthCallba } function MoneriumCallbackPage() { + const impersonation = useImpersonationSession(); const managedProfile = useManagedProfileSelection(); const user = useAuthStore(state => state.user); - if (managedProfile) return ; + if (impersonation || managedProfile) return ; if (!user && !AuthService.getTokens()) return ; return ; diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 7e2ad024c..33f8ab5c9 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -45,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. @@ -188,14 +191,15 @@ and customer-type policy on every delegated authorization decision. 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, Recipients, Get a quote, New transfer, Transactions, and -Limits remain available where their API routes support managed-child authorization. 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. +**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. **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 @@ -214,6 +218,8 @@ page, while exiting the admin impersonation session returns the operator to `/ad 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. ## High-level implementation strategy diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md index ad1a2fc62..cc21c6e91 100644 --- a/docs/security-spec/01-auth/admin-impersonation.md +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -8,11 +8,11 @@ surface — the per-operator, Supabase-identity-bearing counterpart to the share are direct session targets. Managed headless profiles are reached by impersonating their authenticated manager and composing that session with the existing managed-profile selector. -Depth is broad but excludes ramp money movement. While impersonating, the operator may create -quotes and inspect ramp status, history, and errors, but `POST /v1/ramp/register`, `POST -/v1/ramp/update`, and `POST /v1/ramp/start` reject the request. Other customer-account mutations -remain available, so this is not a general read-only impersonation mode (see the risk register, -RISK-018). +Depth is broad but excludes ramp money movement and provider verification actions. While +impersonating, the operator may create quotes and inspect ramp and KYC/KYB status, history, and +errors, but ramp registration/update/start and KYC/KYB initiation, submission, upload, retry, and +OAuth actions reject the request. Other customer-account mutations remain available, so this is +not a general read-only impersonation mode (see the risk register, RISK-018). ### Routes @@ -187,6 +187,11 @@ and requires deployment/database access rather than an HTTP credential — see 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. ## Threat Vectors & Mitigations @@ -208,10 +213,10 @@ and requires deployment/database access rather than an HTTP credential — see ## Gaps Identified During This Review -- Ramp money movement is denied, but impersonation is still broader than a read-only support - mode: provider onboarding, KYC/KYB, recipient, active-entity, and notification mutations remain - available. A compromised operator account can therefore still make sensitive changes to a - customer's account. Tracked as an accepted risk in the risk register (RISK-018). +- Ramp money movement and KYC/KYB actions are denied, but impersonation is still broader than a + read-only support mode: recipient, active-entity, and notification mutations remain available. A + compromised operator account can therefore still make sensitive changes to a customer's account. + 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 @@ -256,6 +261,9 @@ and requires deployment/database access rather than an HTTP credential — see - [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 diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 309c2aa29..5e7add48a 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. 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 provider actions or multipart buffering. 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. 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 0b3d49be8..ef9657d03 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -170,7 +170,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 `kyb-level-1` 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. 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..54defa8d9 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -28,6 +28,7 @@ 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. ## Threat Vectors & Mitigations @@ -63,3 +64,4 @@ 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. diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index f2af55f9e..0e793dd61 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. 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. From 03d642977b295846959e39161e4759a2a95abbad Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 12:35:18 -0300 Subject: [PATCH 23/29] fix(dashboard): isolate wallet code from node tests --- apps/dashboard/src/machines/transferActor.test.ts | 11 ++++++++++- apps/dashboard/src/stores/auth.store.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/machines/transferActor.test.ts b/apps/dashboard/src/machines/transferActor.test.ts index ee0f9a1ca..f6e4534c3 100644 --- a/apps/dashboard/src/machines/transferActor.test.ts +++ b/apps/dashboard/src/machines/transferActor.test.ts @@ -4,7 +4,6 @@ import assert from "node:assert/strict"; import { after, describe, it } from "node:test"; import { createActor, fromPromise, waitFor } from "xstate"; import type { TransferQuoteRequest } from "./transfer.actors"; -import { transferMachine } from "./transfer.machine"; const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const values = new Map(); @@ -25,6 +24,16 @@ Object.defineProperty(globalThis, "localStorage", { mock.module("@/hooks/useTransactions", () => ({ TRANSACTIONS_QUERY_KEY: "transactions" })); mock.module("@/lib/notify", () => ({ notifyTransferCompleted: () => undefined })); mock.module("@/lib/queryClient", () => ({ queryClient: { invalidateQueries: () => undefined } })); +mock.module("@/services/transactions/userSigning", () => ({ + signAndSubmitEvmTransaction: () => { + throw new Error("Unexpected wallet signing in transfer actor test"); + }, + signMultipleTypedData: () => { + throw new Error("Unexpected wallet signing in transfer actor test"); + } +})); + +const { transferMachine } = await import("./transfer.machine"); const quote = { id: "quote-buy", rampType: RampDirection.BUY } as QuoteResponse; const quoteRequest: TransferQuoteRequest = { diff --git a/apps/dashboard/src/stores/auth.store.ts b/apps/dashboard/src/stores/auth.store.ts index f720c8f08..f4c44bd38 100644 --- a/apps/dashboard/src/stores/auth.store.ts +++ b/apps/dashboard/src/stores/auth.store.ts @@ -49,7 +49,7 @@ function userFromTokens(tokens: AuthTokens): AuthUser { export function clearAccountState(): void { queryClient.clear(); useNotificationsStore.getState().clear(); - void disconnect(wagmiConfig); + if (typeof document !== "undefined") void disconnect(wagmiConfig); } AuthService.configureIdentityTransitionEffects({ activateTransferOwner, canChangeEffectiveIdentity, clearAccountState }); From 02c5aa4fd7932481412fdce02cf535abe2390226 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 14:57:20 -0300 Subject: [PATCH 24/29] style(dashboard): unify impersonation action labels --- apps/dashboard/src/components/admin/AdminAccountsTable.tsx | 2 +- apps/dashboard/src/components/admin/ImpersonateDialog.tsx | 6 +----- apps/dashboard/src/routes/_app/admin.$profileId.tsx | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/components/admin/AdminAccountsTable.tsx b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx index 5040d6892..b35a5e08b 100644 --- a/apps/dashboard/src/components/admin/AdminAccountsTable.tsx +++ b/apps/dashboard/src/components/admin/AdminAccountsTable.tsx @@ -89,7 +89,7 @@ export function AdminAccountsTable({ accounts }: { accounts: AdminAccountSummary variant="outline" > - {account.kind === "managed" ? "Act as" : "Log in as"} + Login as diff --git a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx index 93fc30a14..4469591d9 100644 --- a/apps/dashboard/src/components/admin/ImpersonateDialog.tsx +++ b/apps/dashboard/src/components/admin/ImpersonateDialog.tsx @@ -95,11 +95,7 @@ export function ImpersonateDialog({
diff --git a/apps/dashboard/src/routes/_app/admin.$profileId.tsx b/apps/dashboard/src/routes/_app/admin.$profileId.tsx index 23069c7f8..c81bc7732 100644 --- a/apps/dashboard/src/routes/_app/admin.$profileId.tsx +++ b/apps/dashboard/src/routes/_app/admin.$profileId.tsx @@ -56,7 +56,7 @@ function AccountDetail() {

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

From 70398e548199d6575a54610e016dd2eca51edaa5 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 16:23:08 -0300 Subject: [PATCH 25/29] fix(api): enforce impersonation credential boundaries --- .../middlewares/managedProfileAuth.test.ts | 4 +-- .../src/api/middlewares/managedProfileAuth.ts | 5 ++- .../routes/v1/api-credentials.route.test.ts | 31 ++++++++++++++++--- .../api/routes/v1/api-credentials.route.ts | 3 +- .../api/routes/v1/managed-profiles.route.ts | 3 +- .../01-auth/admin-impersonation.md | 28 +++++++++-------- .../07-operations/api-surface.md | 2 +- docs/security-spec/RISK-REGISTER.md | 2 +- 8 files changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/api/src/api/middlewares/managedProfileAuth.test.ts b/apps/api/src/api/middlewares/managedProfileAuth.test.ts index 1b24fa545..9c8b13733 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.test.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.test.ts @@ -227,7 +227,7 @@ describe("authorizeManagedProfile", () => { expect(unsupported.statusCode).toBe(403); }); - it("applies current customer-type narrowing to every delegated decision", async () => { + it("does not apply customer-type narrowing to policy-free reads", async () => { allowManagedProfile(); ManagedProfileManager.findByPk = mock(async () => ({ allowedCorridors: ["BR"], @@ -238,7 +238,7 @@ describe("authorizeManagedProfile", () => { await authorizeManagedProfile()(request() as never, response() as never, next); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); }); it("requires the route customer type to match the immutable child entity type", async () => { diff --git a/apps/api/src/api/middlewares/managedProfileAuth.ts b/apps/api/src/api/middlewares/managedProfileAuth.ts index e231aa8b2..4575224ac 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.ts @@ -213,7 +213,10 @@ async function authorizeCustomerType( return false; } if ( - (allowedCustomerTypes !== null && allowedCustomerTypes !== undefined && !allowedCustomerTypes.includes(customerType)) || + ((options.corridor !== undefined || options.customerType !== undefined) && + allowedCustomerTypes !== null && + allowedCustomerTypes !== undefined && + !allowedCustomerTypes.includes(customerType)) || corridors.some(corridor => !isCorridorSupportedForCustomerType(corridor, customerType)) ) { sendAccessDenied(res); 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 index 1031fde17..eec41b644 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.test.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.test.ts @@ -1,15 +1,17 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } 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 { 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 /v1/api-credentials", () => { +describe("rejectImpersonation wiring on credential routes", () => { const originalImpersonationEnabled = config.impersonationEnabled; let server: ReturnType; let baseUrl: string; @@ -20,12 +22,13 @@ describe("rejectImpersonation wiring on /v1/api-credentials", () => { 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}${BASE_PATH}`; + baseUrl = `http://127.0.0.1:${address.port}`; }); afterAll(() => { @@ -45,9 +48,29 @@ describe("rejectImpersonation wiring on /v1/api-credentials", () => { 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, { headers: { Authorization: `Bearer ${token}` } }); + 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 } }; @@ -62,7 +85,7 @@ describe("rejectImpersonation wiring on /v1/api-credentials", () => { valid: true }); - const res = await fetch(baseUrl, { headers: { Authorization: "Bearer plain-supabase-token" } }); + 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 a04e669d0..7508389b2 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.ts @@ -6,8 +6,7 @@ import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); router.use(requireAuth); // A credential minted while acting as someone else would outlive the session. -router.use(rejectImpersonation); -router.post("/", createUserApiKey as unknown as (req: Request, res: Response) => void); +router.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); 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..20dd4e939 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"; @@ -17,7 +18,7 @@ router.use(requirePartnerOrUserAuth()); router.use(rejectDirectManagedCredential); router.post("/", 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.get("/:profileId", readManagedProfile); diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md index cc21c6e91..61668b85c 100644 --- a/docs/security-spec/01-auth/admin-impersonation.md +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -8,11 +8,11 @@ surface — the per-operator, Supabase-identity-bearing counterpart to the share are direct session targets. Managed headless profiles are reached by impersonating their authenticated manager and composing that session with the existing managed-profile selector. -Depth is broad but excludes ramp money movement and provider verification actions. While -impersonating, the operator may create quotes and inspect ramp and KYC/KYB status, history, and -errors, but ramp registration/update/start and KYC/KYB initiation, submission, upload, retry, and -OAuth actions reject the request. Other customer-account mutations remain available, so this is -not a general read-only impersonation mode (see the risk register, RISK-018). +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 is also denied because it would outlive the session +(see the risk register, RISK-018). ### Routes @@ -139,9 +139,10 @@ and requires deployment/database access rather than an HTTP credential — see ([`supabase-otp.md`](supabase-otp.md) invariant 3) — no controller or service sets it directly. 11. **An impersonated request MUST NOT be able to mint durable credentials** — - `rejectImpersonation` is applied ahead of `/v1/api-credentials` (`api-credentials.route.ts`): - a credential minted while acting as someone else would outlive the 30-minute session and - become a standing backdoor into the target's account. + `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 @@ -199,7 +200,7 @@ and requires deployment/database access rather than an HTTP credential — see |---|---|---| | Database dump exposes usable tokens | Attacker reads `admin_impersonation_sessions` from a backup or replica | Only a SHA-256 hash is stored; the raw token is never persisted (Invariant 2) | | Stolen or leaked impersonation token replayed after the operator's intent has ended | Token captured via logs, browser history, or a compromised operator device | 30-minute non-renewable TTL (Invariant 4); instant hash-based revocation via `DELETE /impersonation/:sessionId` (Invariant 8); re-checked liveness on every use (Invariant 5) | -| Impersonation used to mint a permanent backdoor | Operator (or an attacker who obtained an operator's token) mints an API secret key while impersonating, which outlives the session | `rejectImpersonation` on `/v1/api-credentials` (Invariant 11) | +| 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 | @@ -213,9 +214,9 @@ and requires deployment/database access rather than an HTTP credential — see ## Gaps Identified During This Review -- Ramp money movement and KYC/KYB actions are denied, but impersonation is still broader than a - read-only support mode: recipient, active-entity, and notification mutations remain available. A - compromised operator account can therefore still make sensitive changes to a customer's account. +- Ramp money movement and KYC/KYB actions are denied, but recipient, active-entity, and notification + mutations remain available. A compromised operator account can therefore still make sensitive + changes to a customer's account. 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 @@ -257,7 +258,8 @@ and requires deployment/database access rather than an HTTP credential — see token — **PASS**. - [x] `req.impersonation` is set only within `resolveBearerPrincipal()`, consumed by `supabaseAuth.ts` and `dualAuth.ts` — **PASS**. -- [x] `rejectImpersonation` blocks `/v1/api-credentials` (credential minting) — **PASS**. +- [x] `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`). diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 91b2a6639..c770ee795 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -73,7 +73,7 @@ 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, the manager's current customer-type narrowing on every delegated decision, every required corridor, 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. 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 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 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. 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. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 51be137ad..8428d8401 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -38,7 +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 are denied, but provider onboarding, KYC/KYB, recipient, active-entity, and notification mutations are not generally read-only. | 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, credential minting, and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before reducing the remaining mutation scope to a read-only investigate mode or before v2 sub-account delegation reuses this mechanism; see `01-auth/admin-impersonation.md`. | +| 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 plus provider onboarding and KYC/KYB mutations are denied; quote generation, recipient, active-entity, notification, and other customer-account operations remain available. | 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, durable credential minting for targets and managed children, 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 or before v2 sub-account delegation reuses this mechanism; 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. | From fd7eb10691fa37e5f64c55855771c878f7443dcf Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 17:30:25 -0300 Subject: [PATCH 26/29] fix(dashboard): serialize unit test execution --- apps/dashboard/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 59458ca8b9b284c320a3f89aaa87454c0109c2a8 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 17:48:04 -0300 Subject: [PATCH 27/29] test(dashboard): isolate transfer machine wallet imports --- .../dashboard/src/machines/transfer.machine.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/machines/transfer.machine.test.ts b/apps/dashboard/src/machines/transfer.machine.test.ts index c4a0727ab..8e6243e55 100644 --- a/apps/dashboard/src/machines/transfer.machine.test.ts +++ b/apps/dashboard/src/machines/transfer.machine.test.ts @@ -6,11 +6,22 @@ import { type RampProcess, type UnsignedTx } from "@vortexfi/shared"; +import { mock } from "bun:test"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createActor, fromPromise, waitFor } from "xstate"; import type { TransferQuoteRequest } from "./transfer.actors"; -import { transferMachine } from "./transfer.machine"; + +mock.module("@/services/transactions/userSigning", () => ({ + signAndSubmitEvmTransaction: () => { + throw new Error("Unexpected wallet signing in transfer machine test"); + }, + signMultipleTypedData: () => { + throw new Error("Unexpected wallet signing in transfer machine test"); + } +})); + +const { transferMachine } = await import("./transfer.machine"); const quote = { id: "quote-buy", rampType: RampDirection.BUY } as QuoteResponse; const quoteRequest: TransferQuoteRequest = { From 79cd0a045881d21b5af5ba778ab7c71921bcb9f5 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 18:03:45 -0300 Subject: [PATCH 28/29] fix(api): revalidate managed recipient access --- .../api/middlewares/bearerPrincipal.test.ts | 4 ++++ .../api/middlewares/managedProfileAuth.test.ts | 18 ++++++++++++++++++ .../src/api/middlewares/managedProfileAuth.ts | 3 ++- .../supabaseAuth.impersonation.test.ts | 4 ++++ apps/api/src/api/routes/v1/recipients.route.ts | 7 ++++++- apps/api/src/database/migrator.test.ts | 7 ++++--- .../src/tests/recipients.integration.test.ts | 2 +- .../security-spec/07-operations/api-surface.md | 2 +- 8 files changed, 40 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/middlewares/bearerPrincipal.test.ts b/apps/api/src/api/middlewares/bearerPrincipal.test.ts index c08d7c2d5..8cb142911 100644 --- a/apps/api/src/api/middlewares/bearerPrincipal.test.ts +++ b/apps/api/src/api/middlewares/bearerPrincipal.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, 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"; @@ -39,6 +40,7 @@ describe("resolveBearerPrincipal", () => { 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); @@ -79,6 +81,7 @@ describe("resolveBearerPrincipal", () => { 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) }); @@ -88,6 +91,7 @@ describe("resolveBearerPrincipal", () => { 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"); 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 4575224ac..4530c8d37 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.ts @@ -40,6 +40,7 @@ type CustomerTypeResolver = interface ManagedProfileAuthOptions { corridor?: CorridorResolver; customerType?: CustomerTypeResolver; + enforceCustomerTypePolicy?: boolean; } export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) { @@ -213,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/supabaseAuth.impersonation.test.ts b/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts index 697948552..9c53f36bd 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.impersonation.test.ts @@ -1,6 +1,7 @@ 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"; @@ -45,6 +46,7 @@ describe("Supabase auth middleware under impersonation", () => { 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}`); @@ -67,6 +69,7 @@ describe("Supabase auth middleware under impersonation", () => { 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}`); @@ -83,6 +86,7 @@ describe("Supabase auth middleware under impersonation", () => { 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}`); diff --git a/apps/api/src/api/routes/v1/recipients.route.ts b/apps/api/src/api/routes/v1/recipients.route.ts index 4870d3121..e9fbf8d17 100644 --- a/apps/api/src/api/routes/v1/recipients.route.ts +++ b/apps/api/src/api/routes/v1/recipients.route.ts @@ -55,7 +55,12 @@ router.post( * GET /v1/recipients * List the sender's recipients (relationship + onboarding status) and pending invitations. */ -router.get("/", requireAuth, authorizeManagedProfile(), 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 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/tests/recipients.integration.test.ts b/apps/api/src/tests/recipients.integration.test.ts index 288d2663b..670f4c4a0 100644 --- a/apps/api/src/tests/recipients.integration.test.ts +++ b/apps/api/src/tests/recipients.integration.test.ts @@ -751,7 +751,7 @@ describe("GET /v1/recipients", () => { await ManagedProfileManager.update({ allowedCustomerTypes: null }, { where: { profileId: manager.user.id } }); await ManagedProfile.update( - { status: "deleted" }, + { deletedAt: new Date(), status: "deleted" }, { where: { managerProfileId: manager.user.id, profileId: child.profileId } } ); expect((await api.request("/v1/recipients", { headers })).status).toBe(403); diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index c770ee795..1f10c670e 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -44,7 +44,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - 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. From 0e40aa945af9ecbaf0dd05659e56b15186595005 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 24 Aug 2026 19:42:04 -0300 Subject: [PATCH 29/29] fix(api): block impersonated lifecycle mutations --- .../routes/v1/api-credentials.route.test.ts | 39 +++++++++++++++++++ .../api/routes/v1/api-credentials.route.ts | 8 +++- .../api/routes/v1/managed-profiles.route.ts | 6 +-- docs/adr-0003-managed-headless-profiles.md | 4 +- docs/architecture-identity-model.md | 4 +- docs/product-dashboard.md | 7 ++++ .../01-auth/admin-impersonation.md | 25 +++++++++--- docs/security-spec/01-auth/api-keys.md | 6 +-- .../05-integrations/alfredpay.md | 4 +- .../security-spec/05-integrations/monerium.md | 2 + docs/security-spec/05-integrations/mykobo.md | 2 +- .../07-operations/api-surface.md | 2 +- docs/security-spec/RISK-REGISTER.md | 2 +- 13 files changed, 91 insertions(+), 20 deletions(-) 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 index eec41b644..f026606e0 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.test.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.test.ts @@ -1,6 +1,7 @@ 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"; @@ -77,6 +78,44 @@ describe("rejectImpersonation wiring on credential routes", () => { 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({ 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 7508389b2..52ee3e711 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.ts @@ -5,9 +5,13 @@ import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); router.use(requireAuth); -// A credential minted while acting as someone else would outlive the session. +// 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/managed-profiles.route.ts b/apps/api/src/api/routes/v1/managed-profiles.route.ts index 20dd4e939..322216581 100644 --- a/apps/api/src/api/routes/v1/managed-profiles.route.ts +++ b/apps/api/src/api/routes/v1/managed-profiles.route.ts @@ -16,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", 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/docs/adr-0003-managed-headless-profiles.md b/docs/adr-0003-managed-headless-profiles.md index 619992e71..a5603432a 100644 --- a/docs/adr-0003-managed-headless-profiles.md +++ b/docs/adr-0003-managed-headless-profiles.md @@ -45,7 +45,9 @@ requests. Historical and status reads remain available where reconciliation requ 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. +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/architecture-identity-model.md b/docs/architecture-identity-model.md index aff009359..bb6a918c6 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -171,7 +171,9 @@ 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 diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 33f8ab5c9..f74c7b733 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -201,6 +201,10 @@ unavailable and must not be shown as child operations. The dashboard API client 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. @@ -220,6 +224,9 @@ managed rows by child contact email, show the controlling manager's email, and o **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 diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md index 61668b85c..523393ac7 100644 --- a/docs/security-spec/01-auth/admin-impersonation.md +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -11,8 +11,10 @@ authenticated manager and composing that session with the existing managed-profi 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 is also denied because it would outlive the session -(see the risk register, RISK-018). +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 @@ -193,6 +195,13 @@ and requires deployment/database access rather than an HTTP credential — see 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 @@ -214,9 +223,10 @@ and requires deployment/database access rather than an HTTP credential — see ## Gaps Identified During This Review -- Ramp money movement and KYC/KYB actions are denied, but recipient, active-entity, and notification - mutations remain available. A compromised operator account can therefore still make sensitive - changes to a customer's account. +- 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 @@ -291,6 +301,11 @@ and requires deployment/database access rather than an HTTP credential — see - [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 — diff --git a/docs/security-spec/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index d51bc7a8c..13f8ccfa5 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -44,7 +44,7 @@ Possession of a public key never authorizes exact financial usage, provider iden ### 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,8 +97,8 @@ 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 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. 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 diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 5e7add48a..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. KYC/KYB action routes reject admin impersonation while status and business-detail reads remain available. 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. Admin impersonation is rejected before provider actions or multipart buffering. 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/monerium.md b/docs/security-spec/05-integrations/monerium.md index 54defa8d9..f7fadfac3 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -29,6 +29,7 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec 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 @@ -65,3 +66,4 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec - [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 0e793dd61..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 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. +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 1f10c670e..e7f7cd62b 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -75,7 +75,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 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, 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 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. +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 diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 8428d8401..928aa2e53 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -38,7 +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 plus provider onboarding and KYC/KYB mutations are denied; quote generation, recipient, active-entity, notification, and other customer-account operations remain available. | 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, durable credential minting for targets and managed children, 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 or before v2 sub-account delegation reuses this mechanism; see `01-auth/admin-impersonation.md`. | +| 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. |