From 8b45530ae53b8f0fc98ac63a73fe54412cca7641 Mon Sep 17 00:00:00 2001 From: DevNetlife Date: Sat, 29 Aug 2026 08:20:04 +0100 Subject: [PATCH 01/12] feat: asset lifecycle timeline, permission notifications, session devices, and impersonation safeguards Implements four platform security, governance, and asset lifecycle capabilities: - #1040 Asset Lifecycle State Timeline: asset state transition recording, history querying, stats, and frontend UI. - #1176 Permission Change Notifications: notification creation, user inbox list, status tracking, and dispatching. - #1173 Session Device Management: session device registration, active session list, revocation, and device trust toggles. - #1175 Admin Impersonation Safeguards: time-bounded admin impersonation sessions with ticket justification, audit logging, auto-expiration, and frontend safeguards. Includes Knex database migrations, Fastify routes, backend services, Vitest unit tests, React UI pages, and operational handbooks. Closes #1040 Closes #1176 Closes #1173 Closes #1175 --- .../api/routes/adminImpersonation.routes.ts | 131 +++++++ .../routes/assetLifecycleTimeline.routes.ts | 107 ++++++ .../permissionChangeNotification.routes.ts | 116 ++++++ .../api/routes/route-groups/admin-routes.ts | 14 + .../api/routes/route-groups/asset-routes.ts | 4 + .../api/routes/route-groups/utility-routes.ts | 2 + .../src/api/routes/sessionDevice.routes.ts | 141 ++++++++ ...29000010_asset_lifecycle_state_timeline.ts | 24 ++ ...9000020_permission_change_notifications.ts | 25 ++ ...0260829000030_session_device_management.ts | 28 ++ ...29000040_admin_impersonation_safeguards.ts | 44 +++ .../services/adminImpersonation.service.ts | 249 +++++++++++++ .../assetLifecycleTimeline.service.ts | 187 ++++++++++ .../permissionChangeNotification.service.ts | 209 +++++++++++ backend/src/services/sessionDevice.service.ts | 205 +++++++++++ .../adminImpersonation.service.test.ts | 151 ++++++++ .../assetLifecycleTimeline.service.test.ts | 122 +++++++ ...rmissionChangeNotification.service.test.ts | 120 ++++++ .../services/sessionDevice.service.test.ts | 118 ++++++ docs/admin-impersonation-safeguards.md | 44 +++ docs/asset-lifecycle-timeline.md | 37 ++ docs/permission-change-notifications.md | 37 ++ docs/session-device-management.md | 39 ++ frontend/src/App.tsx | 16 + .../src/components/MobileNav/navigation.ts | 4 + frontend/src/pages/AssetLifecycleTimeline.tsx | 281 ++++++++++++++ .../pages/PermissionChangeNotifications.tsx | 240 ++++++++++++ .../src/pages/SessionDeviceManagement.tsx | 261 +++++++++++++ .../admin/AdminImpersonationSafeguards.tsx | 342 ++++++++++++++++++ frontend/src/services/api.ts | 153 ++++++++ frontend/src/types/index.ts | 108 ++++++ package-lock.json | 26 ++ 32 files changed, 3585 insertions(+) create mode 100644 backend/src/api/routes/adminImpersonation.routes.ts create mode 100644 backend/src/api/routes/assetLifecycleTimeline.routes.ts create mode 100644 backend/src/api/routes/permissionChangeNotification.routes.ts create mode 100644 backend/src/api/routes/sessionDevice.routes.ts create mode 100644 backend/src/database/migrations/20260829000010_asset_lifecycle_state_timeline.ts create mode 100644 backend/src/database/migrations/20260829000020_permission_change_notifications.ts create mode 100644 backend/src/database/migrations/20260829000030_session_device_management.ts create mode 100644 backend/src/database/migrations/20260829000040_admin_impersonation_safeguards.ts create mode 100644 backend/src/services/adminImpersonation.service.ts create mode 100644 backend/src/services/assetLifecycleTimeline.service.ts create mode 100644 backend/src/services/permissionChangeNotification.service.ts create mode 100644 backend/src/services/sessionDevice.service.ts create mode 100644 backend/tests/services/adminImpersonation.service.test.ts create mode 100644 backend/tests/services/assetLifecycleTimeline.service.test.ts create mode 100644 backend/tests/services/permissionChangeNotification.service.test.ts create mode 100644 backend/tests/services/sessionDevice.service.test.ts create mode 100644 docs/admin-impersonation-safeguards.md create mode 100644 docs/asset-lifecycle-timeline.md create mode 100644 docs/permission-change-notifications.md create mode 100644 docs/session-device-management.md create mode 100644 frontend/src/pages/AssetLifecycleTimeline.tsx create mode 100644 frontend/src/pages/PermissionChangeNotifications.tsx create mode 100644 frontend/src/pages/SessionDeviceManagement.tsx create mode 100644 frontend/src/pages/admin/AdminImpersonationSafeguards.tsx diff --git a/backend/src/api/routes/adminImpersonation.routes.ts b/backend/src/api/routes/adminImpersonation.routes.ts new file mode 100644 index 00000000..95907e3c --- /dev/null +++ b/backend/src/api/routes/adminImpersonation.routes.ts @@ -0,0 +1,131 @@ +import type { FastifyInstance } from "fastify"; +import { + adminImpersonationService, + type ImpersonationStatus, +} from "../../services/adminImpersonation.service.js"; +import { sendApiError } from "../utils/response.js"; +import { authMiddleware } from "../middleware/auth.js"; + +interface StartImpersonationBody { + adminId?: string; + impersonatedUserId: string; + reason: string; + approvalTicketId?: string; + durationMinutes?: number; +} + +interface StopImpersonationBody { + sessionId: string; + adminId?: string; +} + +export async function adminImpersonationRoutes(server: FastifyInstance) { + const requireAdmin = authMiddleware({ requiredScopes: ["admin:access"] }); + + // Start impersonation session + server.post<{ Body: StartImpersonationBody }>( + "/start", + { preHandler: requireAdmin }, + async (request, reply) => { + const { adminId, impersonatedUserId, reason, approvalTicketId, durationMinutes } = + request.body; + + const resolvedAdminId = adminId ?? request.apiKeyAuth?.name ?? "admin"; + const ipAddress = request.ip ?? "127.0.0.1"; + + if (!impersonatedUserId?.trim()) { + return sendApiError(reply, 400, "impersonatedUserId is required"); + } + if (!reason?.trim()) { + return sendApiError( + reply, + 400, + "Mandatory justification reason is required for admin impersonation" + ); + } + + try { + const result = await adminImpersonationService.startSession({ + adminId: resolvedAdminId, + impersonatedUserId, + reason, + approvalTicketId, + durationMinutes, + ipAddress, + }); + + return reply.code(201).send(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to start impersonation"; + return sendApiError(reply, 400, message); + } + } + ); + + // Stop impersonation session + server.post<{ Body: StopImpersonationBody }>( + "/stop", + { preHandler: requireAdmin }, + async (request, reply) => { + const { sessionId, adminId } = request.body; + const resolvedAdminId = adminId ?? request.apiKeyAuth?.name ?? "admin"; + + if (!sessionId?.trim()) { + return sendApiError(reply, 400, "sessionId is required"); + } + + const session = await adminImpersonationService.endSession( + sessionId, + resolvedAdminId + ); + + if (!session) { + return sendApiError(reply, 404, "Active impersonation session not found"); + } + + return { session }; + } + ); + + // List impersonation sessions + server.get<{ + Querystring: { + adminId?: string; + impersonatedUserId?: string; + status?: ImpersonationStatus; + limit?: string; + offset?: string; + }; + }>( + "/sessions", + { preHandler: requireAdmin }, + async (request) => { + const sessions = await adminImpersonationService.listSessions({ + adminId: request.query.adminId, + impersonatedUserId: request.query.impersonatedUserId, + status: request.query.status, + limit: request.query.limit ? Number(request.query.limit) : undefined, + offset: request.query.offset ? Number(request.query.offset) : undefined, + }); + + return { sessions }; + } + ); + + // Get audit logs for session + server.get<{ Querystring: { sessionId: string } }>( + "/audit-logs", + { preHandler: requireAdmin }, + async (request, reply) => { + const { sessionId } = request.query; + + if (!sessionId?.trim()) { + return sendApiError(reply, 400, "sessionId is required"); + } + + const auditLogs = await adminImpersonationService.getAuditLogs(sessionId); + return { auditLogs }; + } + ); +} diff --git a/backend/src/api/routes/assetLifecycleTimeline.routes.ts b/backend/src/api/routes/assetLifecycleTimeline.routes.ts new file mode 100644 index 00000000..7755ec72 --- /dev/null +++ b/backend/src/api/routes/assetLifecycleTimeline.routes.ts @@ -0,0 +1,107 @@ +import type { FastifyInstance } from "fastify"; +import { + assetLifecycleTimelineService, + type AssetState, +} from "../../services/assetLifecycleTimeline.service.js"; +import { sendApiError } from "../utils/response.js"; +import { authMiddleware } from "../middleware/auth.js"; + +interface RecordTransitionBody { + assetId: string; + assetSymbol: string; + state: AssetState; + previousState?: AssetState; + reason?: string; + triggeredBy?: string; + metadata?: Record; +} + +export async function assetLifecycleTimelineRoutes(server: FastifyInstance) { + const requireAuth = authMiddleware(); + + // Record a new asset state transition + server.post<{ Body: RecordTransitionBody }>( + "/", + { preHandler: requireAuth }, + async (request, reply) => { + const { assetId, assetSymbol, state, previousState, reason, triggeredBy, metadata } = + request.body; + + if (!assetId?.trim() || !assetSymbol?.trim()) { + return sendApiError(reply, 400, "assetId and assetSymbol are required"); + } + if (!state) { + return sendApiError(reply, 400, "state is required"); + } + + try { + const record = await assetLifecycleTimelineService.recordTransition({ + assetId, + assetSymbol, + state, + previousState, + reason, + triggeredBy: triggeredBy ?? request.apiKeyAuth?.name ?? "admin", + metadata, + }); + return reply.code(201).send({ record }); + } catch (error) { + const message = error instanceof Error ? error.message : "State transition failed"; + return sendApiError(reply, 400, message); + } + } + ); + + // Get timeline entries with optional filters + server.get<{ + Querystring: { + assetId?: string; + state?: AssetState; + startDate?: string; + endDate?: string; + limit?: string; + offset?: string; + }; + }>( + "/", + { preHandler: requireAuth }, + async (request) => { + const records = await assetLifecycleTimelineService.getTimeline( + request.query.assetId, + { + state: request.query.state, + startDate: request.query.startDate, + endDate: request.query.endDate, + limit: request.query.limit ? Number(request.query.limit) : undefined, + offset: request.query.offset ? Number(request.query.offset) : undefined, + } + ); + return { records }; + } + ); + + // Get timeline stats + server.get( + "/stats", + { preHandler: requireAuth }, + async () => { + const stats = await assetLifecycleTimelineService.getStats(); + return { stats }; + } + ); + + // Get latest state for a given asset + server.get<{ Params: { assetId: string } }>( + "/latest/:assetId", + { preHandler: requireAuth }, + async (request, reply) => { + const record = await assetLifecycleTimelineService.getLatestState( + request.params.assetId + ); + if (!record) { + return sendApiError(reply, 404, "No lifecycle history found for asset"); + } + return { record }; + } + ); +} diff --git a/backend/src/api/routes/permissionChangeNotification.routes.ts b/backend/src/api/routes/permissionChangeNotification.routes.ts new file mode 100644 index 00000000..acca9b90 --- /dev/null +++ b/backend/src/api/routes/permissionChangeNotification.routes.ts @@ -0,0 +1,116 @@ +import type { FastifyInstance } from "fastify"; +import { + permissionChangeNotificationService, + type PermissionAction, + type NotificationChannel, + type NotificationStatus, +} from "../../services/permissionChangeNotification.service.js"; +import { sendApiError } from "../utils/response.js"; +import { authMiddleware } from "../middleware/auth.js"; + +interface CreateNotificationBody { + targetUserId: string; + actorId?: string; + action: PermissionAction; + permissionOrRole: string; + channels?: NotificationChannel[]; + details?: Record; +} + +export async function permissionChangeNotificationRoutes(server: FastifyInstance) { + const requireAuth = authMiddleware(); + + // Create & dispatch permission change notification + server.post<{ Body: CreateNotificationBody }>( + "/", + { preHandler: requireAuth }, + async (request, reply) => { + const { targetUserId, actorId, action, permissionOrRole, channels, details } = + request.body; + + if (!targetUserId?.trim() || !action || !permissionOrRole?.trim()) { + return sendApiError( + reply, + 400, + "targetUserId, action, and permissionOrRole are required" + ); + } + + try { + const notification = await permissionChangeNotificationService.notify({ + targetUserId, + actorId: actorId ?? request.apiKeyAuth?.name ?? "system", + action, + permissionOrRole, + channels, + details, + }); + return reply.code(201).send({ notification }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Notification dispatch failed"; + return sendApiError(reply, 400, message); + } + } + ); + + // List notifications for a user + server.get<{ + Querystring: { + targetUserId?: string; + status?: NotificationStatus; + unreadOnly?: string; + limit?: string; + offset?: string; + }; + }>( + "/", + { preHandler: requireAuth }, + async (request, reply) => { + const userId = request.query.targetUserId ?? request.apiKeyAuth?.name ?? "default_user"; + + if (!userId) { + return sendApiError(reply, 400, "targetUserId is required"); + } + + const notifications = + await permissionChangeNotificationService.listUserNotifications(userId, { + status: request.query.status, + unreadOnly: request.query.unreadOnly === "true", + limit: request.query.limit ? Number(request.query.limit) : undefined, + offset: request.query.offset ? Number(request.query.offset) : undefined, + }); + + return { notifications }; + } + ); + + // Mark notification as read + server.patch<{ Params: { id: string }; Body: { targetUserId?: string } }>( + "/:id/read", + { preHandler: requireAuth }, + async (request, reply) => { + const userId = request.body?.targetUserId ?? request.apiKeyAuth?.name ?? "default_user"; + const notification = await permissionChangeNotificationService.markAsRead( + request.params.id, + userId + ); + + if (!notification) { + return sendApiError(reply, 404, "Notification not found or already read"); + } + + return { notification }; + } + ); + + // Get notification stats + server.get( + "/stats", + { preHandler: requireAuth }, + async () => { + const stats = await permissionChangeNotificationService.getStats(); + return { stats }; + } + ); +} diff --git a/backend/src/api/routes/route-groups/admin-routes.ts b/backend/src/api/routes/route-groups/admin-routes.ts index 03e9acfe..9694cd4e 100644 --- a/backend/src/api/routes/route-groups/admin-routes.ts +++ b/backend/src/api/routes/route-groups/admin-routes.ts @@ -33,6 +33,10 @@ import { importValidationPreviewRoutes } from "../importValidationPreview.routes import { apiKeyScopeTemplateRoutes } from "../apiKeyScopeTemplate.routes.js"; // #1168 — Failed Parse Quarantine Queue import { parseQuarantineQueueRoutes } from "../parseQuarantineQueue.routes.js"; +// #1175 — Admin Impersonation Safeguards +import { adminImpersonationRoutes } from "../adminImpersonation.routes.js"; +// #1176 — Permission Change Notifications +import { permissionChangeNotificationRoutes } from "../permissionChangeNotification.routes.js"; export async function registerAdminRoutes(server: FastifyInstance): Promise { server.register(apiKeysRoutes, { prefix: "/api/v1/admin/api-keys" }); @@ -121,5 +125,15 @@ export async function registerAdminRoutes(server: FastifyInstance): Promise { server.register(assetsRoutes, { prefix: "/api/v1/assets" }); @@ -13,4 +14,7 @@ export async function registerAssetRoutes(server: FastifyInstance): Promise { server.register(exportsRoutes, { prefix: "/api/v1/exports" }); @@ -46,4 +47,5 @@ export async function registerUtilityRoutes(server: FastifyInstance): Promise( + "/register", + { preHandler: requireAuth }, + async (request, reply) => { + const { + userId, + deviceFingerprint, + deviceName, + deviceType, + ipAddress, + location, + userAgent, + } = request.body; + + const targetUserId = userId ?? request.apiKeyAuth?.name ?? "default_user"; + const resolvedIp = ipAddress ?? request.ip ?? "127.0.0.1"; + + if (!deviceFingerprint?.trim() || !deviceName?.trim()) { + return sendApiError( + reply, + 400, + "deviceFingerprint and deviceName are required" + ); + } + + try { + const device = await sessionDeviceService.registerOrUpdateDevice({ + userId: targetUserId, + deviceFingerprint, + deviceName, + deviceType, + ipAddress: resolvedIp, + location, + userAgent: userAgent ?? request.headers["user-agent"], + }); + return reply.code(201).send({ device }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Device registration failed"; + return sendApiError(reply, 400, message); + } + } + ); + + // List session devices for user + server.get<{ Querystring: { userId?: string } }>( + "/", + { preHandler: requireAuth }, + async (request) => { + const userId = request.query.userId ?? request.apiKeyAuth?.name ?? "default_user"; + const devices = await sessionDeviceService.getUserDevices(userId); + return { devices }; + } + ); + + // Revoke single device session + server.delete<{ Params: { deviceId: string }; Querystring: { userId?: string } }>( + "/:deviceId", + { preHandler: requireAuth }, + async (request, reply) => { + const userId = request.query.userId ?? request.apiKeyAuth?.name ?? "default_user"; + const device = await sessionDeviceService.revokeDevice( + userId, + request.params.deviceId + ); + + if (!device) { + return sendApiError(reply, 404, "Device session not found"); + } + + return { device }; + } + ); + + // Revoke all other active device sessions + server.post<{ Body: { currentDeviceId: string; userId?: string } }>( + "/revoke-others", + { preHandler: requireAuth }, + async (request, reply) => { + const { currentDeviceId, userId } = request.body; + const targetUserId = userId ?? request.apiKeyAuth?.name ?? "default_user"; + + if (!currentDeviceId?.trim()) { + return sendApiError(reply, 400, "currentDeviceId is required"); + } + + const revokedCount = await sessionDeviceService.revokeOtherDevices( + targetUserId, + currentDeviceId + ); + + return { revokedCount }; + } + ); + + // Toggle device trust status + server.patch<{ Params: { deviceId: string }; Body: SetTrustBody }>( + "/:deviceId/trust", + { preHandler: requireAuth }, + async (request, reply) => { + const userId = request.apiKeyAuth?.name ?? "default_user"; + const device = await sessionDeviceService.setTrustStatus( + userId, + request.params.deviceId, + request.body.isTrusted + ); + + if (!device) { + return sendApiError(reply, 404, "Device session not found"); + } + + return { device }; + } + ); +} diff --git a/backend/src/database/migrations/20260829000010_asset_lifecycle_state_timeline.ts b/backend/src/database/migrations/20260829000010_asset_lifecycle_state_timeline.ts new file mode 100644 index 00000000..1f0cd746 --- /dev/null +++ b/backend/src/database/migrations/20260829000010_asset_lifecycle_state_timeline.ts @@ -0,0 +1,24 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("asset_lifecycle_timeline", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("asset_id", 120).notNullable(); + table.string("asset_symbol", 60).notNullable(); + table.string("state", 60).notNullable(); + table.string("previous_state", 60); + table.text("reason"); + table.string("triggered_by", 120).notNullable(); + table.jsonb("metadata").notNullable().defaultTo(knex.raw("'{}'::jsonb")); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["asset_id"]); + table.index(["state"]); + table.index(["created_at"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("asset_lifecycle_timeline"); +} diff --git a/backend/src/database/migrations/20260829000020_permission_change_notifications.ts b/backend/src/database/migrations/20260829000020_permission_change_notifications.ts new file mode 100644 index 00000000..2ac9e9af --- /dev/null +++ b/backend/src/database/migrations/20260829000020_permission_change_notifications.ts @@ -0,0 +1,25 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("permission_change_notifications", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("target_user_id", 120).notNullable(); + table.string("actor_id", 120).notNullable(); + table.string("action", 60).notNullable(); + table.string("permission_or_role", 120).notNullable(); + table.jsonb("channels").notNullable().defaultTo(knex.raw('\'["IN_APP"]\'::jsonb')); + table.string("status", 40).notNullable().defaultTo("PENDING"); + table.jsonb("details").notNullable().defaultTo(knex.raw("'{}'::jsonb")); + table.timestamp("read_at", { useTz: true }); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["target_user_id"]); + table.index(["status"]); + table.index(["created_at"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("permission_change_notifications"); +} diff --git a/backend/src/database/migrations/20260829000030_session_device_management.ts b/backend/src/database/migrations/20260829000030_session_device_management.ts new file mode 100644 index 00000000..97b4af84 --- /dev/null +++ b/backend/src/database/migrations/20260829000030_session_device_management.ts @@ -0,0 +1,28 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("user_session_devices", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("user_id", 120).notNullable(); + table.string("device_fingerprint", 120).notNullable(); + table.string("device_name", 120).notNullable(); + table.string("device_type", 40).notNullable().defaultTo("DESKTOP"); + table.string("ip_address", 60).notNullable(); + table.string("location", 120); + table.text("user_agent"); + table.boolean("is_active").notNullable().defaultTo(true); + table.boolean("is_trusted").notNullable().defaultTo(false); + table.timestamp("last_active_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("revoked_at", { useTz: true }); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["user_id"]); + table.index(["device_fingerprint"]); + table.index(["is_active"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("user_session_devices"); +} diff --git a/backend/src/database/migrations/20260829000040_admin_impersonation_safeguards.ts b/backend/src/database/migrations/20260829000040_admin_impersonation_safeguards.ts new file mode 100644 index 00000000..a16f646f --- /dev/null +++ b/backend/src/database/migrations/20260829000040_admin_impersonation_safeguards.ts @@ -0,0 +1,44 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("admin_impersonation_sessions", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("admin_id", 120).notNullable(); + table.string("impersonated_user_id", 120).notNullable(); + table.text("reason").notNullable(); + table.string("approval_ticket_id", 120); + table.string("status", 40).notNullable().defaultTo("ACTIVE"); + table.string("token_hash", 120).notNullable(); + table.integer("max_duration_minutes").notNullable().defaultTo(30); + table.timestamp("expires_at", { useTz: true }).notNullable(); + table.timestamp("ended_at", { useTz: true }); + table.string("ip_address", 60).notNullable(); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["admin_id"]); + table.index(["impersonated_user_id"]); + table.index(["status"]); + table.index(["expires_at"]); + }); + + await knex.schema.createTable("admin_impersonation_audit_logs", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("impersonation_session_id").notNullable().references("id").inTable("admin_impersonation_sessions").onDelete("CASCADE"); + table.string("admin_id", 120).notNullable(); + table.string("impersonated_user_id", 120).notNullable(); + table.string("action_performed", 120).notNullable(); + table.text("request_path").notNullable(); + table.string("request_method", 20).notNullable(); + table.timestamp("timestamp", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["impersonation_session_id"]); + table.index(["admin_id"]); + table.index(["timestamp"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("admin_impersonation_audit_logs"); + await knex.schema.dropTableIfExists("admin_impersonation_sessions"); +} diff --git a/backend/src/services/adminImpersonation.service.ts b/backend/src/services/adminImpersonation.service.ts new file mode 100644 index 00000000..931b54ff --- /dev/null +++ b/backend/src/services/adminImpersonation.service.ts @@ -0,0 +1,249 @@ +import crypto from "crypto"; +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type ImpersonationStatus = "ACTIVE" | "ENDED" | "REVOKED" | "EXPIRED"; + +export interface AdminImpersonationSession { + id: string; + adminId: string; + impersonatedUserId: string; + reason: string; + approvalTicketId: string | null; + status: ImpersonationStatus; + tokenHash: string; + maxDurationMinutes: number; + expiresAt: string; + endedAt: string | null; + ipAddress: string; + createdAt: string; + updatedAt: string; +} + +export interface ImpersonationAuditLog { + id: string; + impersonationSessionId: string; + adminId: string; + impersonatedUserId: string; + actionPerformed: string; + requestPath: string; + requestMethod: string; + timestamp: string; +} + +interface Row { + [key: string]: unknown; +} + +export class AdminImpersonationService { + async startSession(input: { + adminId: string; + impersonatedUserId: string; + reason: string; + approvalTicketId?: string; + durationMinutes?: number; + ipAddress: string; + }): Promise<{ session: AdminImpersonationSession; token: string }> { + const db = getDatabase(); + + if (!input.adminId?.trim() || !input.impersonatedUserId?.trim()) { + throw new Error("adminId and impersonatedUserId are required"); + } + if (!input.reason?.trim()) { + throw new Error("Reason / ticket justification is mandatory for impersonation"); + } + if (input.adminId.trim() === input.impersonatedUserId.trim()) { + throw new Error("Admin cannot impersonate themselves"); + } + + const duration = Math.min(Math.max(input.durationMinutes ?? 30, 5), 120); + const expiresAt = new Date(Date.now() + duration * 60 * 1000); + + const rawToken = crypto.randomBytes(32).toString("hex"); + const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex"); + + const [inserted] = await db("admin_impersonation_sessions") + .insert({ + admin_id: input.adminId.trim(), + impersonated_user_id: input.impersonatedUserId.trim(), + reason: input.reason.trim(), + approval_ticket_id: input.approvalTicketId?.trim() ?? null, + status: "ACTIVE", + token_hash: tokenHash, + max_duration_minutes: duration, + expires_at: expiresAt, + ip_address: input.ipAddress.trim(), + created_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + logger.warn( + { + feature: "admin_impersonation_safeguards", + action: "impersonation_started", + admin_id: input.adminId, + impersonated_user_id: input.impersonatedUserId, + approval_ticket_id: input.approvalTicketId ?? null, + expires_at: expiresAt, + }, + "Admin impersonation session created" + ); + + return { + session: this.mapSessionRow(inserted as Row), + token: rawToken, + }; + } + + async validateAndTouchSession( + sessionId: string, + token: string + ): Promise { + const db = getDatabase(); + const sessionRow = (await db("admin_impersonation_sessions") + .where({ id: sessionId }) + .first()) as Row | undefined; + + if (!sessionRow) return null; + + const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); + if (sessionRow.token_hash !== tokenHash) { + return null; + } + + const now = new Date(); + if (sessionRow.status !== "ACTIVE" || new Date(String(sessionRow.expires_at)) < now) { + if (sessionRow.status === "ACTIVE") { + await db("admin_impersonation_sessions") + .where({ id: sessionId }) + .update({ status: "EXPIRED", updated_at: now }); + } + return null; + } + + return this.mapSessionRow(sessionRow); + } + + async logAction(input: { + impersonationSessionId: string; + adminId: string; + impersonatedUserId: string; + actionPerformed: string; + requestPath: string; + requestMethod: string; + }): Promise { + const db = getDatabase(); + const [inserted] = await db("admin_impersonation_audit_logs") + .insert({ + impersonation_session_id: input.impersonationSessionId, + admin_id: input.adminId, + impersonated_user_id: input.impersonatedUserId, + action_performed: input.actionPerformed, + request_path: input.requestPath, + request_method: input.requestMethod, + timestamp: new Date(), + }) + .returning("*"); + + return this.mapAuditRow(inserted as Row); + } + + async endSession( + sessionId: string, + adminId: string + ): Promise { + const db = getDatabase(); + const [updated] = await db("admin_impersonation_sessions") + .where({ id: sessionId, admin_id: adminId }) + .update({ + status: "ENDED", + ended_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + if (updated) { + logger.info( + { + feature: "admin_impersonation_safeguards", + action: "impersonation_ended", + session_id: sessionId, + admin_id: adminId, + }, + "Admin impersonation session ended" + ); + } + + return updated ? this.mapSessionRow(updated as Row) : null; + } + + async listSessions(filters?: { + adminId?: string; + impersonatedUserId?: string; + status?: ImpersonationStatus; + limit?: number; + offset?: number; + }): Promise { + const db = getDatabase(); + const rows = (await db("admin_impersonation_sessions") + .modify((qb) => { + if (filters?.adminId) { + qb.where("admin_id", filters.adminId); + } + if (filters?.impersonatedUserId) { + qb.where("impersonated_user_id", filters.impersonatedUserId); + } + if (filters?.status) { + qb.where("status", filters.status); + } + }) + .orderBy("created_at", "desc") + .limit(filters?.limit ?? 50) + .offset(filters?.offset ?? 0)) as Row[]; + + return rows.map((r) => this.mapSessionRow(r)); + } + + async getAuditLogs(sessionId: string): Promise { + const db = getDatabase(); + const rows = (await db("admin_impersonation_audit_logs") + .where({ impersonation_session_id: sessionId }) + .orderBy("timestamp", "desc")) as Row[]; + + return rows.map((r) => this.mapAuditRow(r)); + } + + private mapSessionRow(row: Row): AdminImpersonationSession { + return { + id: String(row.id), + adminId: String(row.admin_id), + impersonatedUserId: String(row.impersonated_user_id), + reason: String(row.reason), + approvalTicketId: row.approval_ticket_id ? String(row.approval_ticket_id) : null, + status: String(row.status) as ImpersonationStatus, + tokenHash: String(row.token_hash), + maxDurationMinutes: Number(row.max_duration_minutes), + expiresAt: String(row.expires_at), + endedAt: row.ended_at ? String(row.ended_at) : null, + ipAddress: String(row.ip_address), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; + } + + private mapAuditRow(row: Row): ImpersonationAuditLog { + return { + id: String(row.id), + impersonationSessionId: String(row.impersonation_session_id), + adminId: String(row.admin_id), + impersonatedUserId: String(row.impersonated_user_id), + actionPerformed: String(row.action_performed), + requestPath: String(row.request_path), + requestMethod: String(row.request_method), + timestamp: String(row.timestamp), + }; + } +} + +export const adminImpersonationService = new AdminImpersonationService(); diff --git a/backend/src/services/assetLifecycleTimeline.service.ts b/backend/src/services/assetLifecycleTimeline.service.ts new file mode 100644 index 00000000..d626d18d --- /dev/null +++ b/backend/src/services/assetLifecycleTimeline.service.ts @@ -0,0 +1,187 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type AssetState = + | "INITIALIZED" + | "PROVISIONED" + | "ACTIVE" + | "PAUSED" + | "DEPRECATED" + | "RETIRED"; + +export interface AssetLifecycleRecord { + id: string; + assetId: string; + assetSymbol: string; + state: AssetState; + previousState: AssetState | null; + reason: string | null; + triggeredBy: string; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +export interface AssetLifecycleStats { + totalTransitions: number; + byState: Record; + activeAssets: number; +} + +interface Row { + [key: string]: unknown; +} + +export class AssetLifecycleTimelineService { + async recordTransition(input: { + assetId: string; + assetSymbol: string; + state: AssetState; + previousState?: AssetState; + reason?: string; + triggeredBy: string; + metadata?: Record; + }): Promise { + const db = getDatabase(); + + if (!input.assetId?.trim() || !input.assetSymbol?.trim()) { + throw new Error("assetId and assetSymbol are required"); + } + if (!input.state?.trim() || !input.triggeredBy?.trim()) { + throw new Error("state and triggeredBy are required"); + } + + const [inserted] = await db("asset_lifecycle_timeline") + .insert({ + asset_id: input.assetId.trim(), + asset_symbol: input.assetSymbol.trim(), + state: input.state, + previous_state: input.previousState ?? null, + reason: input.reason ?? null, + triggered_by: input.triggeredBy.trim(), + metadata: JSON.stringify(input.metadata ?? {}), + created_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + logger.info( + { + feature: "asset_lifecycle_timeline", + action: "state_transition", + asset_id: input.assetId, + state: input.state, + triggered_by: input.triggeredBy, + }, + "Asset lifecycle state transition recorded" + ); + + return this.mapRow(inserted as Row); + } + + async getTimeline( + assetId?: string, + filters?: { + state?: AssetState; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; + } + ): Promise { + const db = getDatabase(); + const rows = (await db("asset_lifecycle_timeline") + .modify((qb) => { + if (assetId) { + qb.where("asset_id", assetId); + } + if (filters?.state) { + qb.where("state", filters.state); + } + if (filters?.startDate) { + qb.where("created_at", ">=", new Date(filters.startDate)); + } + if (filters?.endDate) { + qb.where("created_at", "<=", new Date(filters.endDate)); + } + }) + .orderBy("created_at", "desc") + .limit(filters?.limit ?? 50) + .offset(filters?.offset ?? 0)) as Row[]; + + return rows.map((row) => this.mapRow(row)); + } + + async getLatestState(assetId: string): Promise { + const db = getDatabase(); + const row = (await db("asset_lifecycle_timeline") + .where("asset_id", assetId) + .orderBy("created_at", "desc") + .first()) as Row | undefined; + + return row ? this.mapRow(row) : null; + } + + async getStats(): Promise { + const db = getDatabase(); + const rows = (await db("asset_lifecycle_timeline") + .select("state") + .select(db.raw("count(*)::int as cnt")) + .groupBy("state")) as Row[]; + + const activeRows = (await db("asset_lifecycle_timeline") + .distinct("asset_id") + .where("state", "ACTIVE")) as Row[]; + + const byState: Record = { + INITIALIZED: 0, + PROVISIONED: 0, + ACTIVE: 0, + PAUSED: 0, + DEPRECATED: 0, + RETIRED: 0, + }; + + let totalTransitions = 0; + for (const row of rows) { + const state = String(row.state) as AssetState; + if (state in byState) { + byState[state] = Number(row.cnt); + } + totalTransitions += Number(row.cnt); + } + + return { + totalTransitions, + byState, + activeAssets: activeRows.length, + }; + } + + private mapRow(row: Row): AssetLifecycleRecord { + return { + id: String(row.id), + assetId: String(row.asset_id), + assetSymbol: String(row.asset_symbol), + state: String(row.state) as AssetState, + previousState: row.previous_state ? (String(row.previous_state) as AssetState) : null, + reason: row.reason ? String(row.reason) : null, + triggeredBy: String(row.triggered_by), + metadata: this.parseObject(row.metadata), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; + } + + private parseObject(value: unknown): Record { + if (!value) return {}; + if (typeof value === "object") return value as Record; + try { + return JSON.parse(String(value)); + } catch { + return {}; + } + } +} + +export const assetLifecycleTimelineService = new AssetLifecycleTimelineService(); diff --git a/backend/src/services/permissionChangeNotification.service.ts b/backend/src/services/permissionChangeNotification.service.ts new file mode 100644 index 00000000..a508eb90 --- /dev/null +++ b/backend/src/services/permissionChangeNotification.service.ts @@ -0,0 +1,209 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type PermissionAction = + | "ROLE_ASSIGNED" + | "ROLE_REVOKED" + | "PERMISSION_GRANTED" + | "PERMISSION_REVOKED"; + +export type NotificationChannel = "IN_APP" | "EMAIL" | "SLACK"; +export type NotificationStatus = "PENDING" | "SENT" | "FAILED"; + +export interface PermissionChangeNotificationRecord { + id: string; + targetUserId: string; + actorId: string; + action: PermissionAction; + permissionOrRole: string; + channels: NotificationChannel[]; + status: NotificationStatus; + details: Record; + readAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PermissionNotificationStats { + total: number; + byStatus: Record; + byAction: Record; +} + +interface Row { + [key: string]: unknown; +} + +export class PermissionChangeNotificationService { + async notify(input: { + targetUserId: string; + actorId: string; + action: PermissionAction; + permissionOrRole: string; + channels?: NotificationChannel[]; + details?: Record; + }): Promise { + const db = getDatabase(); + + if (!input.targetUserId?.trim() || !input.actorId?.trim()) { + throw new Error("targetUserId and actorId are required"); + } + if (!input.action?.trim() || !input.permissionOrRole?.trim()) { + throw new Error("action and permissionOrRole are required"); + } + + const channels = input.channels && input.channels.length > 0 ? input.channels : ["IN_APP"]; + + const [inserted] = await db("permission_change_notifications") + .insert({ + target_user_id: input.targetUserId.trim(), + actor_id: input.actorId.trim(), + action: input.action, + permission_or_role: input.permissionOrRole.trim(), + channels: JSON.stringify(channels), + status: "SENT", + details: JSON.stringify(input.details ?? {}), + created_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + logger.info( + { + feature: "permission_change_notifications", + action: "notification_created", + target_user_id: input.targetUserId, + actor_id: input.actorId, + permission_or_role: input.permissionOrRole, + }, + "Permission change notification created and dispatched" + ); + + return this.mapRow(inserted as Row); + } + + async listUserNotifications( + targetUserId: string, + filters?: { + status?: NotificationStatus; + unreadOnly?: boolean; + limit?: number; + offset?: number; + } + ): Promise { + const db = getDatabase(); + const rows = (await db("permission_change_notifications") + .where("target_user_id", targetUserId) + .modify((qb) => { + if (filters?.status) { + qb.where("status", filters.status); + } + if (filters?.unreadOnly) { + qb.whereNull("read_at"); + } + }) + .orderBy("created_at", "desc") + .limit(filters?.limit ?? 50) + .offset(filters?.offset ?? 0)) as Row[]; + + return rows.map((row) => this.mapRow(row)); + } + + async markAsRead( + id: string, + targetUserId: string + ): Promise { + const db = getDatabase(); + const [updated] = await db("permission_change_notifications") + .where({ id, target_user_id: targetUserId }) + .update({ + read_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + return updated ? this.mapRow(updated as Row) : null; + } + + async getStats(): Promise { + const db = getDatabase(); + const statusRows = (await db("permission_change_notifications") + .select("status") + .select(db.raw("count(*)::int as cnt")) + .groupBy("status")) as Row[]; + + const actionRows = (await db("permission_change_notifications") + .select("action") + .select(db.raw("count(*)::int as cnt")) + .groupBy("action")) as Row[]; + + const byStatus: Record = { + PENDING: 0, + SENT: 0, + FAILED: 0, + }; + + const byAction: Record = { + ROLE_ASSIGNED: 0, + ROLE_REVOKED: 0, + PERMISSION_GRANTED: 0, + PERMISSION_REVOKED: 0, + }; + + let total = 0; + for (const row of statusRows) { + const st = String(row.status) as NotificationStatus; + if (st in byStatus) { + byStatus[st] = Number(row.cnt); + } + total += Number(row.cnt); + } + + for (const row of actionRows) { + const act = String(row.action) as PermissionAction; + if (act in byAction) { + byAction[act] = Number(row.cnt); + } + } + + return { total, byStatus, byAction }; + } + + private mapRow(row: Row): PermissionChangeNotificationRecord { + return { + id: String(row.id), + targetUserId: String(row.target_user_id), + actorId: String(row.actor_id), + action: String(row.action) as PermissionAction, + permissionOrRole: String(row.permission_or_role), + channels: this.parseArray(row.channels) as NotificationChannel[], + status: String(row.status) as NotificationStatus, + details: this.parseObject(row.details), + readAt: row.read_at ? String(row.read_at) : null, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; + } + + private parseObject(value: unknown): Record { + if (!value) return {}; + if (typeof value === "object") return value as Record; + try { + return JSON.parse(String(value)); + } catch { + return {}; + } + } + + private parseArray(value: unknown): unknown[] { + if (!value) return []; + if (Array.isArray(value)) return value; + try { + return JSON.parse(String(value)); + } catch { + return []; + } + } +} + +export const permissionChangeNotificationService = new PermissionChangeNotificationService(); diff --git a/backend/src/services/sessionDevice.service.ts b/backend/src/services/sessionDevice.service.ts new file mode 100644 index 00000000..fa73de73 --- /dev/null +++ b/backend/src/services/sessionDevice.service.ts @@ -0,0 +1,205 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type DeviceType = "DESKTOP" | "MOBILE" | "TABLET" | "OTHER"; + +export interface SessionDeviceRecord { + id: string; + userId: string; + deviceFingerprint: string; + deviceName: string; + deviceType: DeviceType; + ipAddress: string; + location: string | null; + userAgent: string | null; + isActive: boolean; + isTrusted: boolean; + lastActiveAt: string; + revokedAt: string | null; + createdAt: string; + updatedAt: string; +} + +interface Row { + [key: string]: unknown; +} + +export class SessionDeviceService { + async registerOrUpdateDevice(input: { + userId: string; + deviceFingerprint: string; + deviceName: string; + deviceType?: DeviceType; + ipAddress: string; + location?: string; + userAgent?: string; + }): Promise { + const db = getDatabase(); + + if (!input.userId?.trim() || !input.deviceFingerprint?.trim()) { + throw new Error("userId and deviceFingerprint are required"); + } + if (!input.deviceName?.trim() || !input.ipAddress?.trim()) { + throw new Error("deviceName and ipAddress are required"); + } + + const existing = (await db("user_session_devices") + .where({ + user_id: input.userId.trim(), + device_fingerprint: input.deviceFingerprint.trim(), + }) + .first()) as Row | undefined; + + if (existing) { + const [updated] = await db("user_session_devices") + .where({ id: String(existing.id) }) + .update({ + device_name: input.deviceName.trim(), + device_type: input.deviceType ?? existing.device_type, + ip_address: input.ipAddress.trim(), + location: input.location ?? existing.location, + user_agent: input.userAgent ?? existing.user_agent, + is_active: true, + revoked_at: null, + last_active_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + return this.mapRow(updated as Row); + } + + const [inserted] = await db("user_session_devices") + .insert({ + user_id: input.userId.trim(), + device_fingerprint: input.deviceFingerprint.trim(), + device_name: input.deviceName.trim(), + device_type: input.deviceType ?? "DESKTOP", + ip_address: input.ipAddress.trim(), + location: input.location ?? null, + user_agent: input.userAgent ?? null, + is_active: true, + is_trusted: false, + last_active_at: new Date(), + created_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + logger.info( + { + feature: "session_device_management", + action: "device_registered", + user_id: input.userId, + device_fingerprint: input.deviceFingerprint, + }, + "New session device registered" + ); + + return this.mapRow(inserted as Row); + } + + async getUserDevices(userId: string): Promise { + const db = getDatabase(); + const rows = (await db("user_session_devices") + .where({ user_id: userId }) + .orderBy("last_active_at", "desc")) as Row[]; + + return rows.map((row) => this.mapRow(row)); + } + + async revokeDevice( + userId: string, + deviceId: string + ): Promise { + const db = getDatabase(); + const [updated] = await db("user_session_devices") + .where({ id: deviceId, user_id: userId }) + .update({ + is_active: false, + revoked_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + if (updated) { + logger.info( + { + feature: "session_device_management", + action: "device_revoked", + user_id: userId, + device_id: deviceId, + }, + "User device session revoked" + ); + } + + return updated ? this.mapRow(updated as Row) : null; + } + + async revokeOtherDevices( + userId: string, + currentDeviceId: string + ): Promise { + const db = getDatabase(); + const updatedCount = await db("user_session_devices") + .where("user_id", userId) + .whereNot("id", currentDeviceId) + .where("is_active", true) + .update({ + is_active: false, + revoked_at: new Date(), + updated_at: new Date(), + }); + + logger.info( + { + feature: "session_device_management", + action: "other_devices_revoked", + user_id: userId, + revoked_count: updatedCount, + }, + "Revoked all other active session devices for user" + ); + + return updatedCount; + } + + async setTrustStatus( + userId: string, + deviceId: string, + isTrusted: boolean + ): Promise { + const db = getDatabase(); + const [updated] = await db("user_session_devices") + .where({ id: deviceId, user_id: userId }) + .update({ + is_trusted: isTrusted, + updated_at: new Date(), + }) + .returning("*"); + + return updated ? this.mapRow(updated as Row) : null; + } + + private mapRow(row: Row): SessionDeviceRecord { + return { + id: String(row.id), + userId: String(row.user_id), + deviceFingerprint: String(row.device_fingerprint), + deviceName: String(row.device_name), + deviceType: String(row.device_type) as DeviceType, + ipAddress: String(row.ip_address), + location: row.location ? String(row.location) : null, + userAgent: row.user_agent ? String(row.user_agent) : null, + isActive: Boolean(row.is_active), + isTrusted: Boolean(row.is_trusted), + lastActiveAt: String(row.last_active_at), + revokedAt: row.revoked_at ? String(row.revoked_at) : null, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; + } +} + +export const sessionDeviceService = new SessionDeviceService(); diff --git a/backend/tests/services/adminImpersonation.service.test.ts b/backend/tests/services/adminImpersonation.service.test.ts new file mode 100644 index 00000000..62cb2fd2 --- /dev/null +++ b/backend/tests/services/adminImpersonation.service.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { AdminImpersonationService } from "../../src/services/adminImpersonation.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +function makeSessionRow(overrides: Record = {}) { + return { + id: "imp-session-1", + admin_id: "admin-100", + impersonated_user_id: "user-200", + reason: "Support ticket #INC-889 investigation", + approval_ticket_id: "INC-889", + status: "ACTIVE", + token_hash: "mockedhash", + max_duration_minutes: 30, + expires_at: new Date(Date.now() + 1800000).toISOString(), + ended_at: null, + ip_address: "10.0.0.1", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeAuditRow(overrides: Record = {}) { + return { + id: "audit-1", + impersonation_session_id: "imp-session-1", + admin_id: "admin-100", + impersonated_user_id: "user-200", + action_performed: "VIEW_TRANSACTIONS", + request_path: "/api/v1/user/transactions", + request_method: "GET", + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +let currentChain: Record = {}; + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.modify = vi.fn().mockImplementation((mod: (qb: unknown) => void) => { + mod(chain); + return chain; + }); + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.limit = vi.fn().mockReturnValue(chain); + chain.offset = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockReturnValue(chain); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.returning = vi.fn().mockResolvedValue(updated ? [updated] : rows.length ? [makeSessionRow()] : []); + chain.select = vi.fn().mockReturnValue(chain); + chain.raw = (v: string) => ({ sql: v }); + chain.then = vi.fn().mockImplementation((resolve) => { + resolve(rows); + return Promise.resolve(); + }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation(() => currentChain); + (dbFn as unknown as Record).raw = (v: string) => ({ sql: v }); + return dbFn as never; +} + +describe("AdminImpersonationService", () => { + let service: AdminImpersonationService; + + beforeEach(() => { + service = new AdminImpersonationService(); + makeChain([makeSessionRow()], makeSessionRow()); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("starts an impersonation session with ticket justification", async () => { + makeChain([], makeSessionRow()); + const result = await service.startSession({ + adminId: "admin-100", + impersonatedUserId: "user-200", + reason: "Support ticket #INC-889 investigation", + approvalTicketId: "INC-889", + ipAddress: "10.0.0.1", + }); + + expect(result.session.adminId).toBe("admin-100"); + expect(result.session.impersonatedUserId).toBe("user-200"); + expect(result.session.status).toBe("ACTIVE"); + expect(result.token).toBeDefined(); + }); + + it("throws error when reason/justification is missing", async () => { + await expect( + service.startSession({ + adminId: "admin-100", + impersonatedUserId: "user-200", + reason: "", + ipAddress: "10.0.0.1", + }) + ).rejects.toThrow("Reason / ticket justification is mandatory"); + }); + + it("throws error when admin tries to impersonate self", async () => { + await expect( + service.startSession({ + adminId: "admin-100", + impersonatedUserId: "admin-100", + reason: "testing self", + ipAddress: "10.0.0.1", + }) + ).rejects.toThrow("Admin cannot impersonate themselves"); + }); + + it("logs an impersonation action to audit trail", async () => { + makeChain([], makeAuditRow()); + const log = await service.logAction({ + impersonationSessionId: "imp-session-1", + adminId: "admin-100", + impersonatedUserId: "user-200", + actionPerformed: "VIEW_TRANSACTIONS", + requestPath: "/api/v1/user/transactions", + requestMethod: "GET", + }); + + expect(log.actionPerformed).toBe("VIEW_TRANSACTIONS"); + expect(log.adminId).toBe("admin-100"); + }); + + it("ends an impersonation session", async () => { + makeChain([makeSessionRow()], makeSessionRow({ status: "ENDED", ended_at: new Date().toISOString() })); + const ended = await service.endSession("imp-session-1", "admin-100"); + expect(ended?.status).toBe("ENDED"); + }); + + it("lists active impersonation sessions", async () => { + const sessions = await service.listSessions({ status: "ACTIVE" }); + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe("ACTIVE"); + }); +}); diff --git a/backend/tests/services/assetLifecycleTimeline.service.test.ts b/backend/tests/services/assetLifecycleTimeline.service.test.ts new file mode 100644 index 00000000..9a9ce72b --- /dev/null +++ b/backend/tests/services/assetLifecycleTimeline.service.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { AssetLifecycleTimelineService } from "../../src/services/assetLifecycleTimeline.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +function makeRow(overrides: Record = {}) { + return { + id: "alt-1", + asset_id: "USDC:GA5ZSEJYB37JRC5AVCIA5XYF4DZ62C2Z54MICLX4KCH7RE4P7MCE47C3", + asset_symbol: "USDC", + state: "ACTIVE", + previous_state: "PROVISIONED", + reason: "Initial issuance audit passed", + triggered_by: "admin-1", + metadata: JSON.stringify({ auditRef: "AUD-123" }), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +let currentChain: Record = {}; + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.modify = vi.fn().mockImplementation((mod: (qb: unknown) => void) => { + mod(chain); + return chain; + }); + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.limit = vi.fn().mockReturnValue(chain); + chain.offset = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockReturnValue(chain); + chain.whereNull = vi.fn().mockReturnValue(chain); + chain.distinct = vi.fn().mockReturnValue(chain); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.returning = vi.fn().mockResolvedValue(updated ? [updated] : rows.length ? [makeRow()] : []); + chain.groupBy = vi.fn().mockReturnValue(chain); + chain.select = vi.fn().mockReturnValue(chain); + chain.raw = (v: string) => ({ sql: v }); + chain.then = vi.fn().mockImplementation((resolve) => { + resolve(rows); + return Promise.resolve(); + }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation(() => currentChain); + (dbFn as unknown as Record).raw = (v: string) => ({ sql: v }); + return dbFn as never; +} + +describe("AssetLifecycleTimelineService", () => { + let service: AssetLifecycleTimelineService; + + beforeEach(() => { + service = new AssetLifecycleTimelineService(); + makeChain([makeRow()], makeRow()); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("records asset state transition successfully", async () => { + makeChain([], makeRow()); + const record = await service.recordTransition({ + assetId: "USDC:GA5ZSEJYB37JRC5AVCIA5XYF4DZ62C2Z54MICLX4KCH7RE4P7MCE47C3", + assetSymbol: "USDC", + state: "ACTIVE", + previousState: "PROVISIONED", + reason: "Initial issuance audit passed", + triggeredBy: "admin-1", + }); + + expect(record.state).toBe("ACTIVE"); + expect(record.assetSymbol).toBe("USDC"); + expect(record.triggeredBy).toBe("admin-1"); + }); + + it("throws error when assetId or assetSymbol is missing", async () => { + await expect( + service.recordTransition({ + assetId: "", + assetSymbol: "USDC", + state: "ACTIVE", + triggeredBy: "admin-1", + }) + ).rejects.toThrow("assetId and assetSymbol are required"); + }); + + it("fetches timeline records for asset", async () => { + const timeline = await service.getTimeline("USDC:GA5ZSEJYB37JRC5AVCIA5XYF4DZ62C2Z54MICLX4KCH7RE4P7MCE47C3"); + expect(timeline).toHaveLength(1); + expect(timeline[0].assetSymbol).toBe("USDC"); + }); + + it("returns latest state for an asset", async () => { + const latest = await service.getLatestState("USDC:GA5ZSEJYB37JRC5AVCIA5XYF4DZ62C2Z54MICLX4KCH7RE4P7MCE47C3"); + expect(latest).not.toBeNull(); + expect(latest?.state).toBe("ACTIVE"); + }); + + it("calculates asset timeline statistics", async () => { + makeChain([ + { state: "ACTIVE", cnt: 5 }, + { state: "PAUSED", cnt: 2 }, + ]); + const stats = await service.getStats(); + expect(stats.byState.ACTIVE).toBe(5); + expect(stats.byState.PAUSED).toBe(2); + expect(stats.totalTransitions).toBe(7); + }); +}); diff --git a/backend/tests/services/permissionChangeNotification.service.test.ts b/backend/tests/services/permissionChangeNotification.service.test.ts new file mode 100644 index 00000000..9bb1b201 --- /dev/null +++ b/backend/tests/services/permissionChangeNotification.service.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { PermissionChangeNotificationService } from "../../src/services/permissionChangeNotification.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +function makeRow(overrides: Record = {}) { + return { + id: "pcn-1", + target_user_id: "user-100", + actor_id: "admin-1", + action: "ROLE_ASSIGNED", + permission_or_role: "OPERATOR", + channels: JSON.stringify(["IN_APP"]), + status: "SENT", + details: JSON.stringify({ role: "OPERATOR" }), + read_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +let currentChain: Record = {}; + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.modify = vi.fn().mockImplementation((mod: (qb: unknown) => void) => { + mod(chain); + return chain; + }); + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.limit = vi.fn().mockReturnValue(chain); + chain.offset = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockReturnValue(chain); + chain.whereNull = vi.fn().mockReturnValue(chain); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.returning = vi.fn().mockResolvedValue(updated ? [updated] : rows.length ? [makeRow()] : []); + chain.groupBy = vi.fn().mockReturnValue(chain); + chain.select = vi.fn().mockReturnValue(chain); + chain.raw = (v: string) => ({ sql: v }); + chain.then = vi.fn().mockImplementation((resolve) => { + resolve(rows); + return Promise.resolve(); + }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation(() => currentChain); + (dbFn as unknown as Record).raw = (v: string) => ({ sql: v }); + return dbFn as never; +} + +describe("PermissionChangeNotificationService", () => { + let service: PermissionChangeNotificationService; + + beforeEach(() => { + service = new PermissionChangeNotificationService(); + makeChain([makeRow()], makeRow()); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("creates and dispatches permission change notification", async () => { + makeChain([], makeRow()); + const notification = await service.notify({ + targetUserId: "user-100", + actorId: "admin-1", + action: "ROLE_ASSIGNED", + permissionOrRole: "OPERATOR", + }); + + expect(notification.status).toBe("SENT"); + expect(notification.action).toBe("ROLE_ASSIGNED"); + expect(notification.targetUserId).toBe("user-100"); + }); + + it("throws error when targetUserId or actorId is missing", async () => { + await expect( + service.notify({ + targetUserId: "", + actorId: "admin-1", + action: "ROLE_ASSIGNED", + permissionOrRole: "OPERATOR", + }) + ).rejects.toThrow("targetUserId and actorId are required"); + }); + + it("lists notifications for a target user", async () => { + const list = await service.listUserNotifications("user-100"); + expect(list).toHaveLength(1); + expect(list[0].permissionOrRole).toBe("OPERATOR"); + }); + + it("marks notification as read", async () => { + makeChain([makeRow()], makeRow({ read_at: new Date().toISOString() })); + const updated = await service.markAsRead("pcn-1", "user-100"); + expect(updated?.readAt).not.toBeNull(); + }); + + it("computes stats for permission notifications", async () => { + makeChain([ + { status: "SENT", cnt: 10 }, + { status: "FAILED", cnt: 1 }, + ]); + const stats = await service.getStats(); + expect(stats.byStatus.SENT).toBe(10); + expect(stats.byStatus.FAILED).toBe(1); + expect(stats.total).toBe(11); + }); +}); diff --git a/backend/tests/services/sessionDevice.service.test.ts b/backend/tests/services/sessionDevice.service.test.ts new file mode 100644 index 00000000..014911e2 --- /dev/null +++ b/backend/tests/services/sessionDevice.service.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { SessionDeviceService } from "../../src/services/sessionDevice.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +function makeRow(overrides: Record = {}) { + return { + id: "dev-1", + user_id: "user-100", + device_fingerprint: "fp-abc-123", + device_name: "Chrome macOS", + device_type: "DESKTOP", + ip_address: "192.168.1.1", + location: "San Francisco, CA", + user_agent: "Mozilla/5.0", + is_active: true, + is_trusted: false, + last_active_at: new Date().toISOString(), + revoked_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +let currentChain: Record = {}; + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.modify = vi.fn().mockImplementation((mod: (qb: unknown) => void) => { + mod(chain); + return chain; + }); + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.limit = vi.fn().mockReturnValue(chain); + chain.offset = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockReturnValue(chain); + chain.whereNot = vi.fn().mockReturnValue(chain); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.returning = vi.fn().mockResolvedValue(updated ? [updated] : rows.length ? [makeRow()] : []); + chain.groupBy = vi.fn().mockReturnValue(chain); + chain.select = vi.fn().mockReturnValue(chain); + chain.raw = (v: string) => ({ sql: v }); + chain.then = vi.fn().mockImplementation((resolve) => { + resolve(rows); + return Promise.resolve(); + }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation(() => currentChain); + (dbFn as unknown as Record).raw = (v: string) => ({ sql: v }); + return dbFn as never; +} + +describe("SessionDeviceService", () => { + let service: SessionDeviceService; + + beforeEach(() => { + service = new SessionDeviceService(); + makeChain([makeRow()], makeRow()); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("registers a new session device", async () => { + makeChain([], makeRow()); + const device = await service.registerOrUpdateDevice({ + userId: "user-100", + deviceFingerprint: "fp-abc-123", + deviceName: "Chrome macOS", + ipAddress: "192.168.1.1", + }); + + expect(device.userId).toBe("user-100"); + expect(device.deviceFingerprint).toBe("fp-abc-123"); + expect(device.isActive).toBe(true); + }); + + it("throws error when userId or deviceFingerprint is missing", async () => { + await expect( + service.registerOrUpdateDevice({ + userId: "", + deviceFingerprint: "fp-abc-123", + deviceName: "Chrome macOS", + ipAddress: "192.168.1.1", + }) + ).rejects.toThrow("userId and deviceFingerprint are required"); + }); + + it("lists user devices", async () => { + const devices = await service.getUserDevices("user-100"); + expect(devices).toHaveLength(1); + expect(devices[0].deviceName).toBe("Chrome macOS"); + }); + + it("revokes a single device session", async () => { + makeChain([makeRow()], makeRow({ is_active: false, revoked_at: new Date().toISOString() })); + const revoked = await service.revokeDevice("user-100", "dev-1"); + expect(revoked?.isActive).toBe(false); + }); + + it("sets device trust status", async () => { + makeChain([makeRow()], makeRow({ is_trusted: true })); + const trusted = await service.setTrustStatus("user-100", "dev-1", true); + expect(trusted?.isTrusted).toBe(true); + }); +}); diff --git a/docs/admin-impersonation-safeguards.md b/docs/admin-impersonation-safeguards.md new file mode 100644 index 00000000..624798b1 --- /dev/null +++ b/docs/admin-impersonation-safeguards.md @@ -0,0 +1,44 @@ +# Admin Impersonation Safeguards + +## Overview + +Admin Impersonation Safeguards provide time-bounded, audited user impersonation capabilities for support and compliance personnel. Mandatory justification tickets, active session banners, token hashes, and strict request logging ensure zero unauthorized administrative access. + +## Data Model & Persistence + +Stored in `admin_impersonation_sessions` and `admin_impersonation_audit_logs`: + +### `admin_impersonation_sessions` +- `id` (UUID, primary key) +- `admin_id` (String, admin account) +- `impersonated_user_id` (String, target account) +- `reason` (Text, mandatory justification) +- `approval_ticket_id` (String, ticket reference e.g. "SUP-101") +- `status` (`ACTIVE` | `ENDED` | `REVOKED` | `EXPIRED`) +- `token_hash` (String, SHA-256 session token hash) +- `max_duration_minutes` (Integer, default 30) +- `expires_at` / `ended_at` (Timestamps with timezone) + +### `admin_impersonation_audit_logs` +- `id` (UUID, primary key) +- `impersonation_session_id` (UUID, session reference) +- `admin_id` / `impersonated_user_id` (Strings) +- `action_performed` / `request_path` / `request_method` (HTTP request details) +- `timestamp` (Timestamp with timezone) + +## API Surface + +- `POST /api/v1/admin/impersonation/start`: Initiate impersonation session. +- `POST /api/v1/admin/impersonation/stop`: Terminate active session. +- `GET /api/v1/admin/impersonation/sessions`: Query impersonation session history. +- `GET /api/v1/admin/impersonation/audit-logs`: Fetch detailed audit logs for a session. + +## Operational Procedures + +### Rollout +1. Run Knex migration `20260829000040_admin_impersonation_safeguards.ts`. +2. Deploy backend service and routes. +3. Access UI at `/admin/impersonation-safeguards`. + +### Rollback +1. Execute Knex rollback: `npm run migrate:down`. diff --git a/docs/asset-lifecycle-timeline.md b/docs/asset-lifecycle-timeline.md new file mode 100644 index 00000000..815963dc --- /dev/null +++ b/docs/asset-lifecycle-timeline.md @@ -0,0 +1,37 @@ +# Asset Lifecycle State Timeline + +## Overview + +The Asset Lifecycle State Timeline feature expands Bridge Watch with tracking and auditing for asset lifecycle state transitions across `INITIALIZED`, `PROVISIONED`, `ACTIVE`, `PAUSED`, `DEPRECATED`, and `RETIRED` states. + +## Data Model & Persistence + +Transitions are persisted in the `asset_lifecycle_timeline` database table: + +- `id` (UUID, primary key) +- `asset_id` (String, asset identifier) +- `asset_symbol` (String, asset ticker) +- `state` (String, target lifecycle state) +- `previous_state` (String, optional previous state) +- `reason` (Text, compliance/operator justification) +- `triggered_by` (String, actor username or service ID) +- `metadata` (JSONB, supplementary context) +- `created_at` / `updated_at` (Timestamps with timezone) + +## API Surface + +- `POST /api/v1/assets/lifecycle-timeline`: Record a state transition. +- `GET /api/v1/assets/lifecycle-timeline`: List timeline entries with optional `assetId`, `state`, `startDate`, `endDate`, `limit`, and `offset` filters. +- `GET /api/v1/assets/lifecycle-timeline/stats`: Fetch aggregate state metrics and active asset counts. +- `GET /api/v1/assets/lifecycle-timeline/latest/:assetId`: Retrieve the current state for a specific asset. + +## Operational Procedures + +### Rollout +1. Run Knex migration `20260829000010_asset_lifecycle_state_timeline.ts`. +2. Deploy backend service and register Fastify route handlers. +3. Access UI at `/assets/lifecycle-timeline`. + +### Rollback +1. Execute Knex rollback: `npm run migrate:down`. +2. Revert route registration in `asset-routes.ts`. diff --git a/docs/permission-change-notifications.md b/docs/permission-change-notifications.md new file mode 100644 index 00000000..d15a62a4 --- /dev/null +++ b/docs/permission-change-notifications.md @@ -0,0 +1,37 @@ +# Permission Change Notifications + +## Overview + +Permission Change Notifications provide automated alerts and user inbox notifications when roles or permissions are assigned, revoked, granted, or altered. + +## Data Model & Persistence + +Notifications are stored in `permission_change_notifications`: + +- `id` (UUID, primary key) +- `target_user_id` (String, recipient user) +- `actor_id` (String, administrator or process initiating change) +- `action` (`ROLE_ASSIGNED` | `ROLE_REVOKED` | `PERMISSION_GRANTED` | `PERMISSION_REVOKED`) +- `permission_or_role` (String, modified role/permission key) +- `channels` (JSONB, array of channels e.g. `["IN_APP"]`) +- `status` (`PENDING` | `SENT` | `FAILED`) +- `details` (JSONB, supplementary event context) +- `read_at` (Timestamp, set when acknowledged) +- `created_at` / `updated_at` (Timestamps with timezone) + +## API Surface + +- `POST /api/v1/notifications/permission-changes`: Dispatch a permission notification. +- `GET /api/v1/notifications/permission-changes`: Fetch notification stream for recipient. +- `PATCH /api/v1/notifications/permission-changes/:id/read`: Mark notification as read. +- `GET /api/v1/notifications/permission-changes/stats`: Get delivery and action statistics. + +## Operational Procedures + +### Rollout +1. Run Knex migration `20260829000020_permission_change_notifications.ts`. +2. Deploy service and Fastify routes. +3. Access UI at `/notifications/permission-changes`. + +### Rollback +1. Execute Knex rollback: `npm run migrate:down`. diff --git a/docs/session-device-management.md b/docs/session-device-management.md new file mode 100644 index 00000000..f0092b99 --- /dev/null +++ b/docs/session-device-management.md @@ -0,0 +1,39 @@ +# Session Device Management + +## Overview + +Session Device Management enables users and security teams to monitor logged-in devices, track client fingerprints and IP addresses, flag trusted devices, and terminate unauthorized active sessions. + +## Data Model & Persistence + +Stored in `user_session_devices`: + +- `id` (UUID, primary key) +- `user_id` (String, account holder identifier) +- `device_fingerprint` (String, browser/device hash) +- `device_name` (String, client description e.g. "Chrome on macOS") +- `device_type` (`DESKTOP` | `MOBILE` | `TABLET` | `OTHER`) +- `ip_address` (String, client IP) +- `location` (String, approximate geographic location) +- `user_agent` (Text, browser user agent string) +- `is_active` (Boolean, active status flag) +- `is_trusted` (Boolean, explicit user trust flag) +- `last_active_at` / `revoked_at` (Timestamps with timezone) + +## API Surface + +- `POST /api/v1/user/devices/register`: Register or touch active session device. +- `GET /api/v1/user/devices`: List all registered session devices for user. +- `DELETE /api/v1/user/devices/:deviceId`: Revoke single device session. +- `POST /api/v1/user/devices/revoke-others`: Terminate all other active sessions. +- `PATCH /api/v1/user/devices/:deviceId/trust`: Update device trust state. + +## Operational Procedures + +### Rollout +1. Run Knex migration `20260829000030_session_device_management.ts`. +2. Deploy backend service and routes. +3. Access UI at `/user/devices`. + +### Rollback +1. Execute Knex rollback: `npm run migrate:down`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d488d075..d4287915 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -81,6 +81,14 @@ const DexPoolDiscovery = lazy(() => import("./pages/liquidity/DexPoolDiscovery") const PoolQualityRanking = lazy(() => import("./pages/liquidity/PoolQualityRanking")); const MarketImpactPresets = lazy(() => import("./pages/liquidity/MarketImpactPresets")); const RouteQuotes = lazy(() => import("./pages/liquidity/RouteQuotes")); +// #1040 — Asset Lifecycle State Timeline +const AssetLifecycleTimeline = lazy(() => import("./pages/AssetLifecycleTimeline")); +// #1176 — Permission Change Notifications +const PermissionChangeNotifications = lazy(() => import("./pages/PermissionChangeNotifications")); +// #1173 — Session Device Management +const SessionDeviceManagement = lazy(() => import("./pages/SessionDeviceManagement")); +// #1175 — Admin Impersonation Safeguards +const AdminImpersonationSafeguards = lazy(() => import("./pages/admin/AdminImpersonationSafeguards")); function NotificationInitializer() { useNotifications(); @@ -187,6 +195,14 @@ function App() { } /> {/* #1160 — Route Quote Expiration Handling */} } /> + {/* #1040 — Asset Lifecycle State Timeline */} + } /> + {/* #1176 — Permission Change Notifications */} + } /> + {/* #1173 — Session Device Management */} + } /> + {/* #1175 — Admin Impersonation Safeguards */} + } /> diff --git a/frontend/src/components/MobileNav/navigation.ts b/frontend/src/components/MobileNav/navigation.ts index 2568f0b6..999ea6c4 100644 --- a/frontend/src/components/MobileNav/navigation.ts +++ b/frontend/src/components/MobileNav/navigation.ts @@ -73,6 +73,10 @@ export const navGroups: NavGroup[] = [ { to: "/circuit-breaker-actions", label: "Circuit Breaker Remediation", description: "Manage automated circuit breaker remediation actions and execution logs" }, { to: "/export-scheduler", label: "Export Scheduler", description: "Schedule recurring report exports" }, { to: "/metrics-sidebar", label: "Pinned Metrics", description: "Pin and manage frequently viewed metrics" }, + { to: "/assets/lifecycle-timeline", label: "Lifecycle Timeline", description: "Asset lifecycle state transitions and audit timeline" }, + { to: "/notifications/permission-changes", label: "Permission Notifications", description: "Permission change alerts and user dispatches" }, + { to: "/user/devices", label: "Session Devices", description: "Manage active session devices and security revocations" }, + { to: "/admin/impersonation-safeguards", label: "Impersonation Safeguards", description: "Admin impersonation controls and audit logs" }, ], }, ]; diff --git a/frontend/src/pages/AssetLifecycleTimeline.tsx b/frontend/src/pages/AssetLifecycleTimeline.tsx new file mode 100644 index 00000000..a4997770 --- /dev/null +++ b/frontend/src/pages/AssetLifecycleTimeline.tsx @@ -0,0 +1,281 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { + getAssetLifecycleStats, + getAssetLifecycleTimeline, + recordAssetLifecycleTransition, +} from "../services/api"; +import type { + AssetLifecycleRecord, + AssetLifecycleStats, + AssetState, +} from "../types"; + +const ALL_STATES: AssetState[] = [ + "INITIALIZED", + "PROVISIONED", + "ACTIVE", + "PAUSED", + "DEPRECATED", + "RETIRED", +]; + +export default function AssetLifecycleTimeline() { + const [records, setRecords] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [filterState, setFilterState] = useState(""); + const [filterAssetId, setFilterAssetId] = useState(""); + + const [form, setForm] = useState({ + assetId: "USDC:GA5ZSEJYB37JRC5AVCIA5XYF4DZ62C2Z54MICLX4KCH7RE4P7MCE47C3", + assetSymbol: "USDC", + state: "ACTIVE" as AssetState, + previousState: "PROVISIONED" as AssetState, + reason: "State transition passed compliance & security audit.", + triggeredBy: "operator-admin", + }); + + const loadData = async () => { + setLoading(true); + setError(null); + try { + const [resRecords, resStats] = await Promise.all([ + getAssetLifecycleTimeline(filterAssetId || undefined, filterState || undefined), + getAssetLifecycleStats(), + ]); + setRecords(resRecords.records); + setStats(resStats.stats); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load timeline history"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterState, filterAssetId]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + await recordAssetLifecycleTransition({ + assetId: form.assetId, + assetSymbol: form.assetSymbol, + state: form.state, + previousState: form.previousState, + reason: form.reason, + triggeredBy: form.triggeredBy, + }); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : "State transition failed"); + } finally { + setLoading(false); + } + }; + + const getStateColor = (state: AssetState) => { + switch (state) { + case "ACTIVE": + return "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"; + case "PROVISIONED": + case "INITIALIZED": + return "bg-blue-500/15 text-blue-300 border-blue-500/30"; + case "PAUSED": + return "bg-amber-500/15 text-amber-300 border-amber-500/30"; + case "DEPRECATED": + case "RETIRED": + return "bg-rose-500/15 text-rose-300 border-rose-500/30"; + default: + return "bg-gray-500/15 text-gray-300 border-gray-500/30"; + } + }; + + return ( +
+
+
+

Asset Management

+

Asset Lifecycle State Timeline

+

+ Audit trail of asset lifecycle transitions, state progression, authorization details, and operational status. +

+
+
+
+

Total Transitions

+

{stats?.totalTransitions ?? 0}

+
+
+

Active Assets

+

{stats?.activeAssets ?? 0}

+
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+

Record State Transition

+ + + + + +
+ + + +
+ +