diff --git a/backend/prisma/migrations/20260530000000_add_impersonation_sessions/migration.sql b/backend/prisma/migrations/20260530000000_add_impersonation_sessions/migration.sql new file mode 100644 index 00000000..5b80bb35 --- /dev/null +++ b/backend/prisma/migrations/20260530000000_add_impersonation_sessions/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "AdminImpersonationSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "actor" TEXT NOT NULL, + "apiKeyHash" TEXT NOT NULL, + "targetWallet" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "endedAt" DATETIME, + "status" TEXT NOT NULL DEFAULT 'active', + "ipAddress" TEXT NOT NULL, + "userAgent" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminImpersonationLedgerEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "metadata" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AdminImpersonationLedgerEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AdminImpersonationSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_status_idx" ON "AdminImpersonationSession"("status"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_actor_idx" ON "AdminImpersonationSession"("actor"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_targetWallet_idx" ON "AdminImpersonationSession"("targetWallet"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_startedAt_idx" ON "AdminImpersonationSession"("startedAt"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_expiresAt_idx" ON "AdminImpersonationSession"("expiresAt"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationLedgerEntry_sessionId_idx" ON "AdminImpersonationLedgerEntry"("sessionId"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationLedgerEntry_eventType_idx" ON "AdminImpersonationLedgerEntry"("eventType"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationLedgerEntry_createdAt_idx" ON "AdminImpersonationLedgerEntry"("createdAt"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 93bf529b..7b50377e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -175,6 +175,43 @@ model AdminActionReceipt { @@index([timestamp]) } +model AdminImpersonationSession { + id String @id @default(uuid()) + actor String + apiKeyHash String + targetWallet String + reason String + startedAt DateTime @default(now()) + expiresAt DateTime + endedAt DateTime? + status String @default("active") + ipAddress String + userAgent String + + ledgerEntries AdminImpersonationLedgerEntry[] + + @@index([status]) + @@index([actor]) + @@index([targetWallet]) + @@index([startedAt]) + @@index([expiresAt]) +} + +model AdminImpersonationLedgerEntry { + id String @id @default(uuid()) + sessionId String + eventType String + actor String + metadata String + createdAt DateTime @default(now()) + + session AdminImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([sessionId]) + @@index([eventType]) + @@index([createdAt]) +} + model EmailQueue { id String @id @default(uuid()) to String diff --git a/backend/src/__tests__/impersonationSessions.test.ts b/backend/src/__tests__/impersonationSessions.test.ts new file mode 100644 index 00000000..1ffa0255 --- /dev/null +++ b/backend/src/__tests__/impersonationSessions.test.ts @@ -0,0 +1,140 @@ +import request from 'supertest'; +import app from '../index'; +import { clearAdminAuditLogsForTests } from '../adminAudit'; +import { clearImpersonationSessionsForTests } from '../impersonationSessionService'; +import { registerApiKey } from '../middleware/apiKeyAuth'; +import { normalizeWalletAddress } from '../walletUtils'; + +describe('Impersonation session ledger', () => { + const superAdminApiKey = 'super-admin-impersonation-key'; + const adminApiKey = 'admin-impersonation-key'; + const targetWallet = normalizeWalletAddress( + 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + ); + const actingAdmin = 'GADMIN000000000000000000000000000000000000000000000001'; + + beforeEach(() => { + clearAdminAuditLogsForTests(); + clearImpersonationSessionsForTests(); + process.env.ADMIN_AUDIT_LOG_STORAGE = 'memory'; + process.env.IMPERSONATION_SESSION_STORAGE = 'memory'; + process.env.IMPERSONATION_SESSION_TTL_SECONDS = '900'; + registerApiKey(adminApiKey); + registerApiKey(superAdminApiKey, { role: 'super-admin' }); + }); + + async function startSession(reason = 'support investigation') { + return request(app) + .post('/admin/impersonate/sessions') + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin) + .send({ targetWallet, reason }); + } + + it('creates an immutable session record with actor, reason, and expiry', async () => { + const response = await startSession('customer support ticket #42'); + + expect(response.status).toBe(201); + expect(response.body.session).toMatchObject({ + actor: actingAdmin, + targetWallet, + reason: 'customer support ticket #42', + status: 'active', + }); + expect(response.body.session.id).toBeTruthy(); + expect(response.body.session.expiresAt).toBeTruthy(); + expect(Date.parse(response.body.session.expiresAt)).toBeGreaterThan(Date.now()); + }); + + it('requires a valid session to impersonate a wallet', async () => { + const sessionResponse = await startSession(); + const sessionId = sessionResponse.body.session.id; + + const response = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin) + .set('x-impersonation-session-id', sessionId); + + expect(response.status).toBe(200); + expect(response.body.walletAddress).toBe(targetWallet); + expect(response.body.impersonationSession).toMatchObject({ + id: sessionId, + reason: 'support investigation', + }); + }); + + it('rejects impersonation without a session header', async () => { + const response = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + expect(response.status).toBe(400); + expect(response.body.message).toMatch(/x-impersonation-session-id/i); + }); + + it('rejects expired sessions and requires a new session record', async () => { + process.env.IMPERSONATION_SESSION_TTL_SECONDS = '1'; + + const sessionResponse = await startSession(); + const sessionId = sessionResponse.body.session.id; + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + const expiredResponse = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin) + .set('x-impersonation-session-id', sessionId); + + expect(expiredResponse.status).toBe(403); + expect(expiredResponse.body.message).toMatch(/expired/i); + + const endResponse = await request(app) + .delete(`/admin/impersonate/sessions/${sessionId}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + expect(endResponse.status).toBe(404); + + const newSessionResponse = await startSession('renewed after expiry'); + expect(newSessionResponse.status).toBe(201); + expect(newSessionResponse.body.session.id).not.toBe(sessionId); + }); + + it('lists active and historical sessions for super-admins', async () => { + const sessionResponse = await startSession(); + const sessionId = sessionResponse.body.session.id; + + await request(app) + .delete(`/admin/impersonate/sessions/${sessionId}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + const listResponse = await request(app) + .get('/admin/impersonate/sessions') + .query({ status: 'all', limit: 10 }) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + expect(listResponse.status).toBe(200); + expect(listResponse.body.count).toBeGreaterThanOrEqual(1); + expect(listResponse.body.sessions[0]).toMatchObject({ + id: sessionId, + actor: actingAdmin, + targetWallet, + reason: 'support investigation', + }); + }); + + it('denies non-super-admin session management', async () => { + const response = await request(app) + .post('/admin/impersonate/sessions') + .set('Authorization', `ApiKey ${adminApiKey}`) + .set('x-admin-id', actingAdmin) + .send({ targetWallet, reason: 'should fail' }); + + expect(response.status).toBe(403); + }); +}); diff --git a/backend/src/impersonationSessionService.ts b/backend/src/impersonationSessionService.ts new file mode 100644 index 00000000..75a001b8 --- /dev/null +++ b/backend/src/impersonationSessionService.ts @@ -0,0 +1,499 @@ +import type { Request } from 'express'; +import { prisma } from './prisma'; +import { normalizeWalletAddress } from './walletUtils'; + +export type ImpersonationSessionStatus = 'active' | 'ended' | 'expired'; + +export type ImpersonationLedgerEventType = + | 'session.started' + | 'session.ended' + | 'session.expired' + | 'session.access'; + +export interface ImpersonationSessionRecord { + id: string; + actor: string; + apiKeyHash: string; + targetWallet: string; + reason: string; + startedAt: string; + expiresAt: string; + endedAt: string | null; + status: ImpersonationSessionStatus; + ipAddress: string; + userAgent: string; +} + +export interface ImpersonationLedgerEntryRecord { + id: string; + sessionId: string; + eventType: ImpersonationLedgerEventType; + actor: string; + metadata: Record; + createdAt: string; +} + +export interface StartImpersonationSessionInput { + actor: string; + apiKeyHash: string; + targetWallet: string; + reason: string; + ipAddress: string; + userAgent: string; +} + +export interface ListImpersonationSessionsFilters { + status?: ImpersonationSessionStatus | 'all'; + actor?: string; + targetWallet?: string; + limit: number; +} + +type StorageMode = 'memory' | 'prisma' | 'hybrid'; + +const inMemorySessions = new Map(); +const inMemoryLedger: ImpersonationLedgerEntryRecord[] = []; + +function normalizeStorageMode(raw: string | undefined): StorageMode { + if (raw === 'prisma' || raw === 'memory' || raw === 'hybrid') { + return raw; + } + return 'hybrid'; +} + +function getSessionTtlMs(): number { + const raw = process.env.IMPERSONATION_SESSION_TTL_SECONDS; + const seconds = raw ? Number.parseInt(raw, 10) : 900; + if (!Number.isFinite(seconds) || seconds <= 0) { + return 900_000; + } + return seconds * 1000; +} + +function createId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function resolveEffectiveStatus( + session: ImpersonationSessionRecord, + now = Date.now(), +): ImpersonationSessionStatus { + if (session.status === 'ended') { + return 'ended'; + } + if (Date.parse(session.expiresAt) <= now) { + return 'expired'; + } + return session.status === 'active' ? 'active' : session.status; +} + +function toSessionRecord(row: { + id: string; + actor: string; + apiKeyHash: string; + targetWallet: string; + reason: string; + startedAt: Date; + expiresAt: Date; + endedAt: Date | null; + status: string; + ipAddress: string; + userAgent: string; +}): ImpersonationSessionRecord { + return { + id: row.id, + actor: row.actor, + apiKeyHash: row.apiKeyHash, + targetWallet: row.targetWallet, + reason: row.reason, + startedAt: row.startedAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + endedAt: row.endedAt ? row.endedAt.toISOString() : null, + status: row.status as ImpersonationSessionStatus, + ipAddress: row.ipAddress, + userAgent: row.userAgent, + }; +} + +async function appendLedgerEntry( + storageMode: StorageMode, + entry: ImpersonationLedgerEntryRecord, +): Promise { + if (storageMode === 'memory') { + inMemoryLedger.unshift(entry); + return; + } + + try { + await prisma.adminImpersonationLedgerEntry.create({ + data: { + id: entry.id, + sessionId: entry.sessionId, + eventType: entry.eventType, + actor: entry.actor, + metadata: JSON.stringify(entry.metadata), + }, + }); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to persist impersonation ledger entry'); + } + inMemoryLedger.unshift(entry); + } +} + +async function persistSession( + storageMode: StorageMode, + session: ImpersonationSessionRecord, +): Promise { + if (storageMode === 'memory') { + inMemorySessions.set(session.id, session); + return; + } + + try { + await prisma.adminImpersonationSession.upsert({ + where: { id: session.id }, + create: { + id: session.id, + actor: session.actor, + apiKeyHash: session.apiKeyHash, + targetWallet: session.targetWallet, + reason: session.reason, + startedAt: new Date(session.startedAt), + expiresAt: new Date(session.expiresAt), + endedAt: session.endedAt ? new Date(session.endedAt) : null, + status: session.status, + ipAddress: session.ipAddress, + userAgent: session.userAgent, + }, + update: { + endedAt: session.endedAt ? new Date(session.endedAt) : null, + status: session.status, + }, + }); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to persist impersonation session'); + } + inMemorySessions.set(session.id, session); + } +} + +async function getSessionById( + storageMode: StorageMode, + sessionId: string, +): Promise { + if (storageMode === 'memory') { + return inMemorySessions.get(sessionId) || null; + } + + try { + const row = await prisma.adminImpersonationSession.findUnique({ + where: { id: sessionId }, + }); + return row ? toSessionRecord(row) : null; + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to read impersonation session'); + } + return inMemorySessions.get(sessionId) || null; + } +} + +export async function startImpersonationSession( + input: StartImpersonationSessionInput, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const now = Date.now(); + const targetWallet = normalizeWalletAddress(input.targetWallet); + const session: ImpersonationSessionRecord = { + id: createId('imp_sess'), + actor: input.actor, + apiKeyHash: input.apiKeyHash, + targetWallet, + reason: input.reason.trim(), + startedAt: new Date(now).toISOString(), + expiresAt: new Date(now + getSessionTtlMs()).toISOString(), + endedAt: null, + status: 'active', + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }; + + await persistSession(storageMode, session); + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: session.id, + eventType: 'session.started', + actor: input.actor, + metadata: { + targetWallet, + reason: session.reason, + expiresAt: session.expiresAt, + }, + createdAt: new Date(now).toISOString(), + }); + + return session; +} + +export async function endImpersonationSession( + sessionId: string, + actor: string, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const session = await getSessionById(storageMode, sessionId); + if (!session) { + return null; + } + + const effectiveStatus = resolveEffectiveStatus(session); + if (effectiveStatus === 'expired') { + return null; + } + if (effectiveStatus === 'ended') { + return session; + } + + const endedSession: ImpersonationSessionRecord = { + ...session, + status: 'ended', + endedAt: new Date().toISOString(), + }; + + await persistSession(storageMode, endedSession); + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: endedSession.id, + eventType: 'session.ended', + actor, + metadata: { + targetWallet: endedSession.targetWallet, + endedAt: endedSession.endedAt, + }, + createdAt: new Date().toISOString(), + }); + + return endedSession; +} + +async function markSessionExpired( + session: ImpersonationSessionRecord, + actor: string, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const expiredSession: ImpersonationSessionRecord = { + ...session, + status: 'expired', + endedAt: session.endedAt || new Date().toISOString(), + }; + + await persistSession(storageMode, expiredSession); + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: expiredSession.id, + eventType: 'session.expired', + actor, + metadata: { + targetWallet: expiredSession.targetWallet, + expiresAt: expiredSession.expiresAt, + }, + createdAt: new Date().toISOString(), + }); + + return expiredSession; +} + +export async function validateImpersonationSession( + sessionId: string, + targetWallet: string, + actor: string, +): Promise< + | { ok: true; session: ImpersonationSessionRecord } + | { ok: false; reason: 'not_found' | 'expired' | 'ended' | 'wallet_mismatch' | 'actor_mismatch' } +> { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const session = await getSessionById(storageMode, sessionId); + if (!session) { + return { ok: false, reason: 'not_found' }; + } + + if (session.actor !== actor) { + return { ok: false, reason: 'actor_mismatch' }; + } + + const normalizedTarget = normalizeWalletAddress(targetWallet); + if (session.targetWallet !== normalizedTarget) { + return { ok: false, reason: 'wallet_mismatch' }; + } + + const effectiveStatus = resolveEffectiveStatus(session); + if (effectiveStatus === 'ended') { + return { ok: false, reason: 'ended' }; + } + + if (effectiveStatus === 'expired') { + if (session.status !== 'expired') { + await markSessionExpired(session, actor); + } + return { ok: false, reason: 'expired' }; + } + + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: session.id, + eventType: 'session.access', + actor, + metadata: { + targetWallet: normalizedTarget, + }, + createdAt: new Date().toISOString(), + }); + + return { ok: true, session }; +} + +export async function listImpersonationSessions( + filters: ListImpersonationSessionsFilters, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const statusFilter = filters.status || 'all'; + + if (storageMode === 'memory') { + return Array.from(inMemorySessions.values()) + .filter((session) => { + const effectiveStatus = resolveEffectiveStatus(session); + if (statusFilter !== 'all' && effectiveStatus !== statusFilter) { + return false; + } + if (filters.actor && session.actor !== filters.actor) { + return false; + } + if (filters.targetWallet && session.targetWallet !== normalizeWalletAddress(filters.targetWallet)) { + return false; + } + return true; + }) + .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)) + .slice(0, filters.limit) + .map((session) => ({ + ...session, + status: resolveEffectiveStatus(session), + })); + } + + try { + const rows = await prisma.adminImpersonationSession.findMany({ + where: { + ...(filters.actor ? { actor: filters.actor } : {}), + ...(filters.targetWallet + ? { targetWallet: normalizeWalletAddress(filters.targetWallet) } + : {}), + ...(statusFilter !== 'all' ? { status: statusFilter } : {}), + }, + orderBy: { startedAt: 'desc' }, + take: filters.limit, + }); + + return rows.map((row) => { + const session = toSessionRecord(row); + return { + ...session, + status: resolveEffectiveStatus(session), + }; + }); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to list impersonation sessions'); + } + return Array.from(inMemorySessions.values()) + .filter((session) => { + const effectiveStatus = resolveEffectiveStatus(session); + if (statusFilter !== 'all' && effectiveStatus !== statusFilter) { + return false; + } + if (filters.actor && session.actor !== filters.actor) { + return false; + } + if (filters.targetWallet && session.targetWallet !== normalizeWalletAddress(filters.targetWallet)) { + return false; + } + return true; + }) + .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)) + .slice(0, filters.limit) + .map((session) => ({ + ...session, + status: resolveEffectiveStatus(session), + })); + } +} + +export async function listImpersonationLedgerEntries( + sessionId: string, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + + if (storageMode === 'memory') { + return inMemoryLedger + .filter((entry) => entry.sessionId === sessionId) + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + } + + try { + const rows = await prisma.adminImpersonationLedgerEntry.findMany({ + where: { sessionId }, + orderBy: { createdAt: 'desc' }, + }); + + return rows.map((row) => ({ + id: row.id, + sessionId: row.sessionId, + eventType: row.eventType as ImpersonationLedgerEventType, + actor: row.actor, + metadata: safeParseMetadata(row.metadata), + createdAt: row.createdAt.toISOString(), + })); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to list impersonation ledger entries'); + } + return inMemoryLedger.filter((entry) => entry.sessionId === sessionId); + } +} + +function safeParseMetadata(raw: string): Record { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +} + +export function resolveImpersonationSessionContext(req: Request): { + actor: string; + apiKeyHash: string; + ipAddress: string; + userAgent: string; +} { + return { + actor: + req.get('x-admin-address') || + req.get('x-admin-id') || + req.get('x-wallet-address') || + 'unknown', + apiKeyHash: req.authApiKeyHash || 'unknown', + ipAddress: req.ip || 'unknown', + userAgent: req.get('user-agent') || 'unknown', + }; +} + +export function clearImpersonationSessionsForTests(): void { + inMemorySessions.clear(); + inMemoryLedger.length = 0; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 1242e1ff..0244e03d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,13 @@ import { import { idempotencyStore } from './idempotency'; import { createAdminAuditMiddleware, getAuditLogs, getAuditLogMetrics } from './auditLog'; import { recordAdminAuditLog } from './adminAudit'; +import { + startImpersonationSession, + endImpersonationSession, + validateImpersonationSession, + listImpersonationSessions, + resolveImpersonationSessionContext, +} from './impersonationSessionService'; import { generateAdminReceipt, getAdminReceipt, listAdminReceipts, verifyReceiptSignature } from './adminReceipt'; import { startApySnapshotScheduler } from './apySnapshot'; import { sorobanCircuitBreaker } from './circuitBreaker'; @@ -1125,18 +1132,209 @@ app.get('/admin/allowlist', validateApiKey, (_req: Request, res: Response) => { }); /** - * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet + * POST /admin/impersonate/sessions - start a time-bounded impersonation session * Requires super-admin API key. */ +app.post('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const { actor, apiKeyHash, ipAddress, userAgent } = resolveImpersonationSessionContext(req); + const targetWallet = typeof req.body?.targetWallet === 'string' ? req.body.targetWallet.trim() : ''; + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + targetWallet: targetWallet || 'unknown', + impersonation: true, + }; + + if (!targetWallet || !reason) { + req.adminAuditAction = 'admin.impersonate.session.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'targetWallet and reason are required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required for impersonation sessions', + }); + return; + } + + try { + const session = await startImpersonationSession({ + actor: actingAdminAddress, + apiKeyHash, + targetWallet, + reason, + ipAddress, + userAgent, + }); + + req.adminAuditAction = 'admin.impersonate.session.started'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + sessionId: session.id, + expiresAt: session.expiresAt, + }; + + res.status(201).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to start impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/sessions - list active and historical impersonation sessions + * Requires super-admin API key. + */ +app.get('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + impersonation: true, + }; + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.list.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to list impersonation sessions', + }); + return; + } + + const statusRaw = typeof req.query.status === 'string' ? req.query.status : 'all'; + const status = + statusRaw === 'active' || statusRaw === 'ended' || statusRaw === 'expired' ? statusRaw : 'all'; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const targetWallet = typeof req.query.targetWallet === 'string' ? req.query.targetWallet : undefined; + const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50; + + try { + const sessions = await listImpersonationSessions({ + status, + actor, + targetWallet, + limit: Number.isFinite(limit) && limit > 0 ? limit : 50, + }); + + req.adminAuditAction = 'admin.impersonate.session.list'; + res.status(200).json({ sessions, count: sessions.length }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.list.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to list impersonation sessions', + }); + } +}); + +/** + * DELETE /admin/impersonate/sessions/:id - end an active impersonation session + * Requires super-admin API key. + */ +app.delete('/admin/impersonate/sessions/:id', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.params.id || '').trim(); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + sessionId: sessionId || 'unknown', + impersonation: true, + }; + + if (!sessionId) { + req.adminAuditAction = 'admin.impersonate.session.end.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'session id is required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.end.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to end impersonation sessions', + }); + return; + } + + try { + const session = await endImpersonationSession(sessionId, actingAdminAddress); + if (!session) { + req.adminAuditAction = 'admin.impersonate.session.end.not_found'; + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Impersonation session not found or already expired', + }); + return; + } + + req.adminAuditAction = 'admin.impersonate.session.ended'; + res.status(200).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.end.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to end impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet + * Requires super-admin API key and a valid non-expired impersonation session. + */ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: Response) => { const wallet = String(req.params.wallet || '').trim(); const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.get('x-impersonation-session-id') || '').trim(); req.adminAuditActor = actingAdminAddress; req.adminAuditMetadata = { actingAdminAddress, adminRole: req.authApiKeyRole || 'admin', targetWallet: wallet || 'unknown', + sessionId: sessionId || undefined, impersonation: true, }; @@ -1160,11 +1358,56 @@ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: return; } + if (!sessionId) { + req.adminAuditAction = 'admin.impersonate.session.required'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'x-impersonation-session-id header is required', + }); + return; + } + + const validation = await validateImpersonationSession(sessionId, wallet, actingAdminAddress); + if (!validation.ok) { + const statusCode = validation.reason === 'not_found' ? 404 : 403; + req.adminAuditAction = + validation.reason === 'expired' + ? 'admin.impersonate.expired' + : 'admin.impersonate.session.invalid'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + validationReason: validation.reason, + }; + res.status(statusCode).json({ + error: statusCode === 404 ? 'Not Found' : 'Forbidden', + status: statusCode, + message: + validation.reason === 'expired' + ? 'Impersonation session has expired; start a new session to continue' + : validation.reason === 'ended' + ? 'Impersonation session has ended; start a new session to continue' + : validation.reason === 'wallet_mismatch' + ? 'Session target wallet does not match requested wallet' + : validation.reason === 'actor_mismatch' + ? 'Session actor does not match requesting admin' + : 'Impersonation session not found', + }); + return; + } + req.adminAuditAction = 'admin.impersonate'; try { const snapshot = await buildImpersonatedVaultState(wallet); - res.status(200).json(snapshot); + res.status(200).json({ + ...snapshot, + impersonationSession: { + id: validation.session.id, + expiresAt: validation.session.expiresAt, + reason: validation.session.reason, + }, + }); } catch (error) { req.adminAuditAction = 'admin.impersonate.failed'; req.adminAuditMetadata = { diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index 4ea55949..a7d784c6 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -88,12 +88,19 @@ export function getTracer(): Tracer { * Wraps an async function in a named span. * Automatically records exceptions and sets error status. */ +const NOOP_SPAN = { + setAttributes: () => {}, + setStatus: () => {}, + recordException: () => {}, + end: () => {}, +} as unknown as Span; + export async function withSpan( name: string, fn: (span: Span) => Promise, attributes?: Record, ): Promise { - if (!OTEL_ENABLED) return fn(trace.getTracer(SERVICE_NAME).startSpan(name)); + if (!OTEL_ENABLED) return fn(NOOP_SPAN); const tracer = getTracer(); return tracer.startActiveSpan(name, async (span) => {