Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cd6d1c6
feat(api): add vortex_admin profile role granted out-of-band
Sharqiewicz Aug 6, 2026
edcb207
feat(api): add admin impersonation session model and service
Sharqiewicz Aug 6, 2026
1724958
feat(api): resolve impersonation tokens on bearer-authenticated routes
Sharqiewicz Aug 6, 2026
abeeddc
feat(api): stamp impersonation context on api client events
Sharqiewicz Aug 6, 2026
65c99ff
feat(api): add vortex admin console accounts and impersonation routes
Sharqiewicz Aug 6, 2026
5a9f9a6
feat(dashboard): route requests through the active impersonation token
Sharqiewicz Aug 6, 2026
4505c5f
feat(dashboard): add admin console with impersonation controls
Sharqiewicz Aug 6, 2026
be0c582
docs: document admin impersonation trust chain and residual risk
Sharqiewicz Aug 6, 2026
47f548f
test(dashboard): cover impersonation store state transitions
Sharqiewicz Aug 6, 2026
94cc04c
Merge origin/staging into feat/subaccounts-dashboard
ebma Aug 7, 2026
e9eeae1
fix(api): harden impersonation session lifecycle
ebma Aug 7, 2026
0556d65
fix(api): preserve impersonation audit attribution
ebma Aug 7, 2026
2ba5490
fix(dashboard): make impersonation identity atomic
ebma Aug 7, 2026
dff0f59
fix(dashboard): repair nested admin routes
ebma Aug 7, 2026
94c4210
docs(repo): align impersonation contracts with implementation
ebma Aug 7, 2026
195fd7c
Merge branch 'managed-profiles' into feat/subaccounts-dashboard
gianfra-t Aug 13, 2026
ecd0cf8
docs(dashboard): specify managed child selection
gianfra-t Aug 13, 2026
7021224
feat(api): enable managed recipient delegation
gianfra-t Aug 13, 2026
8c9df19
feat(dashboard): add managed identity routing
gianfra-t Aug 13, 2026
f9151a5
feat(dashboard): add managed profile selection
gianfra-t Aug 13, 2026
b8ae609
docs(dashboard): record managed profile support
gianfra-t Aug 13, 2026
fc3394d
fix(api): block impersonated ramp mutations
gianfra-t Aug 21, 2026
aaa9368
Merge remote-tracking branch 'origin/staging' into feat/subaccounts-d…
gianfra-t Aug 24, 2026
85972f1
feat(dashboard): add managed profile admin access
gianfra-t Aug 24, 2026
092be0a
fix(dashboard): make acted-for verification read-only
gianfra-t Aug 24, 2026
03d6429
fix(dashboard): isolate wallet code from node tests
gianfra-t Aug 24, 2026
02c5aa4
style(dashboard): unify impersonation action labels
gianfra-t Aug 24, 2026
70398e5
fix(api): enforce impersonation credential boundaries
gianfra-t Aug 24, 2026
fd7eb10
fix(dashboard): serialize unit test execution
gianfra-t Aug 24, 2026
59458ca
test(dashboard): isolate transfer machine wallet imports
gianfra-t Aug 24, 2026
ecb4a56
Merge remote-tracking branch 'origin/staging' into feat/subaccounts-d…
gianfra-t Aug 24, 2026
79cd0a0
fix(api): revalidate managed recipient access
gianfra-t Aug 24, 2026
0e40aa9
fix(api): block impersonated lifecycle mutations
gianfra-t Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ ADMIN_SECRET=your-secure-admin-secret-here
# Use a different secret than ADMIN_SECRET to reduce blast radius.
METRICS_DASHBOARD_SECRET=your-secure-metrics-dashboard-secret-here

# Kill switch for vortex_admin "act as another profile" sessions. Off unless explicitly
# "true". Turning it off also invalidates sessions that are already in flight.
IMPERSONATION_ENABLED=false

# Supabase Configuration
SUPABASE_URL=https://your-project-id.supabase.co
SUPABASE_ANON_KEY=your-anon-key-here
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"build": "bun run swc src -d dist --strip-leading-paths",
"build:auth-emails": "bun src/scripts/auth-email-templates.ts",
"dev": "NODE_ENV=development bun --watch src/index.ts",
"grant:vortex-admin": "bun scripts/grant-vortex-admin.ts",
"migrate": "bun -r @swc-node/register src/database/migrator.ts",
"migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all",
"migrate:revert-last": "bun -r @swc-node/register src/database/migrator.ts revert",
Expand Down
37 changes: 37 additions & 0 deletions apps/api/scripts/grant-vortex-admin.ts
Original file line number Diff line number Diff line change
@@ -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 <email>
*/
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 <email>");
}

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();
}
290 changes: 290 additions & 0 deletions apps/api/src/api/controllers/admin-console/accounts.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import { Request, Response } from "express";
import httpStatus from "http-status";
import { literal, Op } from "sequelize";
import sequelize from "../../../config/database";
import logger from "../../../config/logger";
import AdminImpersonationSession from "../../../models/adminImpersonationSession.model";
import CustomerEntity from "../../../models/customerEntity.model";
import KycCase from "../../../models/kycCase.model";
import ManagedProfile from "../../../models/managedProfile.model";
import ManagedProfileManager from "../../../models/managedProfileManager.model";
import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model";
import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model";
import User from "../../../models/user.model";
import { isSessionActive } from "../../services/impersonation.service";

const DEFAULT_LIMIT = 25;
const MAX_LIMIT = 100;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function clampLimit(value: unknown): number {
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT;
return Math.min(parsed, MAX_LIMIT);
}

function parseCursor(value: unknown): number {
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
}

function emptyVerificationSummary(): Record<VerificationStatus, number> {
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<void> {
try {
const search = typeof req.query.search === "string" ? req.query.search.trim() : "";
const limit = clampLimit(req.query.limit);
const offset = parseCursor(req.query.cursor);
const searchPattern = `%${search.replace(/[\\%_]/g, "\\$&")}%`;
const managedIdentityMatch = sequelize.escape(searchPattern);

const { rows: profiles, count: total } = await User.findAndCountAll({
attributes: ["id", "email", "kind", "createdAt"],
limit: limit + 1,
offset,
order: [["createdAt", "DESC"]],
where: search
? {
[Op.or]: [
{ email: { [Op.iLike]: searchPattern } },
literal(`EXISTS (
SELECT 1
FROM managed_profiles AS managed
LEFT JOIN profiles AS manager_profile ON manager_profile.id = managed.manager_profile_id
WHERE managed.profile_id = "User".id
AND (
managed.contact_email ILIKE ${managedIdentityMatch}
OR managed.external_subject_id ILIKE ${managedIdentityMatch}
OR manager_profile.email ILIKE ${managedIdentityMatch}
)
)`)
]
}
: {}
});

const hasMore = profiles.length > limit;
const pageProfiles = hasMore ? profiles.slice(0, limit) : profiles;
const profileIds = pageProfiles.map(profile => profile.id);

const [entities, activeAssignments, managedRelationships] = await Promise.all([
profileIds.length ? CustomerEntity.findAll({ where: { profileId: profileIds } }) : [],
profileIds.length
? ProfilePartnerAssignment.findAll({
where: {
[Op.or]: [{ expiresAt: null }, { expiresAt: { [Op.gt]: new Date() } }],
isActive: true,
userId: profileIds
}
})
: [],
profileIds.length ? ManagedProfile.findAll({ where: { profileId: profileIds } }) : []
]);
const managerProfileIds = [...new Set(managedRelationships.map(relationship => relationship.managerProfileId))];
const [managerProfiles, managerConfigs] = await Promise.all([
managerProfileIds.length ? User.findAll({ attributes: ["id", "email"], where: { id: managerProfileIds } }) : [],
managerProfileIds.length ? ManagedProfileManager.findAll({ where: { profileId: managerProfileIds } }) : []
]);
const managedRelationshipByProfileId = new Map(
managedRelationships.map(relationship => [relationship.profileId, relationship])
);
const managerProfileById = new Map(managerProfiles.map(manager => [manager.id, manager]));
const managerConfigById = new Map(managerConfigs.map(manager => [manager.profileId, manager]));

const entityIds = entities.map(entity => entity.id);
const providerCustomers = entityIds.length
? await ProviderCustomer.findAll({ attributes: ["customerEntityId", "status"], where: { customerEntityId: entityIds } })
: [];
const entityProfileById = new Map(entities.map(entity => [entity.id, entity.profileId]));

res.status(httpStatus.OK).json({
accounts: pageProfiles.map(profile => {
const profileEntities = entities.filter(entity => entity.profileId === profile.id);
const managedRelationship = managedRelationshipByProfileId.get(profile.id);
const managerProfile = managedRelationship ? managerProfileById.get(managedRelationship.managerProfileId) : undefined;
const managerConfig = managedRelationship ? managerConfigById.get(managedRelationship.managerProfileId) : undefined;
const verificationSummary = emptyVerificationSummary();
for (const customer of providerCustomers) {
if (entityProfileById.get(customer.customerEntityId) === profile.id) {
verificationSummary[customer.status] += 1;
}
}

return {
activePartnerName: activeAssignments.find(assignment => assignment.userId === profile.id)?.partnerName ?? null,
createdAt: profile.createdAt,
email: profile.email,
entities: profileEntities.map(entity => ({ id: entity.id, status: entity.status, type: entity.type })),
id: profile.id,
kind: profile.kind,
managedProfile:
managedRelationship && managerProfile && managerConfig
? {
contactEmail: managedRelationship.contactEmail,
customerType: profileEntities[0]?.type ?? null,
externalSubjectId: managedRelationship.externalSubjectId,
manager: {
email: managerProfile.email,
isActive: managerConfig.isActive,
profileId: managerProfile.id
},
status: managedRelationship.status
}
: null,
verificationSummary
};
}),
limit,
nextCursor: hasMore ? String(offset + limit) : null,
total
});
} catch (error) {
logger.error("Error listing admin-console accounts:", error);
res.status(httpStatus.INTERNAL_SERVER_ERROR).json({
error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to list accounts", status: httpStatus.INTERNAL_SERVER_ERROR }
});
}
}

/**
* GET /v1/admin-console/accounts/:profileId
* Full account detail: entities, nested provider customers + KYC cases (mirrors the
* nesting in onboarding.controller.ts), and recent impersonation sessions targeting
* this profile.
*/
export async function getAccount(req: Request<{ profileId: string }>, res: Response): Promise<void> {
try {
const { profileId } = req.params;
if (!UUID_PATTERN.test(profileId)) {
res.status(httpStatus.BAD_REQUEST).json({
error: { code: "INVALID_PROFILE_ID", message: "profileId must be a valid UUID", status: httpStatus.BAD_REQUEST }
});
return;
}

const profile = await User.findByPk(profileId);
if (!profile) {
res.status(httpStatus.NOT_FOUND).json({
error: { code: "USER_NOT_FOUND", message: "Profile was not found", status: httpStatus.NOT_FOUND }
});
return;
}

const [entities, managedRelationship] = await Promise.all([
CustomerEntity.findAll({ where: { profileId } }),
ManagedProfile.findOne({ where: { profileId } })
]);
const entityIds = entities.map(entity => entity.id);

const [providerCustomers, kycCases, impersonationSessions, managerProfile, managerConfig] = await Promise.all([
entityIds.length
? ProviderCustomer.findAll({ order: [["updatedAt", "DESC"]], where: { customerEntityId: entityIds } })
: [],
entityIds.length ? KycCase.findAll({ where: { customerEntityId: entityIds } }) : [],
AdminImpersonationSession.findAll({
include: [{ as: "actor", attributes: ["id", "email"], model: User }],
limit: 20,
order: [["createdAt", "DESC"]],
where: { targetProfileId: profileId }
}),
managedRelationship ? User.findByPk(managedRelationship.managerProfileId, { attributes: ["id", "email"] }) : null,
managedRelationship ? ManagedProfileManager.findByPk(managedRelationship.managerProfileId) : null
]);

const kycCaseByProviderCustomer = new Map<string, KycCase>();
for (const kycCase of kycCases) {
if (kycCase.providerCustomerId) {
kycCaseByProviderCustomer.set(kycCase.providerCustomerId, kycCase);
}
}

res.status(httpStatus.OK).json({
activeEntityId: profile.activeCustomerEntityId,
createdAt: profile.createdAt,
email: profile.email,
entities: entities.map(entity => ({
country: entity.country,
id: entity.id,
providerCustomers: providerCustomers
.filter(customer => customer.customerEntityId === entity.id)
.map(customer => {
const kycCase = kycCaseByProviderCustomer.get(customer.id) ?? null;
return {
companyName: customer.companyName,
country: customer.country,
createdAt: customer.createdAt,
customerType: customer.customerType,
id: customer.id,
kycCase: kycCase
? {
approvedAt: kycCase.approvedAt,
failureReasons: kycCase.failureReasons,
id: kycCase.id,
level: kycCase.level,
rejectedAt: kycCase.rejectedAt,
status: kycCase.status,
statusExternal: kycCase.statusExternal,
submittedAt: kycCase.submittedAt,
type: kycCase.type
}
: null,
provider: customer.provider,
rail: customer.rail,
status: customer.status,
statusExternal: customer.statusExternal,
updatedAt: customer.updatedAt
};
}),
status: entity.status,
type: entity.type
})),
id: profile.id,
impersonationSessions: impersonationSessions.map(session => {
const actor = (session as AdminImpersonationSession & { actor?: User }).actor;
return {
active: isSessionActive(session),
actor: actor ? { email: actor.email, id: actor.id } : { email: null, id: session.actorProfileId },
createdAt: session.createdAt,
expiresAt: session.expiresAt,
id: session.id,
revokedAt: session.revokedAt,
revokedReason: session.revokedReason
};
}),
kind: profile.kind,
managedProfile:
managedRelationship && managerProfile && managerConfig
? {
contactEmail: managedRelationship.contactEmail,
customerType: entities[0]?.type ?? null,
externalSubjectId: managedRelationship.externalSubjectId,
manager: {
email: managerProfile.email,
isActive: managerConfig.isActive,
profileId: managerProfile.id
},
status: managedRelationship.status
}
: null
});
} catch (error) {
logger.error("Error reading admin-console account detail:", error);
res.status(httpStatus.INTERNAL_SERVER_ERROR).json({
error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to read account", status: httpStatus.INTERNAL_SERVER_ERROR }
});
}
}
Loading
Loading