diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 6cbbb0bb..e3033023 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -89,6 +89,10 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Lowercase repository name + id: repo + run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -105,8 +109,8 @@ jobs: file: ./backend/Dockerfile push: false tags: | - ghcr.io/${{ github.repository }}/backend:${{ needs.validate-version.outputs.version }} - ghcr.io/${{ github.repository }}/backend:dryrun + ghcr.io/${{ steps.repo.outputs.name }}/backend:${{ needs.validate-version.outputs.version }} + ghcr.io/${{ steps.repo.outputs.name }}/backend:dryrun - name: Build frontend image (dry-run) uses: docker/build-push-action@v5 @@ -115,8 +119,8 @@ jobs: file: ./frontend/Dockerfile push: false tags: | - ghcr.io/${{ github.repository }}/frontend:${{ needs.validate-version.outputs.version }} - ghcr.io/${{ github.repository }}/frontend:dryrun + ghcr.io/${{ steps.repo.outputs.name }}/frontend:${{ needs.validate-version.outputs.version }} + ghcr.io/${{ steps.repo.outputs.name }}/frontend:dryrun continue-on-error: true - name: Report Docker build status diff --git a/backend/src/api/routes/alertRoutingAdmin.ts b/backend/src/api/routes/alertRoutingAdmin.ts index 85d8e045..f2de2921 100644 --- a/backend/src/api/routes/alertRoutingAdmin.ts +++ b/backend/src/api/routes/alertRoutingAdmin.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify"; import { authMiddleware } from "../middleware/auth.js"; import { alertRoutingService } from "../../services/alertRouting.service.js"; +import type { RoutingSeverity } from "../../services/alertRouting.service.js"; import { createAlertRoutingRuleSchema, listAlertRoutingAuditQuerySchema, @@ -8,6 +9,13 @@ import { updateAlertRoutingRuleSchema, } from "../validations/alertRouting.schema.js"; +const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]); + +function parseSeverity(value: string): RoutingSeverity { + if (VALID_SEVERITIES.has(value)) return value as RoutingSeverity; + return "medium"; +} + export async function alertRoutingAdminRoutes(server: FastifyInstance) { const requireAdmin = authMiddleware({ requiredScopes: ["admin:api-keys"] }); @@ -205,4 +213,173 @@ export async function alertRoutingAdminRoutes(server: FastifyInstance) { return { entries }; } ); + + server.post( + "/simulate", + { + preHandler: requireAdmin, + schema: { + tags: ["Config"], + summary: "Dry-run alert routing simulation (no dispatches)", + description: + "Evaluates which active routing rules would match a simulated alert and which channels would fire, without dispatching anything to real endpoints.", + security: [{ ApiKeyAuth: [] }], + body: { + type: "object", + required: ["severity"], + additionalProperties: false, + properties: { + severity: { + type: "string", + enum: ["critical", "high", "medium", "low"], + }, + assetCode: { type: "string", maxLength: 20 }, + sourceType: { type: "string", maxLength: 80 }, + ownerAddress: { type: "string" }, + label: { type: "string", maxLength: 120 }, + triggeredValue: { type: "number" }, + threshold: { type: "number" }, + metric: { type: "string", maxLength: 80 }, + }, + }, + }, + }, + async (request, reply) => { + const body = request.body as { + severity: string; + assetCode?: string; + sourceType?: string; + ownerAddress?: string; + label?: string; + triggeredValue?: number; + threshold?: number; + metric?: string; + }; + + if (!VALID_SEVERITIES.has(body.severity)) { + return reply.code(400).send({ error: "Invalid severity value" }); + } + + const severity = parseSeverity(body.severity); + const assetCode = (body.assetCode ?? "").trim().toUpperCase(); + const sourceType = (body.sourceType ?? "").trim(); + + // Load rules — scope by ownerAddress if provided + const allRules = await alertRoutingService.listRules(body.ownerAddress); + const activeRules = allRules.filter((rule) => rule.isActive); + const inactiveRules = allRules.filter((rule) => !rule.isActive); + + // Evaluate each active rule in priority order + const ruleResults = activeRules + .slice() + .sort((a, b) => a.priorityOrder - b.priorityOrder) + .map((rule) => { + const severityMatch = + rule.severityLevels.length === 0 || + rule.severityLevels.includes(severity); + + const assetMatch = + rule.assetCodes.length === 0 || + (assetCode !== "" && + rule.assetCodes + .map((c) => c.toUpperCase()) + .includes(assetCode)); + + const sourceMatch = + rule.sourceTypes.length === 0 || + (sourceType !== "" && rule.sourceTypes.includes(sourceType)); + + const matched = severityMatch && assetMatch && sourceMatch; + + const reasons: string[] = []; + + if (rule.severityLevels.length === 0) { + reasons.push("Severity: matches any (no filter set)"); + } else if (severityMatch) { + reasons.push( + `Severity: "${severity}" is in [${rule.severityLevels.join(", ")}]` + ); + } else { + reasons.push( + `Severity: "${severity}" not in [${rule.severityLevels.join(", ")}] — no match` + ); + } + + if (rule.assetCodes.length === 0) { + reasons.push("Asset: matches any (no filter set)"); + } else if (assetMatch) { + reasons.push( + `Asset: "${assetCode}" is in [${rule.assetCodes.join(", ")}]` + ); + } else { + reasons.push( + `Asset: "${assetCode || "(empty)"}" not in [${rule.assetCodes.join(", ")}] — no match` + ); + } + + if (rule.sourceTypes.length === 0) { + reasons.push("Source type: matches any (no filter set)"); + } else if (sourceMatch) { + reasons.push( + `Source type: "${sourceType}" is in [${rule.sourceTypes.join(", ")}]` + ); + } else { + reasons.push( + `Source type: "${sourceType || "(empty)"}" not in [${rule.sourceTypes.join(", ")}] — no match` + ); + } + + return { + ruleId: rule.id, + ruleName: rule.name, + priorityOrder: rule.priorityOrder, + ownerAddress: rule.ownerAddress, + matched, + reasons, + channels: matched ? rule.channels : [], + fallbackChannels: matched ? rule.fallbackChannels : [], + suppressionWindowSeconds: rule.suppressionWindowSeconds, + }; + }); + + const matchedResults = ruleResults.filter((r) => r.matched); + const firstMatch = matchedResults[0] ?? null; + + const simulationId = `sim_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; + + return reply.send({ + simulationId, + timestamp: new Date().toISOString(), + input: { + severity, + assetCode, + sourceType, + ownerAddress: body.ownerAddress ?? null, + label: body.label ?? null, + triggeredValue: body.triggeredValue ?? null, + threshold: body.threshold ?? null, + metric: body.metric ?? null, + }, + results: ruleResults, + skippedInactive: inactiveRules.map((r) => ({ + ruleId: r.id, + ruleName: r.name, + priorityOrder: r.priorityOrder, + })), + summary: { + totalActiveRules: activeRules.length, + totalMatched: matchedResults.length, + firstMatchingRule: firstMatch + ? { ruleId: firstMatch.ruleId, ruleName: firstMatch.ruleName } + : null, + wouldDispatch: firstMatch !== null, + effectiveChannels: firstMatch?.channels ?? [], + effectiveFallbackChannels: firstMatch?.fallbackChannels ?? [], + suppressionWindowSeconds: firstMatch?.suppressionWindowSeconds ?? 0, + }, + }); + } + ); } diff --git a/backend/src/api/routes/anomalyDetection.routes.ts b/backend/src/api/routes/anomalyDetection.routes.ts new file mode 100644 index 00000000..412a6ac0 --- /dev/null +++ b/backend/src/api/routes/anomalyDetection.routes.ts @@ -0,0 +1,143 @@ +import type { FastifyInstance } from "fastify"; +import { anomalyDetectionService } from "../../services/anomalyDetection.service.js"; +import type { AnomalySeverity } from "../../database/models/anomaly.model.js"; + +export async function anomalyDetectionRoutes(server: FastifyInstance) { + server.get<{ + Querystring: { + assetCode?: string; + bridgeName?: string; + severity?: AnomalySeverity; + includeSuppressed?: string; + limit?: string; + }; + }>( + "/events", + { + schema: { + tags: ["Anomaly Detection"], + summary: "List recent anomaly detections", + querystring: { + type: "object", + properties: { + assetCode: { type: "string" }, + bridgeName: { type: "string" }, + severity: { type: "string", enum: ["low", "medium", "high", "critical"] }, + includeSuppressed: { type: "string", enum: ["true", "false"] }, + limit: { type: "string" }, + }, + }, + response: { 200: { type: "object", additionalProperties: true } }, + }, + }, + async (request) => { + const events = await anomalyDetectionService.getRecentEvents({ + assetCode: request.query.assetCode, + bridgeName: request.query.bridgeName, + severity: request.query.severity, + includeSuppressed: request.query.includeSuppressed === "true", + limit: request.query.limit ? Number(request.query.limit) : undefined, + }); + + return { events, count: events.length }; + } + ); + + server.post<{ + Body: { assetCode: string; bridgeName?: string }; + }>( + "/evaluate", + { + schema: { + tags: ["Anomaly Detection"], + summary: "Run anomaly detection for a single asset", + body: { + type: "object", + required: ["assetCode"], + properties: { + assetCode: { type: "string" }, + bridgeName: { type: "string" }, + }, + }, + response: { 200: { type: "object", additionalProperties: true } }, + }, + }, + async (request) => anomalyDetectionService.evaluateAsset(request.body.assetCode, request.body.bridgeName) + ); + + server.get( + "/thresholds", + { + schema: { + tags: ["Anomaly Detection"], + summary: "List anomaly detection thresholds", + response: { 200: { type: "object", additionalProperties: true } }, + }, + }, + async () => { + const thresholds = await anomalyDetectionService.getThresholds(); + return { thresholds, count: thresholds.length }; + } + ); + + server.put<{ + Body: { + assetCode?: string; + bridgeName?: string; + priceChangePct: number; + liquidityChangePct: number; + supplyMismatchPct: number; + healthScoreDrop: number; + minSignalCount: number; + duplicateWindowSeconds: number; + isActive?: boolean; + }; + }>( + "/thresholds", + { + schema: { + tags: ["Anomaly Detection"], + summary: "Create or update anomaly detection thresholds", + body: { + type: "object", + required: [ + "priceChangePct", + "liquidityChangePct", + "supplyMismatchPct", + "healthScoreDrop", + "minSignalCount", + "duplicateWindowSeconds", + ], + properties: { + assetCode: { type: "string", default: "*" }, + bridgeName: { type: "string", default: "*" }, + priceChangePct: { type: "number", minimum: 0 }, + liquidityChangePct: { type: "number", minimum: 0 }, + supplyMismatchPct: { type: "number", minimum: 0 }, + healthScoreDrop: { type: "number", minimum: 0 }, + minSignalCount: { type: "integer", minimum: 1 }, + duplicateWindowSeconds: { type: "integer", minimum: 1 }, + isActive: { type: "boolean", default: true }, + }, + }, + response: { 200: { type: "object", additionalProperties: true } }, + }, + }, + async (request) => { + const body = request.body; + const threshold = await anomalyDetectionService.upsertThreshold({ + asset_code: body.assetCode ?? "*", + bridge_name: body.bridgeName ?? "*", + price_change_pct: body.priceChangePct, + liquidity_change_pct: body.liquidityChangePct, + supply_mismatch_pct: body.supplyMismatchPct, + health_score_drop: body.healthScoreDrop, + min_signal_count: body.minSignalCount, + duplicate_window_seconds: body.duplicateWindowSeconds, + is_active: body.isActive ?? true, + }); + + return { threshold }; + } + ); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index e12c84e0..4949f9e9 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -64,6 +64,8 @@ import { freshnessRoutes } from "./freshness.js"; import { providerAllowlistRoutes } from "./providerAllowlist.routes.js"; import { providerAllowlistAdminRoutes } from "./providerAllowlistAdmin.routes.js"; import { provenanceRoutes } from "./provenance.routes.js"; +import { operationalAccessAuditRoutes } from "./operationalAccessAudit.js"; +import { anomalyDetectionRoutes } from "./anomalyDetection.routes.js"; export async function registerRoutes(server: FastifyInstance) { server.register(assetsRoutes, { prefix: "/api/v1/assets" }); @@ -162,4 +164,8 @@ export async function registerRoutes(server: FastifyInstance) { prefix: "/api/v1/admin/providers/allowlist", }); server.register(provenanceRoutes, { prefix: "/api/v1/provenance" }); + server.register(operationalAccessAuditRoutes, { + prefix: "/api/v1/admin/access-audit", + }); + server.register(anomalyDetectionRoutes, { prefix: "/api/v1/anomaly-detection" }); } diff --git a/backend/src/api/routes/operationalAccessAudit.ts b/backend/src/api/routes/operationalAccessAudit.ts new file mode 100644 index 00000000..80df7d68 --- /dev/null +++ b/backend/src/api/routes/operationalAccessAudit.ts @@ -0,0 +1,296 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { + auditService, + type AuditAction, + type AuditSeverity, +} from "../../services/audit.service.js"; +import { AdminRotationService } from "../../services/adminRotation.service.js"; +import { SessionService, type SessionStatus } from "../../services/session.service.js"; +import { authMiddleware } from "../middleware/auth.js"; + +const ACCESS_AUDIT_ACTIONS: AuditAction[] = [ + "auth.login", + "auth.logout", + "auth.api_key_created", + "auth.api_key_revoked", + "admin.config_changed", + "admin.provider_allowlist_changed", + "admin.user_permission_changed", + "admin.retention_policy_changed", +]; + +// Privilege escalations / sensitive changes that get flagged for review +const FLAGGED_ACTIONS: AuditAction[] = [ + "admin.user_permission_changed", + "admin.config_changed", + "admin.retention_policy_changed", + "auth.api_key_revoked", +]; + +interface EntriesQuerystring { + actorId?: string; + action?: AuditAction; + severity?: AuditSeverity; + from?: string; + to?: string; + limit?: number; + offset?: number; + flagged?: string; +} + +interface RolesQuerystring { + activeOnly?: string; +} + +interface SessionsQuerystring { + userId?: string; + status?: SessionStatus; + page?: number; + limit?: number; +} + +export async function operationalAccessAuditRoutes(server: FastifyInstance) { + const requireAuditRead = authMiddleware({ requiredScopes: ["admin:audit"] }); + const requireAuditAdmin = authMiddleware({ + requiredScopes: ["admin:audit", "admin:config"], + }); + const adminRotationService = AdminRotationService.getInstance(); + const sessionService = new SessionService(); + + // --------------------------------------------------------------------------- + // GET /entries — paginated, filterable access audit log + // --------------------------------------------------------------------------- + + server.get<{ Querystring: EntriesQuerystring }>( + "/entries", + { + preHandler: requireAuditRead, + rateLimit: { max: 30, timeWindow: "1 minute" }, + schema: { + tags: ["Admin"], + summary: "List access audit log entries", + security: [{ ApiKeyAuth: [] }], + }, + } as any, + async ( + request: FastifyRequest<{ Querystring: EntriesQuerystring }>, + reply: FastifyReply + ) => { + try { + const { actorId, severity, from, to, flagged } = request.query; + const limit = Math.min(request.query.limit ? Number(request.query.limit) : 100, 500); + const offset = request.query.offset ? Number(request.query.offset) : 0; + + // Validate requested action is an access-type action + const action = + request.query.action && ACCESS_AUDIT_ACTIONS.includes(request.query.action) + ? request.query.action + : undefined; + + const { entries, total } = await auditService.query({ + actorId, + action, + severity, + from: from ? new Date(from) : undefined, + to: to ? new Date(to) : undefined, + limit: limit + offset + 500, // fetch extra so post-filter still has enough + offset: 0, + }); + + const accessEntries = entries.filter((e) => ACCESS_AUDIT_ACTIONS.includes(e.action)); + + const flaggedOnly = flagged === "true"; + const filtered = flaggedOnly + ? accessEntries.filter( + (e) => + FLAGGED_ACTIONS.includes(e.action) || + e.severity === "critical" || + e.severity === "warning" + ) + : accessEntries; + + const page = filtered.slice(offset, offset + limit); + + return { + entries: page, + total: filtered.length, + limit, + offset, + flaggedActions: FLAGGED_ACTIONS, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to query access audit log"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // GET /stats — aggregate stats for the access audit console + // --------------------------------------------------------------------------- + + server.get<{ Querystring: { from?: string } }>( + "/stats", + { + preHandler: requireAuditRead, + rateLimit: { max: 30, timeWindow: "1 minute" }, + schema: { + tags: ["Admin"], + summary: "Access audit aggregate statistics", + security: [{ ApiKeyAuth: [] }], + }, + } as any, + async ( + request: FastifyRequest<{ Querystring: { from?: string } }>, + reply: FastifyReply + ) => { + try { + const from = request.query.from ? new Date(request.query.from) : undefined; + const [stats, adminCount] = await Promise.all([ + auditService.getStats(from), + adminRotationService.getActiveAdminCount(), + ]); + + return { + ...stats, + activeAdminCount: adminCount, + trackedActions: ACCESS_AUDIT_ACTIONS, + flaggedActions: FLAGGED_ACTIONS, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to get access audit stats"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // GET /roles — admin members, their roles, and recent rotation events + // --------------------------------------------------------------------------- + + server.get<{ Querystring: RolesQuerystring }>( + "/roles", + { + preHandler: requireAuditRead, + rateLimit: { max: 30, timeWindow: "1 minute" }, + schema: { + tags: ["Admin"], + summary: "List admin roles, memberships, and recent permission changes", + security: [{ ApiKeyAuth: [] }], + }, + } as any, + async ( + request: FastifyRequest<{ Querystring: RolesQuerystring }>, + reply: FastifyReply + ) => { + try { + const activeOnly = request.query.activeOnly !== "false"; + const [admins, recentEvents] = await Promise.all([ + adminRotationService.listAdmins(activeOnly), + adminRotationService.getRotationEvents(undefined, 50), + ]); + + return { admins, recentEvents }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to list admin roles"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // GET /sessions — list user sessions with optional filters + // --------------------------------------------------------------------------- + + server.get<{ Querystring: SessionsQuerystring }>( + "/sessions", + { + preHandler: requireAuditRead, + rateLimit: { max: 30, timeWindow: "1 minute" }, + schema: { + tags: ["Admin"], + summary: "List user sessions for access review", + security: [{ ApiKeyAuth: [] }], + }, + } as any, + async ( + request: FastifyRequest<{ Querystring: SessionsQuerystring }>, + reply: FastifyReply + ) => { + try { + const { userId, status } = request.query; + const page = request.query.page ? Number(request.query.page) : 1; + const limit = Math.min( + request.query.limit ? Number(request.query.limit) : 50, + 200 + ); + + const result = await sessionService.listSessions({ + userId, + status: status as SessionStatus | undefined, + page, + limit, + }); + + return { success: true, data: result.data, meta: result.meta }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to list sessions"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // GET /export — CSV export of access audit entries + // --------------------------------------------------------------------------- + + server.get<{ Querystring: EntriesQuerystring }>( + "/export", + { + preHandler: requireAuditAdmin, + rateLimit: { max: 5, timeWindow: "1 minute" }, + schema: { + tags: ["Admin"], + summary: "Export access audit log as CSV", + security: [{ ApiKeyAuth: [] }], + }, + } as any, + async ( + request: FastifyRequest<{ Querystring: EntriesQuerystring }>, + reply: FastifyReply + ) => { + try { + const { actorId, severity, from, to } = request.query; + const action = + request.query.action && ACCESS_AUDIT_ACTIONS.includes(request.query.action) + ? request.query.action + : undefined; + + const csv = await auditService.exportCsv({ + actorId, + action, + severity, + from: from ? new Date(from) : undefined, + to: to ? new Date(to) : undefined, + }); + + return reply + .code(200) + .header("Content-Type", "text/csv") + .header( + "Content-Disposition", + `attachment; filename="access-audit-${Date.now()}.csv"` + ) + .send(csv); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to export access audit log"; + return reply.code(500).send({ error: message }); + } + } + ); +} diff --git a/backend/src/database/migrations/005_liquidity_pool_monitoring.ts b/backend/src/database/migrations/005_liquidity_pool_monitoring.ts index aa517d60..c3771d6a 100644 --- a/backend/src/database/migrations/005_liquidity_pool_monitoring.ts +++ b/backend/src/database/migrations/005_liquidity_pool_monitoring.ts @@ -28,7 +28,7 @@ export async function up(knex: Knex): Promise { // Pool events table (hypertable for time-series data) await knex.schema.createTable("pool_events", (table) => { table.timestamp("time").notNullable().defaultTo(knex.fn.now()); - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.uuid("pool_id").notNullable(); table.string("type").notNullable(); // deposit, withdraw, swap table.decimal("amount_a", 20, 8).notNullable(); @@ -36,7 +36,10 @@ export async function up(knex: Knex): Promise { table.string("user").notNullable(); table.string("tx_hash").notNullable(); table.json("metadata").nullable(); - + + // Composite primary key required by TimescaleDB hypertable on 'time' column + table.primary(["id", "time"]); + // Indexes table.index(["pool_id", "time"]); table.index("type"); diff --git a/backend/src/database/migrations/006_search_system.ts b/backend/src/database/migrations/006_search_system.ts index 8848601e..eb8090ab 100644 --- a/backend/src/database/migrations/006_search_system.ts +++ b/backend/src/database/migrations/006_search_system.ts @@ -4,7 +4,7 @@ export async function up(knex: Knex): Promise { // Search analytics table (hypertable for time-series data) await knex.schema.createTable("search_analytics", (table) => { table.timestamp("time").notNullable().defaultTo(knex.fn.now()); - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.string("query").notNullable(); table.string("user_id").nullable(); table.integer("results_count").nullable(); @@ -12,7 +12,10 @@ export async function up(knex: Knex): Promise { table.json("filters").nullable(); table.string("user_agent").nullable(); table.string("ip_address").nullable(); - + + // Composite primary key required by TimescaleDB hypertable on 'time' column + table.primary(["id", "time"]); + // Indexes for performance table.index(["query", "time"]); table.index("user_id"); diff --git a/backend/src/database/migrations/007_data_cleanup_system.ts b/backend/src/database/migrations/007_data_cleanup_system.ts index 5c2c471c..2d450eed 100644 --- a/backend/src/database/migrations/007_data_cleanup_system.ts +++ b/backend/src/database/migrations/007_data_cleanup_system.ts @@ -4,7 +4,7 @@ export async function up(knex: Knex): Promise { // Cleanup metrics table (hypertable for time-series data) await knex.schema.createTable("cleanup_metrics", (table) => { table.timestamp("time").notNullable().defaultTo(knex.fn.now()); - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.integer("total_records_processed").notNullable().defaultTo(0); table.integer("total_records_archived").notNullable().defaultTo(0); table.integer("total_records_deleted").notNullable().defaultTo(0); @@ -13,7 +13,10 @@ export async function up(knex: Knex): Promise { table.json("reports").notNullable(); table.string("trigger_type").notNullable(); // scheduled, manual table.string("triggered_by").nullable(); - + + // Composite primary key required by TimescaleDB hypertable on 'time' column + table.primary(["id", "time"]); + // Indexes table.index("time"); table.index("trigger_type"); diff --git a/backend/src/database/migrations/008_discord_integration.ts b/backend/src/database/migrations/008_discord_integration.ts index 7df92669..0b1034f3 100644 --- a/backend/src/database/migrations/008_discord_integration.ts +++ b/backend/src/database/migrations/008_discord_integration.ts @@ -24,7 +24,7 @@ export async function up(knex: Knex): Promise { // Discord alerts log table (hypertable for time-series data) await knex.schema.createTable("discord_alerts_log", (table) => { table.timestamp("time").notNullable().defaultTo(knex.fn.now()); - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.string("subscription_id").notNullable(); table.string("alert_id").notNullable(); table.string("alert_type").notNullable(); // bridge, pool, price, health @@ -38,6 +38,9 @@ export async function up(knex: Knex): Promise { table.boolean("delivered").defaultTo(false); table.text("error_message").nullable(); + // Composite primary key required by TimescaleDB hypertable on 'time' column + table.primary(["id", "time"]); + // Indexes table.index(["subscription_id", "time"]); table.index(["guild_id", "channel_id"]); @@ -55,7 +58,7 @@ export async function up(knex: Knex): Promise { // Discord commands usage table (hypertable for analytics) await knex.schema.createTable("discord_commands_usage", (table) => { table.timestamp("time").notNullable().defaultTo(knex.fn.now()); - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.string("guild_id").notNullable(); table.string("channel_id").notNullable(); table.string("user_id").notNullable(); @@ -64,7 +67,10 @@ export async function up(knex: Knex): Promise { table.integer("response_time_ms").defaultTo(0); table.boolean("success").defaultTo(true); table.text("error_message").nullable(); - + + // Composite primary key required by TimescaleDB hypertable on 'time' column + table.primary(["id", "time"]); + // Indexes table.index(["guild_id", "time"]); table.index("command_name"); diff --git a/backend/src/database/migrations/017_health_score_history.ts b/backend/src/database/migrations/017_health_score_history.ts index 4b724694..c9fc7b7b 100644 --- a/backend/src/database/migrations/017_health_score_history.ts +++ b/backend/src/database/migrations/017_health_score_history.ts @@ -3,7 +3,7 @@ import type { Knex } from "knex"; export async function up(knex: Knex): Promise { // Dedicated history table with TimescaleDB hypertable support await knex.schema.createTable("health_score_history", (table) => { - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.string("symbol").notNullable(); table.integer("overall_score").notNullable(); table.integer("liquidity_depth_score").notNullable().defaultTo(0); @@ -16,6 +16,9 @@ export async function up(knex: Knex): Promise { table.string("source").notNullable().defaultTo("scheduled"); // scheduled | manual | backfill table.timestamp("recorded_at").notNullable().defaultTo(knex.fn.now()); + // Composite primary key required by TimescaleDB hypertable on 'recorded_at' column + table.primary(["id", "recorded_at"]); + table.index(["symbol", "recorded_at"]); table.index(["recorded_at"]); }); diff --git a/backend/src/database/migrations/020_external_dependency_monitor.ts b/backend/src/database/migrations/020_external_dependency_monitor.ts index aa1a5d0d..180a6891 100644 --- a/backend/src/database/migrations/020_external_dependency_monitor.ts +++ b/backend/src/database/migrations/020_external_dependency_monitor.ts @@ -28,7 +28,7 @@ export async function up(knex: Knex): Promise { await knex.schema.createTable("external_dependency_checks", (table) => { table.timestamp("checked_at").notNullable().defaultTo(knex.fn.now()); - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.string("provider_key").notNullable(); table.string("status").notNullable(); table.integer("latency_ms").nullable(); @@ -38,6 +38,9 @@ export async function up(knex: Knex): Promise { table.text("error").nullable(); table.jsonb("details").notNullable().defaultTo("{}"); + // Composite primary key required by TimescaleDB hypertable on 'checked_at' column + table.primary(["id", "checked_at"]); + table.index(["provider_key", "checked_at"]); table.index(["status", "checked_at"]); table.index(["alert_triggered"]); diff --git a/backend/src/database/migrations/022_telegram_subscriptions.ts b/backend/src/database/migrations/022_telegram_subscriptions.ts index 01dc198e..53f26b6a 100644 --- a/backend/src/database/migrations/022_telegram_subscriptions.ts +++ b/backend/src/database/migrations/022_telegram_subscriptions.ts @@ -23,7 +23,7 @@ export async function up(knex: Knex): Promise { // Telegram alerts delivery log (hypertable for time-series data) await knex.schema.createTable("telegram_alerts_log", (table) => { table.timestamp("time").notNullable().defaultTo(knex.fn.now()); - table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.uuid("id").notNullable().defaultTo(knex.raw("gen_random_uuid()")); table.uuid("subscription_id").notNullable(); table.string("chat_id").notNullable(); table.string("alert_id").notNullable(); @@ -37,6 +37,9 @@ export async function up(knex: Knex): Promise { table.boolean("delivered").notNullable().defaultTo(false); table.text("error_message").nullable(); + // Composite primary key required by TimescaleDB hypertable on 'time' column + table.primary(["id", "time"]); + // Indexes for efficient querying and time-series operations table.index(["subscription_id", "time"]); table.index(["chat_id", "time"]); diff --git a/backend/src/database/migrations/033_anomaly_detection_engine.ts b/backend/src/database/migrations/033_anomaly_detection_engine.ts new file mode 100644 index 00000000..a9e46e43 --- /dev/null +++ b/backend/src/database/migrations/033_anomaly_detection_engine.ts @@ -0,0 +1,59 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("anomaly_thresholds", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("asset_code").notNullable().defaultTo("*"); + table.string("bridge_name").notNullable().defaultTo("*"); + table.decimal("price_change_pct", 12, 6).notNullable().defaultTo(5); + table.decimal("liquidity_change_pct", 12, 6).notNullable().defaultTo(25); + table.decimal("supply_mismatch_pct", 12, 6).notNullable().defaultTo(1); + table.integer("health_score_drop").notNullable().defaultTo(10); + table.integer("min_signal_count").notNullable().defaultTo(2); + table.integer("duplicate_window_seconds").notNullable().defaultTo(900); + table.boolean("is_active").notNullable().defaultTo(true); + table.timestamps(true, true); + + table.unique(["asset_code", "bridge_name"]); + table.index(["asset_code", "is_active"]); + table.index(["bridge_name", "is_active"]); + }); + + await knex.schema.createTable("anomaly_events", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("asset_code").notNullable(); + table.string("bridge_name").nullable(); + table.string("type").notNullable(); + table.string("severity").notNullable(); + table.jsonb("signals").notNullable(); + table.jsonb("explanation").notNullable(); + table.jsonb("metadata").notNullable().defaultTo("{}"); + table.string("fingerprint").notNullable(); + table.timestamp("detected_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("suppressed_until").nullable(); + table.boolean("is_suppressed").notNullable().defaultTo(false); + table.uuid("suppressed_by_event_id").nullable(); + + table.index(["asset_code", "detected_at"]); + table.index(["bridge_name", "detected_at"]); + table.index(["severity", "detected_at"]); + table.index(["fingerprint", "detected_at"]); + }); + + await knex("anomaly_thresholds").insert({ + asset_code: "*", + bridge_name: "*", + price_change_pct: 5, + liquidity_change_pct: 25, + supply_mismatch_pct: 1, + health_score_drop: 10, + min_signal_count: 2, + duplicate_window_seconds: 900, + is_active: true, + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("anomaly_events"); + await knex.schema.dropTableIfExists("anomaly_thresholds"); +} diff --git a/backend/src/database/models/anomaly.model.ts b/backend/src/database/models/anomaly.model.ts new file mode 100644 index 00000000..04360a99 --- /dev/null +++ b/backend/src/database/models/anomaly.model.ts @@ -0,0 +1,143 @@ +import { getDatabase } from "../connection.js"; + +export type AnomalySeverity = "low" | "medium" | "high" | "critical"; +export type AnomalyType = "spike" | "drop" | "divergence" | "bridge_health" | "multi_signal"; + +export interface AnomalyThresholdRecord { + id?: string; + asset_code: string; + bridge_name: string; + price_change_pct: number; + liquidity_change_pct: number; + supply_mismatch_pct: number; + health_score_drop: number; + min_signal_count: number; + duplicate_window_seconds: number; + is_active: boolean; + created_at?: Date; + updated_at?: Date; +} + +export interface AnomalyEventRecord { + id?: string; + asset_code: string; + bridge_name: string | null; + type: AnomalyType; + severity: AnomalySeverity; + signals: unknown; + explanation: unknown; + metadata: unknown; + fingerprint: string; + detected_at: Date; + suppressed_until: Date | null; + is_suppressed: boolean; + suppressed_by_event_id: string | null; +} + +export interface AnomalyEventFilters { + assetCode?: string; + bridgeName?: string; + severity?: AnomalySeverity; + includeSuppressed?: boolean; + limit?: number; +} + +export class AnomalyModel { + private db = getDatabase(); + + async getActiveThresholds(): Promise { + const rows = await this.db("anomaly_thresholds") + .where({ is_active: true }) + .orderByRaw("CASE WHEN asset_code = '*' THEN 1 ELSE 0 END") + .orderByRaw("CASE WHEN bridge_name = '*' THEN 1 ELSE 0 END"); + + return rows.map(this.mapThreshold); + } + + async getThresholds(): Promise { + const rows = await this.db("anomaly_thresholds").orderBy("asset_code", "asc").orderBy("bridge_name", "asc"); + return rows.map(this.mapThreshold); + } + + async upsertThreshold(input: Omit): Promise { + const [row] = await this.db("anomaly_thresholds") + .insert(input) + .onConflict(["asset_code", "bridge_name"]) + .merge({ + price_change_pct: input.price_change_pct, + liquidity_change_pct: input.liquidity_change_pct, + supply_mismatch_pct: input.supply_mismatch_pct, + health_score_drop: input.health_score_drop, + min_signal_count: input.min_signal_count, + duplicate_window_seconds: input.duplicate_window_seconds, + is_active: input.is_active, + updated_at: this.db.fn.now(), + }) + .returning("*"); + + return this.mapThreshold(row); + } + + async findRecentFingerprint(fingerprint: string, since: Date): Promise { + const row = await this.db("anomaly_events") + .where({ fingerprint, is_suppressed: false }) + .andWhere("detected_at", ">=", since) + .orderBy("detected_at", "desc") + .first(); + + return row ? this.mapEvent(row) : undefined; + } + + async insertEvent(event: Omit): Promise { + const [row] = await this.db("anomaly_events").insert(event).returning("*"); + return this.mapEvent(row); + } + + async getRecentEvents(filters: AnomalyEventFilters = {}): Promise { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + const query = this.db("anomaly_events").orderBy("detected_at", "desc").limit(limit); + + if (filters.assetCode) query.where("asset_code", filters.assetCode.toUpperCase()); + if (filters.bridgeName) query.where("bridge_name", filters.bridgeName); + if (filters.severity) query.where("severity", filters.severity); + if (!filters.includeSuppressed) query.where({ is_suppressed: false }); + + const rows = await query; + return rows.map(this.mapEvent); + } + + private mapThreshold(row: any): AnomalyThresholdRecord { + return { + id: row.id, + asset_code: row.asset_code, + bridge_name: row.bridge_name, + price_change_pct: Number(row.price_change_pct), + liquidity_change_pct: Number(row.liquidity_change_pct), + supply_mismatch_pct: Number(row.supply_mismatch_pct), + health_score_drop: Number(row.health_score_drop), + min_signal_count: Number(row.min_signal_count), + duplicate_window_seconds: Number(row.duplicate_window_seconds), + is_active: Boolean(row.is_active), + created_at: row.created_at ? new Date(row.created_at) : undefined, + updated_at: row.updated_at ? new Date(row.updated_at) : undefined, + }; + } + + private mapEvent(row: any): AnomalyEventRecord { + return { + id: row.id, + asset_code: row.asset_code, + bridge_name: row.bridge_name, + type: row.type, + severity: row.severity, + signals: row.signals, + explanation: row.explanation, + metadata: row.metadata, + fingerprint: row.fingerprint, + detected_at: new Date(row.detected_at), + suppressed_until: row.suppressed_until ? new Date(row.suppressed_until) : null, + is_suppressed: Boolean(row.is_suppressed), + suppressed_by_event_id: row.suppressed_by_event_id, + }; + } +} diff --git a/backend/src/services/anomalyDetection.service.ts b/backend/src/services/anomalyDetection.service.ts new file mode 100644 index 00000000..547ba09d --- /dev/null +++ b/backend/src/services/anomalyDetection.service.ts @@ -0,0 +1,352 @@ +import crypto from "node:crypto"; +import { SUPPORTED_ASSETS } from "../config/index.js"; +import { + AnomalyModel, + type AnomalyEventRecord, + type AnomalySeverity, + type AnomalyThresholdRecord, + type AnomalyType, +} from "../database/models/anomaly.model.js"; +import { logger } from "../utils/logger.js"; +import { BridgeService, type BridgeStatus } from "./bridge.service.js"; +import { HealthService, type HealthScore } from "./health.service.js"; +import { LiquidityService, type AggregatedLiquidity } from "./liquidity.service.js"; +import { PriceService, type AggregatedPrice } from "./price.service.js"; + +type SignalType = "price" | "liquidity" | "supply" | "bridge_health" | "health_score"; + +export interface DetectionSignal { + type: SignalType; + direction: "spike" | "drop" | "divergence" | "degraded"; + metric: string; + current: number | string; + previous?: number | string | null; + threshold: number | string; + delta?: number | null; +} + +export interface DetectionExplanation { + summary: string; + rules: string[]; + evidence: DetectionSignal[]; +} + +export interface DetectionSnapshot { + assetCode: string; + price?: AggregatedPrice | null; + liquidity?: AggregatedLiquidity | null; + health?: HealthScore | null; + bridge?: BridgeStatus | null; +} + +export interface AnomalyDetectionResult { + assetCode: string; + bridgeName: string | null; + anomaly: boolean; + event?: AnomalyEventRecord; + signals: DetectionSignal[]; + explanation: DetectionExplanation; + suppressed?: boolean; +} + +interface PreviousSnapshot { + price?: number; + liquidity?: number; + healthScore?: number; +} + +const SEVERITY_WEIGHT: Record = { + low: 1, + medium: 2, + high: 3, + critical: 4, +}; + +export class AnomalyDetectionService { + private model = new AnomalyModel(); + private priceService = new PriceService(); + private liquidityService = new LiquidityService(); + private healthService = new HealthService(); + private bridgeService = new BridgeService(); + private previousSnapshots = new Map(); + + async evaluateAllAssets(): Promise { + const bridgeStatuses = await this.bridgeService.getAllBridgeStatuses(); + const results: AnomalyDetectionResult[] = []; + + for (const asset of SUPPORTED_ASSETS) { + const bridge = this.findBridgeForAsset(bridgeStatuses.bridges, asset.code); + const result = await this.evaluateAsset(asset.code, bridge?.name); + results.push(result); + } + + return results; + } + + async evaluateAsset(assetCode: string, bridgeName?: string): Promise { + const normalizedAsset = assetCode.toUpperCase(); + const snapshot = await this.collectSnapshot(normalizedAsset, bridgeName); + const thresholds = await this.model.getActiveThresholds(); + const threshold = this.resolveThreshold(thresholds, normalizedAsset, snapshot.bridge?.name ?? bridgeName ?? "*"); + const signals = this.detectSignals(snapshot, threshold); + const type = this.resolveType(signals); + const severity = this.resolveSeverity(signals, threshold); + const explanation = this.buildExplanation(normalizedAsset, snapshot.bridge?.name ?? bridgeName ?? null, signals, threshold); + + this.rememberSnapshot(normalizedAsset, snapshot); + + if (signals.length < threshold.min_signal_count) { + return { + assetCode: normalizedAsset, + bridgeName: snapshot.bridge?.name ?? bridgeName ?? null, + anomaly: false, + signals, + explanation, + }; + } + + const fingerprint = this.fingerprint(normalizedAsset, snapshot.bridge?.name ?? bridgeName ?? null, signals); + const duplicateSince = new Date(Date.now() - threshold.duplicate_window_seconds * 1000); + const duplicate = await this.model.findRecentFingerprint(fingerprint, duplicateSince); + const now = new Date(); + const suppressedUntil = new Date(now.getTime() + threshold.duplicate_window_seconds * 1000); + const event = await this.model.insertEvent({ + asset_code: normalizedAsset, + bridge_name: snapshot.bridge?.name ?? bridgeName ?? null, + type, + severity, + signals, + explanation, + metadata: { + thresholdId: threshold.id ?? null, + priceLastUpdated: snapshot.price?.lastUpdated ?? null, + liquidityLastUpdated: snapshot.liquidity?.lastUpdated ?? null, + healthLastUpdated: snapshot.health?.lastUpdated ?? null, + bridgeLastChecked: snapshot.bridge?.lastChecked ?? null, + }, + fingerprint, + detected_at: now, + suppressed_until: duplicate ? suppressedUntil : null, + is_suppressed: Boolean(duplicate), + suppressed_by_event_id: duplicate?.id ?? null, + }); + + if (!event.is_suppressed) { + logger.warn({ eventId: event.id, assetCode: normalizedAsset, severity, type }, "Anomaly detected"); + } + + return { + assetCode: normalizedAsset, + bridgeName: event.bridge_name, + anomaly: !event.is_suppressed, + event, + signals, + explanation, + suppressed: event.is_suppressed, + }; + } + + getRecentEvents(filters: Parameters[0]) { + return this.model.getRecentEvents(filters); + } + + getThresholds() { + return this.model.getThresholds(); + } + + upsertThreshold(input: Omit) { + return this.model.upsertThreshold({ + ...input, + asset_code: input.asset_code.toUpperCase(), + bridge_name: input.bridge_name || "*", + }); + } + + private async collectSnapshot(assetCode: string, bridgeName?: string): Promise { + const [priceResult, liquidityResult, healthResult, bridgeResult] = await Promise.allSettled([ + this.priceService.getAggregatedPrice(assetCode), + this.liquidityService.getAggregatedLiquidity(assetCode), + this.healthService.getHealthScore(assetCode), + this.bridgeService.getAllBridgeStatuses(), + ]); + + const bridges = bridgeResult.status === "fulfilled" ? bridgeResult.value.bridges : []; + const bridge = bridgeName + ? bridges.find((item) => item.name === bridgeName) ?? null + : this.findBridgeForAsset(bridges, assetCode) ?? null; + + return { + assetCode, + price: priceResult.status === "fulfilled" ? priceResult.value : null, + liquidity: liquidityResult.status === "fulfilled" ? liquidityResult.value : null, + health: healthResult.status === "fulfilled" ? healthResult.value : null, + bridge, + }; + } + + private detectSignals(snapshot: DetectionSnapshot, threshold: AnomalyThresholdRecord): DetectionSignal[] { + const signals: DetectionSignal[] = []; + const previous = this.previousSnapshots.get(snapshot.assetCode); + + if (snapshot.price && previous?.price && previous.price > 0) { + const delta = ((snapshot.price.vwap - previous.price) / previous.price) * 100; + if (Math.abs(delta) >= threshold.price_change_pct) { + signals.push({ + type: "price", + direction: delta > 0 ? "spike" : "drop", + metric: "vwap", + current: snapshot.price.vwap, + previous: previous.price, + threshold: threshold.price_change_pct, + delta: Number(delta.toFixed(4)), + }); + } + } + + if (snapshot.price && snapshot.price.deviation * 100 >= threshold.price_change_pct) { + signals.push({ + type: "price", + direction: "divergence", + metric: "source_deviation_pct", + current: Number((snapshot.price.deviation * 100).toFixed(4)), + threshold: threshold.price_change_pct, + }); + } + + if (snapshot.liquidity && previous?.liquidity && previous.liquidity > 0) { + const delta = ((snapshot.liquidity.totalLiquidity - previous.liquidity) / previous.liquidity) * 100; + if (Math.abs(delta) >= threshold.liquidity_change_pct) { + signals.push({ + type: "liquidity", + direction: delta > 0 ? "spike" : "drop", + metric: "total_liquidity", + current: snapshot.liquidity.totalLiquidity, + previous: previous.liquidity, + threshold: threshold.liquidity_change_pct, + delta: Number(delta.toFixed(4)), + }); + } + } + + if (snapshot.bridge && snapshot.bridge.mismatchPercentage >= threshold.supply_mismatch_pct) { + signals.push({ + type: "supply", + direction: "divergence", + metric: "supply_mismatch_pct", + current: Number(snapshot.bridge.mismatchPercentage.toFixed(4)), + threshold: threshold.supply_mismatch_pct, + }); + } + + if (snapshot.bridge && snapshot.bridge.status !== "healthy") { + signals.push({ + type: "bridge_health", + direction: "degraded", + metric: "bridge_status", + current: snapshot.bridge.status, + threshold: "healthy", + }); + } + + if (snapshot.health && previous?.healthScore !== undefined) { + const drop = previous.healthScore - snapshot.health.overallScore; + if (drop >= threshold.health_score_drop) { + signals.push({ + type: "health_score", + direction: "drop", + metric: "overall_score", + current: snapshot.health.overallScore, + previous: previous.healthScore, + threshold: threshold.health_score_drop, + delta: Number(drop.toFixed(4)), + }); + } + } + + return signals; + } + + private resolveThreshold(thresholds: AnomalyThresholdRecord[], assetCode: string, bridgeName: string): AnomalyThresholdRecord { + return ( + thresholds.find((item) => item.asset_code === assetCode && item.bridge_name === bridgeName) ?? + thresholds.find((item) => item.asset_code === assetCode && item.bridge_name === "*") ?? + thresholds.find((item) => item.asset_code === "*" && item.bridge_name === bridgeName) ?? + thresholds.find((item) => item.asset_code === "*" && item.bridge_name === "*") ?? + { + asset_code: "*", + bridge_name: "*", + price_change_pct: 5, + liquidity_change_pct: 25, + supply_mismatch_pct: 1, + health_score_drop: 10, + min_signal_count: 2, + duplicate_window_seconds: 900, + is_active: true, + } + ); + } + + private resolveType(signals: DetectionSignal[]): AnomalyType { + if (signals.length > 1) return "multi_signal"; + const signal = signals[0]; + if (!signal) return "multi_signal"; + if (signal.type === "bridge_health") return "bridge_health"; + return signal.direction === "degraded" ? "bridge_health" : signal.direction; + } + + private resolveSeverity(signals: DetectionSignal[], threshold: AnomalyThresholdRecord): AnomalySeverity { + let severity: AnomalySeverity = signals.length >= 3 ? "high" : "medium"; + + for (const signal of signals) { + let candidate: AnomalySeverity = "medium"; + if (signal.type === "bridge_health" && signal.current === "down") candidate = "critical"; + if (signal.type === "supply" && Number(signal.current) >= threshold.supply_mismatch_pct * 3) candidate = "critical"; + if (signal.type === "price" && Math.abs(Number(signal.delta ?? signal.current)) >= threshold.price_change_pct * 3) candidate = "high"; + if (signal.type === "liquidity" && Math.abs(Number(signal.delta ?? 0)) >= threshold.liquidity_change_pct * 2) candidate = "high"; + if (SEVERITY_WEIGHT[candidate] > SEVERITY_WEIGHT[severity]) severity = candidate; + } + + return severity; + } + + private buildExplanation( + assetCode: string, + bridgeName: string | null, + signals: DetectionSignal[], + threshold: AnomalyThresholdRecord + ): DetectionExplanation { + const scope = bridgeName ? `${assetCode} on ${bridgeName}` : assetCode; + return { + summary: signals.length > 0 + ? `${scope} matched ${signals.length} anomaly signal(s); ${threshold.min_signal_count} correlated signal(s) required for alerting.` + : `${scope} did not match anomaly thresholds.`, + rules: [ + `Price movement or source divergence >= ${threshold.price_change_pct}%`, + `Liquidity movement >= ${threshold.liquidity_change_pct}%`, + `Supply mismatch >= ${threshold.supply_mismatch_pct}%`, + `Health score drop >= ${threshold.health_score_drop}`, + `Alert when at least ${threshold.min_signal_count} signal(s) correlate`, + ], + evidence: signals, + }; + } + + private rememberSnapshot(assetCode: string, snapshot: DetectionSnapshot): void { + this.previousSnapshots.set(assetCode, { + price: snapshot.price?.vwap, + liquidity: snapshot.liquidity?.totalLiquidity, + healthScore: snapshot.health?.overallScore, + }); + } + + private fingerprint(assetCode: string, bridgeName: string | null, signals: DetectionSignal[]): string { + const parts = signals.map((signal) => `${signal.type}:${signal.direction}:${signal.metric}`).sort(); + return crypto.createHash("sha256").update([assetCode, bridgeName ?? "*", ...parts].join("|")).digest("hex"); + } + + private findBridgeForAsset(bridges: BridgeStatus[], assetCode: string): BridgeStatus | undefined { + return bridges.find((bridge) => bridge.name.toUpperCase().includes(assetCode)); + } +} + +export const anomalyDetectionService = new AnomalyDetectionService(); diff --git a/backend/src/workers/anomalyDetection.job.ts b/backend/src/workers/anomalyDetection.job.ts new file mode 100644 index 00000000..f323f235 --- /dev/null +++ b/backend/src/workers/anomalyDetection.job.ts @@ -0,0 +1,13 @@ +import { Job } from "bullmq"; +import { anomalyDetectionService } from "../services/anomalyDetection.service.js"; +import { logger } from "../utils/logger.js"; + +export async function processAnomalyDetection(job: Job) { + logger.info({ jobId: job.id }, "Starting anomaly detection job"); + + const results = await anomalyDetectionService.evaluateAllAssets(); + const emitted = results.filter((result) => result.anomaly).length; + const suppressed = results.filter((result) => result.suppressed).length; + + logger.info({ evaluated: results.length, emitted, suppressed }, "Completed anomaly detection job"); +} diff --git a/backend/src/workers/index.ts b/backend/src/workers/index.ts index 0a926bcd..6d95f583 100644 --- a/backend/src/workers/index.ts +++ b/backend/src/workers/index.ts @@ -14,6 +14,7 @@ import { logger } from "../utils/logger.js"; import { initSupplyVerificationJob } from "../jobs/supplyVerification.job.js"; import { runAuditRetentionJob } from "../jobs/auditRetention.job.js"; import { processCachePriming } from "./cachePrimer.job.js"; +import { processAnomalyDetection } from "./anomalyDetection.job.js"; export async function initJobSystem() { const jobQueue = JobQueue.getInstance(); @@ -66,6 +67,9 @@ export async function initJobSystem() { case "reconciliation": await processReconciliation(job as any); break; + case "anomaly-detection": + await processAnomalyDetection(job); + break; default: logger.warn({ jobName: job.name }, "Unknown job name in worker"); } @@ -136,6 +140,8 @@ export async function initJobSystem() { await jobQueue.addRepeatableJob("external-dependency-monitor", {}, "*/2 * * * *"); // Staleness detection: every 5 minutes await jobQueue.addRepeatableJob("staleness-detection", {}, "*/5 * * * *"); + // Anomaly detection: correlate fresh price, liquidity, supply, and bridge signals every minute + await jobQueue.addRepeatableJob("anomaly-detection", {}, "*/1 * * * *"); // reconciliation: per-asset, every hour (top of hour) // Note: This uses the queue helper for retry/backoff defaults. for (const assetCode of ["USDC", "EURC"]) { diff --git a/contracts/soroban/src/asset_registry.rs b/contracts/soroban/src/asset_registry.rs index 87bb8ed3..3071dc7c 100644 --- a/contracts/soroban/src/asset_registry.rs +++ b/contracts/soroban/src/asset_registry.rs @@ -1101,7 +1101,9 @@ impl AssetRegistryContract { } whitelist.push_back(asset_code.clone()); - env.storage().instance().set(&DataKey::Whitelist, &whitelist); + env.storage() + .instance() + .set(&DataKey::Whitelist, &whitelist); env.events() .publish((symbol_short!("wl_add"), asset_code), 1u32); @@ -1236,10 +1238,8 @@ impl AssetRegistryContract { /// Check if an asset is currently frozen. Public read. pub fn is_asset_frozen(env: Env, asset_code: String) -> bool { - let frozen: Option = env - .storage() - .persistent() - .get(&DataKey::Frozen(asset_code)); + let frozen: Option = + env.storage().persistent().get(&DataKey::Frozen(asset_code)); match frozen { Some(f) => f.is_frozen, @@ -1249,9 +1249,7 @@ impl AssetRegistryContract { /// Get the frozen state of an asset. Public read. pub fn get_frozen_state(env: Env, asset_code: String) -> Option { - env.storage() - .persistent() - .get(&DataKey::Frozen(asset_code)) + env.storage().persistent().get(&DataKey::Frozen(asset_code)) } // ======================================================================= @@ -1957,11 +1955,8 @@ mod tests { let (env, client, admin) = setup(); let nonexistent = String::from_str(&env, "FAKE"); - let result = client.try_deactivate_asset( - &admin, - &nonexistent, - &String::from_str(&env, "Attempt"), - ); + let result = + client.try_deactivate_asset(&admin, &nonexistent, &String::from_str(&env, "Attempt")); assert_eq!(result, Err(Ok(RegistryError::AssetNotFound))); } @@ -2014,11 +2009,7 @@ mod tests { client.initialize(&admin); let asset_code = register_usdc(&env, &client, &admin); client.update_status(&admin, &asset_code, &AssetStatus::Active); - client.deactivate_asset( - &admin, - &asset_code, - &String::from_str(&env, "Temporary"), - ); + client.deactivate_asset(&admin, &asset_code, &String::from_str(&env, "Temporary")); // Try to restore from unauthorized address let result = client.try_restore_asset(&unauthorized, &asset_code); @@ -2091,11 +2082,7 @@ mod tests { let base_meta = client.get_asset(&asset_code).unwrap(); // Deactivate - client.deactivate_asset( - &admin, - &asset_code, - &String::from_str(&env, "Archived"), - ); + client.deactivate_asset(&admin, &asset_code, &String::from_str(&env, "Archived")); // Restore client.restore_asset(&admin, &asset_code); @@ -2872,11 +2859,7 @@ mod tests { let (env, client, admin) = setup(); let asset_code = register_usdc(&env, &client, &admin); - client.freeze_asset( - &admin, - &asset_code, - &String::from_str(&env, "Asset frozen"), - ); + client.freeze_asset(&admin, &asset_code, &String::from_str(&env, "Asset frozen")); let result = client.try_update_metadata( &admin, @@ -2909,11 +2892,8 @@ mod tests { let stranger = Address::generate(&env); let asset_code = register_usdc(&env, &client, &admin); - let result = client.try_freeze_asset( - &stranger, - &asset_code, - &String::from_str(&env, "reason"), - ); + let result = + client.try_freeze_asset(&stranger, &asset_code, &String::from_str(&env, "reason")); assert_eq!(result, Err(Ok(RegistryError::NotAuthorized))); } diff --git a/contracts/soroban/src/circuit_breaker.rs b/contracts/soroban/src/circuit_breaker.rs index 0bcd6820..38189c1a 100644 --- a/contracts/soroban/src/circuit_breaker.rs +++ b/contracts/soroban/src/circuit_breaker.rs @@ -221,10 +221,8 @@ impl CircuitBreakerContract { .instance() .set(&DataKey::Guardians, &guardians); - env.events().publish( - ("cb_guardian_added",), - (guardian, role), - ); + env.events() + .publish(("cb_guardian_added",), (guardian, role)); } pub fn remove_guardian(env: Env, caller: Address, guardian: Address) { @@ -253,10 +251,7 @@ impl CircuitBreakerContract { .instance() .set(&DataKey::Guardians, &new_guardians); - env.events().publish( - ("cb_guardian_removed",), - guardian, - ); + env.events().publish(("cb_guardian_removed",), guardian); } pub fn get_guardians(env: Env) -> Vec { @@ -387,10 +382,8 @@ impl CircuitBreakerContract { .instance() .set(&DataKey::RecoveryRequests, &recovery_requests); - env.events().publish( - ("cb_recovery_requested",), - (pause_id, caller), - ); + env.events() + .publish(("cb_recovery_requested",), (pause_id, caller)); } pub fn approve_recovery(env: Env, caller: Address, pause_id: u32) { @@ -420,10 +413,8 @@ impl CircuitBreakerContract { .instance() .set(&DataKey::RecoveryRequests, &recovery_requests); - env.events().publish( - ("cb_guardian_approved",), - (pause_id, caller, "recovery"), - ); + env.events() + .publish(("cb_guardian_approved",), (pause_id, caller, "recovery")); } pub fn execute_recovery(env: Env, caller: Address, pause_id: u32) { @@ -464,10 +455,7 @@ impl CircuitBreakerContract { .persistent() .remove(&DataKey::PauseState(pause_id)); - env.events().publish( - ("cb_recovery_executed",), - pause_id, - ); + env.events().publish(("cb_recovery_executed",), pause_id); } // ── Trigger Configuration ───────────────────────────────────────────────── @@ -517,7 +505,10 @@ impl CircuitBreakerContract { .unwrap_or(Vec::new(&env)); let config = Self::get_config(&env); - assert!(whitelist.len() < config.max_whitelist_size, "whitelist full"); + assert!( + whitelist.len() < config.max_whitelist_size, + "whitelist full" + ); // Check if already exists for addr in whitelist.iter() { @@ -531,10 +522,8 @@ impl CircuitBreakerContract { .instance() .set(&DataKey::WhitelistAddresses, &whitelist); - env.events().publish( - ("cb_whitelist_updated",), - ("address", address, true), - ); + env.events() + .publish(("cb_whitelist_updated",), ("address", address, true)); } pub fn add_asset_to_whitelist(env: Env, caller: Address, asset_code: String) { @@ -547,7 +536,10 @@ impl CircuitBreakerContract { .unwrap_or(Vec::new(&env)); let config = Self::get_config(&env); - assert!(whitelist.len() < config.max_whitelist_size, "whitelist full"); + assert!( + whitelist.len() < config.max_whitelist_size, + "whitelist full" + ); // Check if already exists for asset in whitelist.iter() { @@ -561,10 +553,8 @@ impl CircuitBreakerContract { .instance() .set(&DataKey::WhitelistAssets, &whitelist); - env.events().publish( - ("cb_whitelist_updated",), - ("asset", asset_code, true), - ); + env.events() + .publish(("cb_whitelist_updated",), ("asset", asset_code, true)); } // ── Query Functions ─────────────────────────────────────────────────────── @@ -708,8 +698,7 @@ mod tests { (admin, guardian1, guardian2, user) } - fn setup( - ) -> ( + fn setup() -> ( Env, Address, CircuitBreakerContractClient<'static>, @@ -731,7 +720,9 @@ mod tests { client.initialize(&admin, &2, &3600, &7200, &14400, &100); - let has_config = env.as_contract(&contract_id, || env.storage().instance().has(&DataKey::Config)); + let has_config = env.as_contract(&contract_id, || { + env.storage().instance().has(&DataKey::Config) + }); assert!(has_config); } diff --git a/contracts/soroban/src/lib.rs b/contracts/soroban/src/lib.rs index 3cce7c29..ad7fac72 100644 --- a/contracts/soroban/src/lib.rs +++ b/contracts/soroban/src/lib.rs @@ -15,6 +15,7 @@ pub mod governance; #[cfg(test)] pub mod insurance_pool; pub mod liquidity_pool; +pub mod migration; #[cfg(test)] pub mod multisig_treasury; #[cfg(test)] @@ -22,7 +23,6 @@ pub mod rate_limiter; #[cfg(test)] pub mod reputation_system; pub mod source_trust; -pub mod migration; pub mod state_export; use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, Address, Bytes, BytesN, Env, String, Vec, @@ -1063,7 +1063,6 @@ pub enum HealthSourceDataKey { // --------------------------------------------------------------------------- // Admin Activity Service types (issue #299) // --------------------------------------------------------------------------- ->>>>>>> upstream/main /// Categories of admin actions captured by the activity log. #[contracttype] @@ -1201,14 +1200,14 @@ impl BridgeWatchContract { bridge_uptime_score: u32, ) { Self::check_permission(&env, &caller, AdminRole::HealthSubmitter); - + // Check if caller is a trusted source (if any sources are registered) let active_sources = source_trust::get_active_trusted_sources(&env); if active_sources.len() > 0 { // If sources are registered, enforce trust requirement source_trust::require_trusted_source(&env, &caller); } - + let status = Self::load_asset_health(&env, &asset_code); Self::assert_asset_accepting_submissions(&status); let timestamp = env.ledger().timestamp(); @@ -1234,8 +1233,10 @@ impl BridgeWatchContract { .persistent() .set(&AssetDataKey::Health(asset_code.clone()), &record); - env.events() - .publish((symbol_short!("health_up"), asset_code.clone()), health_score); + env.events().publish( + (symbol_short!("health_up"), asset_code.clone()), + health_score, + ); Self::append_replay_event( &env, String::from_str(&env, "health_up"), @@ -1320,14 +1321,14 @@ impl BridgeWatchContract { source: String, ) { Self::check_permission(&env, &caller, AdminRole::PriceSubmitter); - + // Check if caller is a trusted source (if any sources are registered) let active_sources = source_trust::get_active_trusted_sources(&env); if active_sources.len() > 0 { // If sources are registered, enforce trust requirement source_trust::require_trusted_source(&env, &caller); } - + let status = Self::load_asset_health(&env, &asset_code); Self::assert_asset_accepting_submissions(&status); let timestamp = env.ledger().timestamp(); @@ -1874,10 +1875,9 @@ impl BridgeWatchContract { Some(record) => { state_export::StateExportHelper::build_asset_snapshot_from_health(env, &record) } - None => state_export::StateExportHelper::build_empty_asset_snapshot( - env, - asset_code, - ), + None => { + state_export::StateExportHelper::build_empty_asset_snapshot(env, asset_code) + } }; snapshots.push_back(snapshot); } @@ -5014,9 +5014,7 @@ impl BridgeWatchContract { } fn tier_rank(tier: &StatusTier) -> u32 { - match tier { - StatusTier::Ok => 0, StatusTier::Low => 1, @@ -5024,106 +5022,61 @@ impl BridgeWatchContract { StatusTier::Medium => 2, StatusTier::High => 3, - } - } - fn max_tier(a: StatusTier, b: StatusTier) -> StatusTier { - if Self::tier_rank(&a) >= Self::tier_rank(&b) { - a - } else { - b - } - } - fn health_to_tier(score: u32) -> StatusTier { - if score >= 80 { - StatusTier::Ok - } else if score >= 60 { - StatusTier::Low - } else if score >= 40 { - StatusTier::Medium - } else { - StatusTier::High - } - } - fn deviation_to_tier(alert: &Option) -> (bool, StatusTier) { - match alert { - None => (false, StatusTier::Ok), Some(a) => match a.severity { - DeviationSeverity::Low => (true, StatusTier::Low), DeviationSeverity::Medium => (true, StatusTier::Medium), DeviationSeverity::High => (true, StatusTier::High), - }, - } - } - fn compute_contract_tier_from_counts(rollup: &ContractStatusRollup) -> StatusTier { - if rollup.asset_high > 0 || rollup.bridge_high > 0 { - StatusTier::High - } else if rollup.asset_medium > 0 || rollup.bridge_medium > 0 { - StatusTier::Medium - } else if rollup.asset_low > 0 || rollup.bridge_low > 0 { - StatusTier::Low - } else { - StatusTier::Ok - } - } - fn bump_contract_counts_for_asset(env: &Env, prev: Option, next: StatusTier) { - let mut rollup: ContractStatusRollup = env - .storage() - .persistent() - .get(&DataKey::ContractStatusRollup) - .unwrap_or(ContractStatusRollup { - tier: StatusTier::Ok, asset_ok: 0, @@ -5143,14 +5096,10 @@ impl BridgeWatchContract { bridge_high: 0, timestamp: env.ledger().timestamp(), - }); - if let Some(p) = prev { - match p { - StatusTier::Ok => rollup.asset_ok = rollup.asset_ok.saturating_sub(1), StatusTier::Low => rollup.asset_low = rollup.asset_low.saturating_sub(1), @@ -5158,14 +5107,10 @@ impl BridgeWatchContract { StatusTier::Medium => rollup.asset_medium = rollup.asset_medium.saturating_sub(1), StatusTier::High => rollup.asset_high = rollup.asset_high.saturating_sub(1), - } - } - match next { - StatusTier::Ok => rollup.asset_ok += 1, StatusTier::Low => rollup.asset_low += 1, @@ -5173,39 +5118,26 @@ impl BridgeWatchContract { StatusTier::Medium => rollup.asset_medium += 1, StatusTier::High => rollup.asset_high += 1, - } - rollup.tier = Self::compute_contract_tier_from_counts(&rollup); rollup.timestamp = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&DataKey::ContractStatusRollup, &rollup); - - env.events().publish((symbol_short!("ctr_st"),), rollup.tier.clone()); - + env.events() + .publish((symbol_short!("ctr_st"),), rollup.tier.clone()); } - fn bump_contract_counts_for_bridge(env: &Env, prev: Option, next: StatusTier) { - let mut rollup: ContractStatusRollup = env - .storage() - .persistent() - .get(&DataKey::ContractStatusRollup) - .unwrap_or(ContractStatusRollup { - tier: StatusTier::Ok, asset_ok: 0, @@ -5225,14 +5157,10 @@ impl BridgeWatchContract { bridge_high: 0, timestamp: env.ledger().timestamp(), - }); - if let Some(p) = prev { - match p { - StatusTier::Ok => rollup.bridge_ok = rollup.bridge_ok.saturating_sub(1), StatusTier::Low => rollup.bridge_low = rollup.bridge_low.saturating_sub(1), @@ -5240,14 +5168,10 @@ impl BridgeWatchContract { StatusTier::Medium => rollup.bridge_medium = rollup.bridge_medium.saturating_sub(1), StatusTier::High => rollup.bridge_high = rollup.bridge_high.saturating_sub(1), - } - } - match next { - StatusTier::Ok => rollup.bridge_ok += 1, StatusTier::Low => rollup.bridge_low += 1, @@ -5255,73 +5179,50 @@ impl BridgeWatchContract { StatusTier::Medium => rollup.bridge_medium += 1, StatusTier::High => rollup.bridge_high += 1, - } - rollup.tier = Self::compute_contract_tier_from_counts(&rollup); rollup.timestamp = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&DataKey::ContractStatusRollup, &rollup); - - env.events().publish((symbol_short!("ctr_st"),), rollup.tier.clone()); - + env.events() + .publish((symbol_short!("ctr_st"),), rollup.tier.clone()); } - fn update_asset_rollup(env: &Env, asset_code: &String) { - let health = Self::load_asset_health(env, asset_code); let deviation: Option = env - .storage() - .persistent() - .get(&DataKey::DeviationAlert(asset_code.clone())); - let health_tier = Self::health_to_tier(health.health_score); let (has_alert, deviation_tier) = Self::deviation_to_tier(&deviation); let mut tier = Self::max_tier(health_tier, deviation_tier.clone()); - if !health.active { - tier = Self::max_tier(tier, StatusTier::Low); - } if health.paused { - tier = Self::max_tier(tier, StatusTier::Low); - } - let prev: Option = env - .storage() - .persistent() - .get(&DataKey::AssetStatusRollup(asset_code.clone())); let prev_tier = prev.as_ref().map(|r| r.tier.clone()); - let rollup = AssetStatusRollup { - asset_code: asset_code.clone(), tier: tier.clone(), @@ -5337,50 +5238,33 @@ impl BridgeWatchContract { active: health.active, timestamp: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&DataKey::AssetStatusRollup(asset_code.clone()), &rollup); - Self::bump_contract_counts_for_asset(env, prev_tier, tier.clone()); - env.events().publish((symbol_short!("asset_st"), asset_code.clone()), tier); - + env.events() + .publish((symbol_short!("asset_st"), asset_code.clone()), tier); } - fn update_bridge_rollup(env: &Env, bridge_id: &String, mismatch_bps: i128, is_critical: bool) { - let tier = if is_critical { - StatusTier::High - } else { - StatusTier::Ok - }; - let prev: Option = env - .storage() - .persistent() - .get(&DataKey::BridgeStatusRollup(bridge_id.clone())); let prev_tier = prev.as_ref().map(|r| r.tier.clone()); - let rollup = BridgeStatusRollup { - bridge_id: bridge_id.clone(), tier: tier.clone(), @@ -5390,21 +5274,16 @@ impl BridgeWatchContract { is_critical, timestamp: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&DataKey::BridgeStatusRollup(bridge_id.clone()), &rollup); - Self::bump_contract_counts_for_bridge(env, prev_tier, tier.clone()); - env.events().publish((symbol_short!("bridge_st"), bridge_id.clone()), tier); - + env.events() + .publish((symbol_short!("bridge_st"), bridge_id.clone()), tier); } // ----------------------------------------------------------------------- @@ -5768,8 +5647,7 @@ impl BridgeWatchContract { .instance() .set(&keys::RISK_SCORE_CONFIG, &config); - env.events() - .publish((symbol_short!("risk_cfg"),), version); + env.events().publish((symbol_short!("risk_cfg"),), version); Self::maybe_create_auto_checkpoint(&env, &caller); } @@ -5780,7 +5658,6 @@ impl BridgeWatchContract { pub fn get_risk_score_config(env: Env) -> RiskScoreConfig { Self::load_risk_score_config(&env) } -} /// Pure deterministic calculation for the composite risk score. /// @@ -5798,12 +5675,7 @@ impl BridgeWatchContract { volatility_bps: u32, ) -> RiskScoreResult { Self::validate_score_range(health_score, "health_score"); - Self::build_risk_score_result( - &env, - health_score, - price_deviation_bps, - volatility_bps, - ) + Self::build_risk_score_result(&env, health_score, price_deviation_bps, volatility_bps) } /// Derive a risk score for an asset from stored health and price history. @@ -5826,11 +5698,7 @@ impl BridgeWatchContract { let volatility_bps = if prices.len() < 2 { 0 } else { - Self::clamp_i128_to_u32(Self::calculate_volatility( - env.clone(), - prices, - period_secs, - )) + Self::clamp_i128_to_u32(Self::calculate_volatility(env.clone(), prices, period_secs)) }; Some(Self::build_risk_score_result( @@ -6867,7 +6735,10 @@ impl BridgeWatchContract { Self::append_u32(&mut data, snapshot.risk_score_config.health_weight_bps); Self::append_u32(&mut data, snapshot.risk_score_config.price_weight_bps); Self::append_u32(&mut data, snapshot.risk_score_config.volatility_weight_bps); - Self::append_u32(&mut data, snapshot.risk_score_config.max_price_deviation_bps); + Self::append_u32( + &mut data, + snapshot.risk_score_config.max_price_deviation_bps, + ); Self::append_u32(&mut data, snapshot.risk_score_config.max_volatility_bps); Self::append_u32(&mut data, snapshot.risk_score_config.version); @@ -7148,9 +7019,7 @@ impl BridgeWatchContract { max_volatility_bps: u32, version: u32, ) { - if health_weight_bps > 10_000 - || price_weight_bps > 10_000 - || volatility_weight_bps > 10_000 + if health_weight_bps > 10_000 || price_weight_bps > 10_000 || volatility_weight_bps > 10_000 { panic!("risk weights must be between 0 and 10000"); } @@ -7196,8 +7065,7 @@ impl BridgeWatchContract { let normalized_volatility_risk_bps = Self::normalize_signal_to_bps(volatility_bps, config.max_volatility_bps); - let weighted_sum = (normalized_health_risk_bps as u64) - * (config.health_weight_bps as u64) + let weighted_sum = (normalized_health_risk_bps as u64) * (config.health_weight_bps as u64) + (normalized_price_risk_bps as u64) * (config.price_weight_bps as u64) + (normalized_volatility_risk_bps as u64) * (config.volatility_weight_bps as u64); let risk_score_bps = Self::clamp_bps_u64(weighted_sum / 10_000); @@ -7912,7 +7780,9 @@ impl BridgeWatchContract { } let now = env.ledger().timestamp(); env.storage().instance().set(&keys::RECOVERY_MODE, &true); - env.storage().instance().set(&keys::RECOVERY_REASON, &reason); + env.storage() + .instance() + .set(&keys::RECOVERY_REASON, &reason); env.storage() .instance() .set(&keys::RECOVERY_ENTERED_AT, &now); @@ -7924,8 +7794,10 @@ impl BridgeWatchContract { env.storage() .persistent() .set(&keys::RECOVERY_STEPS, &steps); - env.events() - .publish((symbol_short!("rec_entr"), caller.clone()), (reason.clone(), now)); + env.events().publish( + (symbol_short!("rec_entr"), caller.clone()), + (reason.clone(), now), + ); Self::append_replay_event( &env, String::from_str(&env, "rec_entr"), @@ -8086,7 +7958,10 @@ impl BridgeWatchContract { for i in offset..end { page.push_back(log.get(i).unwrap()); } - AdminActivityPage { entries: page, total } + AdminActivityPage { + entries: page, + total, + } } /// Retrieve admin activity entries for a specific actor (most-recent first). @@ -8175,12 +8050,7 @@ impl BridgeWatchContract { /// # Panics /// - `caller` is not the contract admin. /// - `weight_bps` is zero. - pub fn register_health_source( - env: Env, - caller: Address, - source_id: String, - weight_bps: u32, - ) { + pub fn register_health_source(env: Env, caller: Address, source_id: String, weight_bps: u32) { caller.require_auth(); let admin: Address = env.storage().instance().get(&keys::ADMIN).unwrap(); if caller != admin { @@ -8215,9 +8085,13 @@ impl BridgeWatchContract { if !found { updated.push_back(source); } - env.storage().instance().set(&keys::HEALTH_SOURCES, &updated); - env.events() - .publish((symbol_short!("src_reg"), caller.clone()), source_id.clone()); + env.storage() + .instance() + .set(&keys::HEALTH_SOURCES, &updated); + env.events().publish( + (symbol_short!("src_reg"), caller.clone()), + source_id.clone(), + ); Self::append_admin_activity( &env, AdminActivityAction::AssetRegistered, @@ -8257,7 +8131,9 @@ impl BridgeWatchContract { if !found { panic!("health source not registered"); } - env.storage().instance().set(&keys::HEALTH_SOURCES, &updated); + env.storage() + .instance() + .set(&keys::HEALTH_SOURCES, &updated); env.events() .publish((symbol_short!("src_rev"), caller), source_id); } @@ -8309,9 +8185,10 @@ impl BridgeWatchContract { bridge_uptime_score, submitted_at: now, }; - env.storage() - .persistent() - .set(&HealthSourceDataKey::Entry(source_id.clone(), asset_code.clone()), &entry); + env.storage().persistent().set( + &HealthSourceDataKey::Entry(source_id.clone(), asset_code.clone()), + &entry, + ); env.events().publish( (symbol_short!("ms_hlth"), caller.clone(), asset_code.clone()), (source_id.clone(), health_score, now), @@ -8531,14 +8408,14 @@ impl BridgeWatchContract { name: String, ) { caller.require_auth(); - + // Check admin permission let admin: Address = env .storage() .instance() .get(&keys::ADMIN) .unwrap_or_else(|| panic!("contract not initialized")); - + if caller != admin { acl::require_permission(&env, &caller, &admin, &Permission::ManageConfig); } @@ -8573,14 +8450,14 @@ impl BridgeWatchContract { /// ``` pub fn revoke_trusted_source(env: Env, caller: Address, source_address: Address) { caller.require_auth(); - + // Check admin permission let admin: Address = env .storage() .instance() .get(&keys::ADMIN) .unwrap_or_else(|| panic!("contract not initialized")); - + if caller != admin { acl::require_permission(&env, &caller, &admin, &Permission::ManageConfig); } @@ -11439,7 +11316,9 @@ mod tests { env.ledger().set_timestamp(300); client.submit_price(&admin, &asset, &1_000_000, &source); - let result = client.get_asset_risk_score(&asset, &StatPeriod::Day).unwrap(); + let result = client + .get_asset_risk_score(&asset, &StatPeriod::Day) + .unwrap(); assert_eq!(result.health_score, 75); assert_eq!(result.price_deviation_bps, 0); assert_eq!(result.volatility_bps, 0); @@ -12334,8 +12213,14 @@ mod tests { client.record_recovery_step(&admin, &String::from_str(&env, "step three")); let steps = client.get_recovery_steps(); assert_eq!(steps.len(), 3); - assert_eq!(steps.get(0).unwrap().description, String::from_str(&env, "step one")); - assert_eq!(steps.get(2).unwrap().description, String::from_str(&env, "step three")); + assert_eq!( + steps.get(0).unwrap().description, + String::from_str(&env, "step one") + ); + assert_eq!( + steps.get(2).unwrap().description, + String::from_str(&env, "step three") + ); } #[test] @@ -12435,8 +12320,14 @@ mod tests { client.exit_recovery_mode(&admin); let entries = client.get_admin_activity_by_actor(&admin, &10); // Most recent (RecoveryExited) should come first in the result - assert_eq!(entries.get(0).unwrap().action, AdminActivityAction::RecoveryExited); - assert_eq!(entries.get(1).unwrap().action, AdminActivityAction::RecoveryEntered); + assert_eq!( + entries.get(0).unwrap().action, + AdminActivityAction::RecoveryExited + ); + assert_eq!( + entries.get(1).unwrap().action, + AdminActivityAction::RecoveryEntered + ); } #[test] diff --git a/contracts/soroban/src/migration.rs b/contracts/soroban/src/migration.rs index fb82ee23..45169479 100644 --- a/contracts/soroban/src/migration.rs +++ b/contracts/soroban/src/migration.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Vec, vec}; +use soroban_sdk::{contracttype, symbol_short, vec, Address, Env, String, Vec}; // Storage key constants for migration state — follows the same pattern as the // top-level `keys` mod in lib.rs. @@ -74,7 +74,9 @@ impl MigrationHelper { /// Persist `version` as the current schema version. pub fn set_version(env: &Env, version: MigrationVersion) { let key = String::from_str(env, keys::MIGRATION_VERSION); - env.storage().persistent().set::(&key, &version); + env.storage() + .persistent() + .set::(&key, &version); } /// Validate that migrating `from` → `to` is a forward-only upgrade. @@ -139,11 +141,7 @@ impl MigrationHelper { /// /// Event topics: `["migration", from_major, from_minor, from_patch]` /// Event data: `[to_major, to_minor, to_patch]` - pub fn emit_migration_event( - env: &Env, - from: &MigrationVersion, - to: &MigrationVersion, - ) { + pub fn emit_migration_event(env: &Env, from: &MigrationVersion, to: &MigrationVersion) { env.events().publish( ( symbol_short!("migration"), diff --git a/contracts/soroban/src/source_trust.rs b/contracts/soroban/src/source_trust.rs index 6b98d780..5db8d7e1 100644 --- a/contracts/soroban/src/source_trust.rs +++ b/contracts/soroban/src/source_trust.rs @@ -50,7 +50,6 @@ /// // Revoke a source /// contract.revoke_trusted_source(env, admin_address, source_address); /// ``` - use soroban_sdk::{contracttype, Address, Env, String, Vec}; // ── Data Types ──────────────────────────────────────────────────────────────── diff --git a/contracts/soroban/src/state_export.rs b/contracts/soroban/src/state_export.rs index 4110d141..99f4649f 100644 --- a/contracts/soroban/src/state_export.rs +++ b/contracts/soroban/src/state_export.rs @@ -104,7 +104,10 @@ impl StateExportHelper { timestamp: u64, ) -> String { let mut hash_input = String::from_str(&env, ""); - hash_input = String::from_str(&env, &format!("{}{}{}{}", asset_code, status, risk_score, timestamp)); + hash_input = String::from_str( + &env, + &format!("{}{}{}{}", asset_code, status, risk_score, timestamp), + ); hash_input } @@ -228,16 +231,8 @@ impl StateExportHelper { let mut idx = 0; while idx < max_len { - let left_byte = if idx < left_len { - left_buf[idx] - } else { - 0 - }; - let right_byte = if idx < right_len { - right_buf[idx] - } else { - 0 - }; + let left_byte = if idx < left_len { left_buf[idx] } else { 0 }; + let right_byte = if idx < right_len { right_buf[idx] } else { 0 }; if left_byte != right_byte { return if left_byte > right_byte { 1 } else { -1 }; @@ -369,9 +364,18 @@ mod tests { StateExportHelper::sort_snapshots(&env, &mut snapshots); - assert_eq!(snapshots.get(0).unwrap().asset_code, String::from_str(&env, "BTC")); - assert_eq!(snapshots.get(1).unwrap().asset_code, String::from_str(&env, "EURC")); - assert_eq!(snapshots.get(2).unwrap().asset_code, String::from_str(&env, "USDC")); + assert_eq!( + snapshots.get(0).unwrap().asset_code, + String::from_str(&env, "BTC") + ); + assert_eq!( + snapshots.get(1).unwrap().asset_code, + String::from_str(&env, "EURC") + ); + assert_eq!( + snapshots.get(2).unwrap().asset_code, + String::from_str(&env, "USDC") + ); } #[test] diff --git a/contracts/soroban/tests/relay_contract_fuzz.rs b/contracts/soroban/tests/relay_contract_fuzz.rs index 06e2c7bd..98f4975f 100644 --- a/contracts/soroban/tests/relay_contract_fuzz.rs +++ b/contracts/soroban/tests/relay_contract_fuzz.rs @@ -30,10 +30,10 @@ fn seed_bytes(seed: u64, len: usize) -> Vec { let mut value = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15); let mut bytes = Vec::with_capacity(len); for _ in 0..len { - value ^= value << 7; - value ^= value >> 9; - value = value.wrapping_mul(0xD134_2543_DE82_EF95); - bytes.push((value & 0xFF) as u8); + value ^= value << 7; + value ^= value >> 9; + value = value.wrapping_mul(0xD134_2543_DE82_EF95); + bytes.push((value & 0xFF) as u8); } bytes } @@ -58,11 +58,19 @@ fn deterministic_fuzz_send_message_rejects_malformed_inputs() { let mut insufficient_fee = 0_u32; for seed in seeds { - let payload_len = if seed % 3 == 0 { 16_385 } else { 1 + (seed as usize % 128) }; + let payload_len = if seed % 3 == 0 { + 16_385 + } else { + 1 + (seed as usize % 128) + }; let payload = Bytes::from_slice(&env, &seed_bytes(seed, payload_len)); let nonce = seed % 2; let ttl = if seed % 5 == 0 { 1 } else { 120 }; - let fee = if seed % 4 == 0 { 0 } else { 10 + i128::from(seed as i64) }; + let fee = if seed % 4 == 0 { + 0 + } else { + 10 + i128::from(seed as i64) + }; let result = client.try_send_message( &chain_for(seed), @@ -92,7 +100,10 @@ fn deterministic_fuzz_send_message_rejects_malformed_inputs() { // accepted may be zero if the Soroban environment rejects all seeds // (e.g. nonce conflicts); we guard on classification coverage instead. assert!(rejected > 0, "expected at least one rejected seed"); - assert!(payload_too_large > 0, "expected payload classification coverage"); + assert!( + payload_too_large > 0, + "expected payload classification coverage" + ); assert!(insufficient_fee > 0, "expected fee classification coverage"); println!( @@ -138,7 +149,10 @@ fn deterministic_fuzz_relay_message_classifies_signature_failures() { let mut payload = [0u8; 64]; payload[..32].copy_from_slice(&message_id.to_array()); payload[32..].copy_from_slice(&op.public_key.to_array()); - let digest: BytesN<32> = env.crypto().sha256(&Bytes::from_slice(&env, &payload)).into(); + let digest: BytesN<32> = env + .crypto() + .sha256(&Bytes::from_slice(&env, &payload)) + .into(); let d = digest.to_array(); let mut out = [0u8; 64]; let mut i = 0usize; @@ -156,7 +170,10 @@ fn deterministic_fuzz_relay_message_classifies_signature_failures() { } } - assert!(invalid_signature_classes > 0, "expected invalid signature coverage"); + assert!( + invalid_signature_classes > 0, + "expected invalid signature coverage" + ); println!("fuzz summary: invalid_signature_classes={invalid_signature_classes}"); } @@ -185,7 +202,10 @@ fn deterministic_fuzz_batch_relay_reports_mixed_outcomes() { let mut payload = [0u8; 64]; payload[..32].copy_from_slice(&message_id.to_array()); payload[32..].copy_from_slice(&op.public_key.to_array()); - let digest: BytesN<32> = env.crypto().sha256(&Bytes::from_slice(&env, &payload)).into(); + let digest: BytesN<32> = env + .crypto() + .sha256(&Bytes::from_slice(&env, &payload)) + .into(); let d = digest.to_array(); let mut out = [0u8; 64]; let mut i = 0usize; @@ -201,7 +221,10 @@ fn deterministic_fuzz_batch_relay_reports_mixed_outcomes() { BytesN::from_array(&env, &invalid_sig_bytes) }; - batch.push_back(BatchRelayItem { message_id, signature }); + batch.push_back(BatchRelayItem { + message_id, + signature, + }); } let result = client.batch_relay(&operator, &batch); @@ -212,4 +235,4 @@ fn deterministic_fuzz_batch_relay_reports_mixed_outcomes() { "fuzz summary: batch_success={}, batch_failure={}", result.success_count, result.failure_count ); -} \ No newline at end of file +} diff --git a/contracts/soroban/tests/source_trust.test.rs b/contracts/soroban/tests/source_trust.test.rs index 359bba7d..77db8580 100644 --- a/contracts/soroban/tests/source_trust.test.rs +++ b/contracts/soroban/tests/source_trust.test.rs @@ -5,7 +5,13 @@ use soroban_sdk::{testutils::Address as _, Address, Env, String}; // Import the contract and client use bridge_watch_soroban::{BridgeWatchContract, BridgeWatchContractClient}; -fn setup() -> (Env, BridgeWatchContractClient<'static>, Address, Address, Address) { +fn setup() -> ( + Env, + BridgeWatchContractClient<'static>, + Address, + Address, + Address, +) { let env = Env::default(); env.mock_all_auths(); @@ -251,7 +257,6 @@ fn test_multiple_registrations_updates_source() { assert_eq!(all_sources.len(), 1); } - // ── Integration Tests with Submission Gating ───────────────────────────────── #[test] @@ -262,12 +267,34 @@ fn test_submit_health_requires_trusted_source_when_sources_registered() { client.register_asset(&admin, &String::from_str(&env, "USDC")); // Grant submission role to both sources - client.grant_role(&admin, &trusted_source, bridge_watch_soroban::AdminRole::HealthSubmitter); - client.grant_role(&admin, &untrusted_source, bridge_watch_soroban::AdminRole::HealthSubmitter); + client.grant_role( + &admin, + &trusted_source, + bridge_watch_soroban::AdminRole::HealthSubmitter, + ); + client.grant_role( + &admin, + &untrusted_source, + bridge_watch_soroban::AdminRole::HealthSubmitter, + ); // Before registering any trusted sources, both should be able to submit - client.submit_health(&trusted_source, &String::from_str(&env, "USDC"), 95, 90, 92, 88); - client.submit_health(&untrusted_source, &String::from_str(&env, "USDC"), 94, 89, 91, 87); + client.submit_health( + &trusted_source, + &String::from_str(&env, "USDC"), + 95, + 90, + 92, + 88, + ); + client.submit_health( + &untrusted_source, + &String::from_str(&env, "USDC"), + 94, + 89, + 91, + 87, + ); // Now register the trusted source client.register_trusted_source( @@ -277,7 +304,14 @@ fn test_submit_health_requires_trusted_source_when_sources_registered() { ); // Trusted source should still work - client.submit_health(&trusted_source, &String::from_str(&env, "USDC"), 96, 91, 93, 89); + client.submit_health( + &trusted_source, + &String::from_str(&env, "USDC"), + 96, + 91, + 93, + 89, + ); // Untrusted source should now fail (would panic with "caller is not a trusted source") // Note: In actual test, this would panic. Documenting expected behavior. @@ -291,8 +325,16 @@ fn test_submit_price_requires_trusted_source_when_sources_registered() { client.register_asset(&admin, &String::from_str(&env, "USDC")); // Grant submission role to both sources - client.grant_role(&admin, &trusted_source, bridge_watch_soroban::AdminRole::PriceSubmitter); - client.grant_role(&admin, &untrusted_source, bridge_watch_soroban::AdminRole::PriceSubmitter); + client.grant_role( + &admin, + &trusted_source, + bridge_watch_soroban::AdminRole::PriceSubmitter, + ); + client.grant_role( + &admin, + &untrusted_source, + bridge_watch_soroban::AdminRole::PriceSubmitter, + ); // Before registering any trusted sources, both should be able to submit client.submit_price( @@ -336,7 +378,11 @@ fn test_revoked_source_cannot_submit() { // Register and grant role to source client.register_trusted_source(&admin, &source, &String::from_str(&env, "Oracle")); - client.grant_role(&admin, &source, bridge_watch_soroban::AdminRole::HealthSubmitter); + client.grant_role( + &admin, + &source, + bridge_watch_soroban::AdminRole::HealthSubmitter, + ); // Should be able to submit client.submit_health(&source, &String::from_str(&env, "USDC"), 95, 90, 92, 88); @@ -357,7 +403,11 @@ fn test_reactivated_source_can_submit_again() { // Register and grant role to source client.register_trusted_source(&admin, &source, &String::from_str(&env, "Oracle")); - client.grant_role(&admin, &source, bridge_watch_soroban::AdminRole::HealthSubmitter); + client.grant_role( + &admin, + &source, + bridge_watch_soroban::AdminRole::HealthSubmitter, + ); // Submit successfully client.submit_health(&source, &String::from_str(&env, "USDC"), 95, 90, 92, 88); @@ -384,8 +434,16 @@ fn test_multiple_trusted_sources_can_all_submit() { client.register_trusted_source(&admin, &source2, &String::from_str(&env, "Oracle 2")); // Grant roles - client.grant_role(&admin, &source1, bridge_watch_soroban::AdminRole::HealthSubmitter); - client.grant_role(&admin, &source2, bridge_watch_soroban::AdminRole::HealthSubmitter); + client.grant_role( + &admin, + &source1, + bridge_watch_soroban::AdminRole::HealthSubmitter, + ); + client.grant_role( + &admin, + &source2, + bridge_watch_soroban::AdminRole::HealthSubmitter, + ); // Both should be able to submit client.submit_health(&source1, &String::from_str(&env, "USDC"), 95, 90, 92, 88); @@ -405,7 +463,11 @@ fn test_admin_can_always_submit_regardless_of_trust() { // Register a trusted source (activates trust enforcement) client.register_trusted_source(&admin, &source, &String::from_str(&env, "Oracle")); - client.grant_role(&admin, &source, bridge_watch_soroban::AdminRole::HealthSubmitter); + client.grant_role( + &admin, + &source, + bridge_watch_soroban::AdminRole::HealthSubmitter, + ); // Admin should still be able to submit even without being a registered trusted source // (because admin has inherent permissions) diff --git a/docs/anomaly-detection-engine.md b/docs/anomaly-detection-engine.md new file mode 100644 index 00000000..926344f7 --- /dev/null +++ b/docs/anomaly-detection-engine.md @@ -0,0 +1,53 @@ +# Real-Time Anomaly Detection Engine + +The anomaly detection engine correlates price, liquidity, supply, bridge-health, and composite health-score signals before surfacing an operator-facing event. It runs from the backend worker queue every minute and can also be invoked manually through the API. + +## Detection Inputs + +- Price data comes from the aggregated price service and includes VWAP plus source deviation. +- Liquidity data comes from aggregated orderbook and AMM depth. +- Supply and bridge-health data comes from bridge status records, including supply mismatch and bridge state. +- Health-score data comes from the composite health service. + +## Correlation Rules + +An event is emitted only when the number of matched signals is at least the configured `min_signal_count`. This avoids alerting on a single noisy datapoint unless an operator explicitly lowers the threshold. + +Default thresholds are seeded in `anomaly_thresholds`: + +| Setting | Default | Meaning | +| --- | ---: | --- | +| `price_change_pct` | 5 | Price move or source-price divergence required to count as a price signal. | +| `liquidity_change_pct` | 25 | Liquidity movement required to count as a liquidity signal. | +| `supply_mismatch_pct` | 1 | Bridge supply mismatch required to count as a supply divergence signal. | +| `health_score_drop` | 10 | Composite health score drop required to count as a health signal. | +| `min_signal_count` | 2 | Minimum correlated signals required before alerting. | +| `duplicate_window_seconds` | 900 | Time window used to suppress repeated detections with the same fingerprint. | + +Thresholds can be global (`asset_code = '*'`, `bridge_name = '*'`), per asset, per bridge, or per asset and bridge. Resolution prefers the most specific active row first. + +## Duplicate Suppression + +Each event is fingerprinted from asset, bridge, signal type, signal direction, and metric. If the same fingerprint appears inside the configured duplicate window, the new event is persisted with `is_suppressed = true` and linked through `suppressed_by_event_id`. Suppressed events are excluded from recent event queries unless requested. + +## Explainability + +Each persisted event includes: + +- `signals`: the exact matched signal set with current value, previous value when available, threshold, direction, and delta. +- `explanation`: a summary, the active rule thresholds, and the evidence used to produce the event. +- `metadata`: source timestamps and threshold id for operator auditability. + +## API + +- `GET /api/v1/anomaly-detection/events` lists recent detections. Query filters: `assetCode`, `bridgeName`, `severity`, `includeSuppressed`, and `limit`. +- `POST /api/v1/anomaly-detection/evaluate` runs detection for one asset and optional bridge. +- `GET /api/v1/anomaly-detection/thresholds` lists configured thresholds. +- `PUT /api/v1/anomaly-detection/thresholds` creates or updates a threshold row for an asset and bridge scope. + +## Tuning Guidance + +- Increase `min_signal_count` to reduce noise during volatile markets. +- Lower `price_change_pct` for stablecoins or pegged assets where small price movement matters. +- Lower `liquidity_change_pct` for thin markets where depth changes are operationally important. +- Lower `duplicate_window_seconds` when operators need repeated reminders for persistent instability. diff --git a/e2e/utils/mockApi.ts b/e2e/utils/mockApi.ts index 6e453c44..0c38f7da 100644 --- a/e2e/utils/mockApi.ts +++ b/e2e/utils/mockApi.ts @@ -1,7 +1,7 @@ import { type Page } from "@playwright/test"; -import { - buildAssetWithHealth, - buildBridge +import { + buildAssetWithHealth, + buildBridge } from "../../frontend/src/test/factories"; const assetsFixture = [ @@ -16,8 +16,8 @@ const assetHealthFixture = { const bridgesFixture = { bridges: [ - buildBridge({ name: "Stellar-Ethereum", status: "healthy" }, 200), - buildBridge({ name: "Stellar-Celo", status: "degraded" }, 201), + buildBridge({ name: "Allbridge", status: "healthy" }, 200), + buildBridge({ name: "Wormhole", status: "healthy" }, 201), ] }; @@ -45,7 +45,7 @@ export async function mockCoreApi(page: Page): Promise { }); }); - await page.route("**/api/v1/bridges", async (route) => { + await page.route("**/api/v1/bridges**", async (route) => { await route.fulfill({ status: 200, headers: jsonHeaders, @@ -53,14 +53,32 @@ export async function mockCoreApi(page: Page): Promise { }); }); - await page.route("**/health", async (route) => { + await page.route("**/health**", async (route) => { await route.fulfill({ status: 200, headers: jsonHeaders, body: JSON.stringify({ status: "ok", timestamp: new Date().toISOString(), + services: {}, }), }); }); + + await page.route("**/api/v1/external-dependencies**", async (route) => { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ dependencies: [], summary: { total: 0, healthy: 0, degraded: 0 } }), + }); + }); + + // Catch-all for any other API routes to prevent proxy errors + await page.route("**/api/v1/**", async (route) => { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({}), + }); + }); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac16a8f8..641c7793 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,8 @@ const RelationshipExplorer = lazy(() => import("./pages/RelationshipExplorer")); const SearchResultsPage = lazy(() => import("./pages/SearchResultsPage")); const Alerts = lazy(() => import("./pages/Alerts")); const DataProvenanceGraph = lazy(() => import("./pages/DataProvenanceGraph")); +const OperationalAccessAudit = lazy(() => import("./pages/OperationalAccessAudit")); +const AlertSimulationSandbox = lazy(() => import("./pages/AlertSimulationSandbox")); function NotificationInitializer() { useNotifications(); @@ -63,6 +65,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -72,6 +75,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/MobileNav/navigation.ts b/frontend/src/components/MobileNav/navigation.ts index 231349d7..d0c252a9 100644 --- a/frontend/src/components/MobileNav/navigation.ts +++ b/frontend/src/components/MobileNav/navigation.ts @@ -37,6 +37,16 @@ export const navGroups: NavGroup[] = [ label: "Alert Routing", description: "Manage alert dispatch routing and audit", }, + { + to: "/admin/access-audit", + label: "Access Audit", + description: "Review operator roles, permissions, and access history", + }, + { + to: "/alert-sandbox", + label: "Alert Sandbox", + description: "Dry-run alert rules against synthetic data before enabling in production", + }, { to: "/settings", label: "Settings", description: "Notification and dashboard preferences" }, ], }, diff --git a/frontend/src/hooks/useAlertSimulation.ts b/frontend/src/hooks/useAlertSimulation.ts new file mode 100644 index 00000000..6650bc78 --- /dev/null +++ b/frontend/src/hooks/useAlertSimulation.ts @@ -0,0 +1,146 @@ +import { useState, useCallback } from "react"; + +export type SimulationSeverity = "critical" | "high" | "medium" | "low"; + +export interface SimulationInput { + severity: SimulationSeverity; + assetCode: string; + sourceType: string; + ownerAddress: string; + label: string; + triggeredValue: number | null; + threshold: number | null; + metric: string; +} + +export interface SimulationRuleResult { + ruleId: string; + ruleName: string; + priorityOrder: number; + ownerAddress: string | null; + matched: boolean; + reasons: string[]; + channels: string[]; + fallbackChannels: string[]; + suppressionWindowSeconds: number; +} + +export interface SimulationSummary { + totalActiveRules: number; + totalMatched: number; + firstMatchingRule: { ruleId: string; ruleName: string } | null; + wouldDispatch: boolean; + effectiveChannels: string[]; + effectiveFallbackChannels: string[]; + suppressionWindowSeconds: number; +} + +export interface SimulationResult { + simulationId: string; + timestamp: string; + input: SimulationInput & { + ownerAddress: string | null; + label: string | null; + triggeredValue: number | null; + threshold: number | null; + metric: string | null; + }; + results: SimulationRuleResult[]; + skippedInactive: { ruleId: string; ruleName: string; priorityOrder: number }[]; + summary: SimulationSummary; +} + +const HISTORY_KEY = "bw_sim_history"; +const MAX_HISTORY = 20; + +function loadHistory(): SimulationResult[] { + try { + const raw = localStorage.getItem(HISTORY_KEY); + return raw ? (JSON.parse(raw) as SimulationResult[]) : []; + } catch { + return []; + } +} + +function persistHistory(items: SimulationResult[]): void { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, MAX_HISTORY))); + } catch { + // ignore quota errors + } +} + +export function useAlertSimulation(adminToken: string) { + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + const [currentResult, setCurrentResult] = useState(null); + const [history, setHistory] = useState(loadHistory); + + const runSimulation = useCallback( + async (input: SimulationInput) => { + setIsRunning(true); + setError(null); + + try { + const payload: Record = { severity: input.severity }; + if (input.assetCode.trim()) payload.assetCode = input.assetCode.trim(); + if (input.sourceType.trim()) payload.sourceType = input.sourceType.trim(); + if (input.ownerAddress.trim()) payload.ownerAddress = input.ownerAddress.trim(); + if (input.label.trim()) payload.label = input.label.trim(); + if (input.metric.trim()) payload.metric = input.metric.trim(); + if (input.triggeredValue !== null) payload.triggeredValue = input.triggeredValue; + if (input.threshold !== null) payload.threshold = input.threshold; + + const res = await fetch("/api/v1/admin/alert-routing/simulate", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": adminToken, + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const data = (await res.json()) as { error?: unknown }; + throw new Error( + typeof data.error === "string" + ? data.error + : `HTTP ${res.status}` + ); + } + + const result = (await res.json()) as SimulationResult; + setCurrentResult(result); + setHistory((prev) => { + const next = [result, ...prev].slice(0, MAX_HISTORY); + persistHistory(next); + return next; + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Simulation failed"); + } finally { + setIsRunning(false); + } + }, + [adminToken] + ); + + const restoreFromHistory = useCallback((result: SimulationResult) => { + setCurrentResult(result); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + persistHistory([]); + }, []); + + return { + isRunning, + error, + currentResult, + history, + runSimulation, + restoreFromHistory, + clearHistory, + }; +} diff --git a/frontend/src/pages/AlertSimulationSandbox.tsx b/frontend/src/pages/AlertSimulationSandbox.tsx new file mode 100644 index 00000000..583419f7 --- /dev/null +++ b/frontend/src/pages/AlertSimulationSandbox.tsx @@ -0,0 +1,872 @@ +import { useState } from "react"; +import { + useAlertSimulation, + type SimulationInput, + type SimulationResult, + type SimulationSeverity, +} from "../hooks/useAlertSimulation"; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const SEVERITY_STYLES: Record = { + critical: { badge: "bg-red-900/50 text-red-400 border border-red-700", dot: "bg-red-500" }, + high: { badge: "bg-orange-900/50 text-orange-400 border border-orange-700", dot: "bg-orange-500" }, + medium: { badge: "bg-yellow-900/50 text-yellow-400 border border-yellow-700", dot: "bg-yellow-500" }, + low: { badge: "bg-blue-900/50 text-blue-400 border border-blue-700", dot: "bg-blue-500" }, +}; + +const CHANNEL_LABELS: Record = { + in_app: "In-App", + webhook: "Webhook", + email: "Email", +}; + +const PRESETS = [ + { + id: "critical_bridge", + label: "Critical Bridge Failure", + description: "Complete bridge outage, funds at risk", + input: { + severity: "critical" as SimulationSeverity, + assetCode: "USDC", + sourceType: "bridge", + metric: "bridge_health", + triggeredValue: 0, + threshold: 100, + }, + }, + { + id: "token_exploit", + label: "Token Exploit", + description: "Security vulnerability detected", + input: { + severity: "critical" as SimulationSeverity, + assetCode: "ETH", + sourceType: "security", + metric: "exploit_severity", + triggeredValue: 1, + threshold: 0, + }, + }, + { + id: "tvl_anomaly", + label: "TVL Anomaly", + description: "Significant total value locked drop", + input: { + severity: "high" as SimulationSeverity, + assetCode: "WBTC", + sourceType: "analytics", + metric: "tvl_usd", + triggeredValue: 8500000, + threshold: 10000000, + }, + }, + { + id: "reserve_drift", + label: "Reserve Backing Drift", + description: "Collateral ratio below threshold", + input: { + severity: "high" as SimulationSeverity, + assetCode: "USDT", + sourceType: "reconciliation", + metric: "backing_ratio", + triggeredValue: 0.94, + threshold: 0.98, + }, + }, + { + id: "gas_spike", + label: "Gas Price Spike", + description: "Network fees impacting operations", + input: { + severity: "medium" as SimulationSeverity, + assetCode: "", + sourceType: "network", + metric: "gas_gwei", + triggeredValue: 250, + threshold: 100, + }, + }, + { + id: "maintenance", + label: "Scheduled Maintenance", + description: "Low-priority maintenance window", + input: { + severity: "low" as SimulationSeverity, + assetCode: "", + sourceType: "maintenance", + metric: "maintenance_flag", + triggeredValue: 1, + threshold: 0, + }, + }, +] as const; + +const ADMIN_TOKEN_KEY = "bw_admin_token"; + +function loadToken(): string { + try { + return localStorage.getItem(ADMIN_TOKEN_KEY) ?? ""; + } catch { + return ""; + } +} + +const DEFAULT_INPUT: SimulationInput = { + severity: "high", + assetCode: "", + sourceType: "", + ownerAddress: "", + label: "", + triggeredValue: null, + threshold: null, + metric: "", +}; + +// ─── Main page ─────────────────────────────────────────────────────────────── + +export default function AlertSimulationSandbox() { + const [adminToken, setAdminToken] = useState(loadToken); + const [input, setInput] = useState(DEFAULT_INPUT); + const [activeTab, setActiveTab] = useState<"results" | "history">("results"); + + const { isRunning, error, currentResult, history, runSimulation, restoreFromHistory, clearHistory } = + useAlertSimulation(adminToken); + + function applyPreset(preset: (typeof PRESETS)[number]) { + setInput({ + ...DEFAULT_INPUT, + severity: preset.input.severity, + assetCode: preset.input.assetCode, + sourceType: preset.input.sourceType, + metric: preset.input.metric, + triggeredValue: preset.input.triggeredValue, + threshold: preset.input.threshold, + }); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + void runSimulation(input); + } + + const inputCls = + "bg-stellar-card border border-stellar-border rounded px-3 py-2 text-sm text-white placeholder-stellar-text-muted focus:outline-none focus:border-stellar-blue w-full"; + + return ( +
+ {/* Header */} +
+
+

+ Alert Simulation Sandbox +

+

+ Dry-run alert rules against synthetic data before enabling in production. No alerts are dispatched. +

+
+ + + Simulation only — no real dispatches + +
+ +
+ {/* ── Left: configuration panel ──────────────────────────── */} +
+ {/* Admin token */} +
+ + { + setAdminToken(e.target.value); + try { + localStorage.setItem(ADMIN_TOKEN_KEY, e.target.value); + } catch { + // ignore + } + }} + placeholder="Enter admin API key" + className={inputCls} + autoComplete="current-password" + /> +
+ + {/* Scenario presets */} +
+

Scenario Presets

+
+ {PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Input form */} +
+

Alert Parameters

+ + {/* Severity selector */} +
+ + Severity + +
+ {(["critical", "high", "medium", "low"] as SimulationSeverity[]).map((sev) => ( + + ))} +
+
+ + {/* Asset code */} +
+ + + setInput((p) => ({ ...p, assetCode: e.target.value.toUpperCase() })) + } + placeholder="e.g. USDC, ETH, WBTC" + className={inputCls} + /> +
+ + {/* Source type */} +
+ + setInput((p) => ({ ...p, sourceType: e.target.value }))} + placeholder="e.g. bridge, security, analytics" + className={inputCls} + /> +
+ + {/* Triggered value / threshold */} +
+
+ + + setInput((p) => ({ + ...p, + triggeredValue: e.target.value === "" ? null : Number(e.target.value), + })) + } + placeholder="0" + className={inputCls} + /> +
+
+ + + setInput((p) => ({ + ...p, + threshold: e.target.value === "" ? null : Number(e.target.value), + })) + } + placeholder="0" + className={inputCls} + /> +
+
+ + {/* Metric */} +
+ + setInput((p) => ({ ...p, metric: e.target.value }))} + placeholder="e.g. bridge_health, backing_ratio" + className={inputCls} + /> +
+ + {/* Owner address */} +
+ + setInput((p) => ({ ...p, ownerAddress: e.target.value }))} + placeholder="Filter rules by owner" + className={inputCls} + /> +
+ + {/* Run label */} +
+ + setInput((p) => ({ ...p, label: e.target.value }))} + placeholder="e.g. Pre-launch validation" + maxLength={120} + className={inputCls} + /> +
+ + + + {!adminToken.trim() && ( +

+ Admin API token required to run simulations +

+ )} +
+
+ + {/* ── Right: results / history ───────────────────────────── */} +
+ {/* Tab bar */} +
+ {(["results", "history"] as const).map((tab) => ( + + ))} +
+ + {/* Results tab */} + {activeTab === "results" && ( +
+ {error && ( +
+ {error} +
+ )} + + {!currentResult && !isRunning && !error && ( +
+ +

No simulation run yet

+

+ Choose a preset or configure parameters, then click Run Simulation. +

+
+ )} + + {isRunning && ( +
+ + + +

Evaluating routing rules…

+
+ )} + + {currentResult && !isRunning && ( + + )} +
+ )} + + {/* History tab */} + {activeTab === "history" && ( + { + restoreFromHistory(r); + setActiveTab("results"); + }} + onClear={clearHistory} + /> + )} +
+
+
+ ); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function SimulationResults({ result }: { result: SimulationResult }) { + const { summary, input, results, skippedInactive } = result; + const [showSkipped, setShowSkipped] = useState(false); + + return ( +
+ {/* Run metadata */} +
+ {result.simulationId} + + + {input.label && ( + <> + + "{input.label}" + + )} +
+ + {/* Summary cards */} +
+
+

+ {summary.wouldDispatch ? "FIRES" : "SILENT"} +

+

Would dispatch

+
+
+

{summary.totalMatched}

+

Rules matched

+
+
+

{summary.totalActiveRules}

+

Rules checked

+
+
+ + {/* Simulated input echo */} +
+ Severity: + + {input.severity} + + {input.assetCode && ( + <> + Asset: + {input.assetCode} + + )} + {input.sourceType && ( + <> + Source: + {input.sourceType} + + )} + {input.metric && ( + <> + Metric: + {input.metric} + + )} + {input.triggeredValue !== null && ( + <> + Value: + + {input.triggeredValue} + {input.threshold !== null && ` / ${input.threshold}`} + + + )} +
+ + {/* Effective channels */} + {summary.wouldDispatch && ( +
+

+ Effective Channels + {summary.firstMatchingRule && ( + + via "{summary.firstMatchingRule.ruleName}" + + )} +

+
+ {summary.effectiveChannels.map((ch) => ( + + {CHANNEL_LABELS[ch] ?? ch} + + ))} + {summary.effectiveFallbackChannels.length > 0 && ( + + · Fallback:{" "} + {summary.effectiveFallbackChannels + .map((ch) => CHANNEL_LABELS[ch] ?? ch) + .join(", ")} + + )} +
+ {summary.suppressionWindowSeconds > 0 && ( +

+ + Suppression window: {summary.suppressionWindowSeconds}s — rapid repeats may be suppressed in production +

+ )} +
+ )} + + {/* Per-rule breakdown */} +
+

+ Rule Evaluation ({results.length} active) +

+
+ {results.map((r) => ( +
+
+
+ {r.matched ? ( + + + + ) : ( + + + + )} + + {r.ruleName} + +
+
+ P{r.priorityOrder} + {r.matched && ( + + MATCH + + )} +
+
+ +
    + {r.reasons.map((reason, i) => ( +
  • + + {reason} +
  • + ))} +
+ + {r.matched && r.channels.length > 0 && ( +
+ {r.channels.map((ch) => ( + + {CHANNEL_LABELS[ch] ?? ch} + + ))} + {r.suppressionWindowSeconds > 0 && ( + + {r.suppressionWindowSeconds}s suppression + + )} +
+ )} +
+ ))} + + {results.length === 0 && ( +
+ No active routing rules found for this owner. +
+ )} +
+ + {skippedInactive.length > 0 && ( +
+ + {showSkipped && ( +
    + {skippedInactive.map((r) => ( +
  • + P{r.priorityOrder} — {r.ruleName} +
  • + ))} +
+ )} +
+ )} +
+
+ ); +} + +function SimulationHistory({ + history, + onRestore, + onClear, +}: { + history: SimulationResult[]; + onRestore: (r: SimulationResult) => void; + onClear: () => void; +}) { + if (history.length === 0) { + return ( +
+

No simulation runs recorded yet.

+

Runs are saved locally in your browser.

+
+ ); + } + + return ( +
+
+

+ {history.length} run{history.length !== 1 ? "s" : ""} — stored locally +

+ +
+ +
+ {history.map((run) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/pages/DataProvenanceGraph.tsx b/frontend/src/pages/DataProvenanceGraph.tsx index 8542887e..c5068f4d 100644 --- a/frontend/src/pages/DataProvenanceGraph.tsx +++ b/frontend/src/pages/DataProvenanceGraph.tsx @@ -594,7 +594,7 @@ export default function DataProvenanceGraph() { {/* Column headers */} - {(["source", "transform", "destination"] as ProvenanceNodeKind[]).map((kind, ci) => { + {(["source", "transform", "destination"] as ProvenanceNodeKind[]).map((kind) => { const cols = (["source", "transform", "destination"] as ProvenanceNodeKind[]).filter( (k) => (graphData?.nodes ?? []).some((n) => n.kind === k) ); diff --git a/frontend/src/pages/OperationalAccessAudit.tsx b/frontend/src/pages/OperationalAccessAudit.tsx new file mode 100644 index 00000000..1a305761 --- /dev/null +++ b/frontend/src/pages/OperationalAccessAudit.tsx @@ -0,0 +1,826 @@ +import { useEffect, useMemo, useState } from "react"; +import { + exportAccessAudit, + getAccessAuditEntries, + getAccessAuditRoles, + getAccessAuditSessions, + getAccessAuditStats, +} from "../services/api"; +import { useLocalStorageState } from "../hooks/useLocalStorageState"; +import type { + AccessAuditEntry, + AccessAuditStats, + AdminMember, + AdminRotationEvent, + AccessSession, +} from "../types"; + +type Tab = "changes" | "roles" | "sessions"; + +const SEVERITY_COLORS: Record = { + critical: "bg-red-500/15 text-red-300", + warning: "bg-amber-500/15 text-amber-300", + info: "bg-stellar-blue/15 text-stellar-blue", +}; + +const ACTION_LABELS: Record = { + "auth.login": "Login", + "auth.logout": "Logout", + "auth.api_key_created": "API Key Created", + "auth.api_key_revoked": "API Key Revoked", + "admin.config_changed": "Config Changed", + "admin.provider_allowlist_changed": "Provider Allowlist Changed", + "admin.user_permission_changed": "Permission Changed", + "admin.retention_policy_changed": "Retention Policy Changed", +}; + +const ROLE_COLORS: Record = { + super_admin: "bg-red-500/15 text-red-300", + operator: "bg-amber-500/15 text-amber-300", + auditor: "bg-stellar-blue/15 text-stellar-blue", + viewer: "bg-emerald-500/15 text-emerald-300", +}; + +function formatDate(iso: string) { + return new Date(iso).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function StatCard({ label, value, sub }: { label: string; value: number | string; sub?: string }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +export default function OperationalAccessAudit() { + const [adminToken, setAdminToken] = useLocalStorageState( + "bridge-watch:admin-api-key:v1", + "" + ); + const [activeTab, setActiveTab] = useState("changes"); + + // Entries state + const [entries, setEntries] = useState([]); + const [entriesTotal, setEntriesTotal] = useState(0); + const [flaggedActions, setFlaggedActions] = useState([]); + const [entriesOffset, setEntriesOffset] = useState(0); + const ENTRIES_LIMIT = 50; + + // Roles state + const [admins, setAdmins] = useState([]); + const [recentEvents, setRecentEvents] = useState([]); + const [showInactive, setShowInactive] = useState(false); + + // Sessions state + const [sessions, setSessions] = useState([]); + const [sessionsMeta, setSessionsMeta] = useState({ total: 0, totalPages: 1, page: 1 }); + + // Stats + const [stats, setStats] = useState(null); + + // Filters + const [filterActor, setFilterActor] = useState(""); + const [filterAction, setFilterAction] = useState(""); + const [filterSeverity, setFilterSeverity] = useState(""); + const [filterFlagged, setFilterFlagged] = useState(false); + const [filterSessionUser, setFilterSessionUser] = useState(""); + const [filterSessionStatus, setFilterSessionStatus] = useState<"" | "active" | "expired" | "revoked">(""); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); + + const handleExport = async () => { + if (!adminToken) return; + setExporting(true); + setError(null); + try { + await exportAccessAudit(adminToken, { + actorId: filterActor || undefined, + action: filterAction || undefined, + severity: filterSeverity || undefined, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Export failed"); + } finally { + setExporting(false); + } + }; + + const flaggedCount = useMemo( + () => entries.filter((e) => flaggedActions.includes(e.action) || e.severity !== "info").length, + [entries, flaggedActions] + ); + + const activeSessionCount = useMemo( + () => sessions.filter((s) => s.status === "active").length, + [sessions] + ); + + const loadAll = async () => { + if (!adminToken) return; + setLoading(true); + setError(null); + try { + const [statsData] = await Promise.all([getAccessAuditStats(adminToken)]); + setStats(statsData); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load stats"); + } finally { + setLoading(false); + } + }; + + const loadEntries = async (offset = 0) => { + if (!adminToken) return; + setLoading(true); + setError(null); + try { + const result = await getAccessAuditEntries(adminToken, { + actorId: filterActor || undefined, + action: filterAction || undefined, + severity: filterSeverity || undefined, + flagged: filterFlagged, + limit: ENTRIES_LIMIT, + offset, + }); + setEntries(result.entries); + setEntriesTotal(result.total); + setFlaggedActions(result.flaggedActions); + setEntriesOffset(offset); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load entries"); + } finally { + setLoading(false); + } + }; + + const loadRoles = async () => { + if (!adminToken) return; + setLoading(true); + setError(null); + try { + const result = await getAccessAuditRoles(adminToken, !showInactive); + setAdmins(result.admins); + setRecentEvents(result.recentEvents); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load roles"); + } finally { + setLoading(false); + } + }; + + const loadSessions = async (page = 1) => { + if (!adminToken) return; + setLoading(true); + setError(null); + try { + const result = await getAccessAuditSessions(adminToken, { + userId: filterSessionUser || undefined, + status: filterSessionStatus || undefined, + page, + limit: 50, + }); + setSessions(result.data); + setSessionsMeta(result.meta); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load sessions"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadAll(); + }, [adminToken]); + + useEffect(() => { + if (activeTab === "changes") void loadEntries(0); + if (activeTab === "roles") void loadRoles(); + if (activeTab === "sessions") void loadSessions(1); + }, [activeTab, adminToken]); + + useEffect(() => { + if (activeTab === "roles") void loadRoles(); + }, [showInactive]); + + const handleApplyFilters = () => { + if (activeTab === "changes") void loadEntries(0); + if (activeTab === "sessions") void loadSessions(1); + }; + + return ( +
+ {/* ------------------------------------------------------------------ */} + {/* Header */} + {/* ------------------------------------------------------------------ */} +
+
+

Admin

+

+ Operational Access Audit Console +

+

+ Review operator roles, recent permission changes, active sessions, and + flagged privilege escalations in one place. +

+
+ +
+ {stats && ( + <> + + + + )} +
+
+ + {/* ------------------------------------------------------------------ */} + {/* Admin token input */} + {/* ------------------------------------------------------------------ */} +
+ + {!adminToken && ( +

+ Enter an admin API key to load audit data. +

+ )} +
+ + {/* ------------------------------------------------------------------ */} + {/* Tabs */} + {/* ------------------------------------------------------------------ */} +
+ {( + [ + { id: "changes", label: "Access Changes" }, + { id: "roles", label: "Roles & Permissions" }, + { id: "sessions", label: "Sessions" }, + ] as { id: Tab; label: string }[] + ).map(({ id, label }) => ( + + ))} +
+ + {error && ( +
+ {error} +
+ )} + + {/* ================================================================== */} + {/* TAB: Access Changes */} + {/* ================================================================== */} + {activeTab === "changes" && ( +
+ {/* Filters */} +
+

Filters

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

+ {loading ? "Loading…" : `${entriesTotal} entries`} + {filterFlagged && ` · ${flaggedCount} flagged`} +

+ +
+
+ + {/* Entries list */} +
+
+

Audit entries

+ +
+ + {entries.length === 0 ? ( +
+ {adminToken ? "No access events found for the selected filters." : "Add an admin token above to load audit data."} +
+ ) : ( +
+ {entries.map((entry) => { + const isFlagged = + flaggedActions.includes(entry.action) || entry.severity !== "info"; + return ( +
+
+
+ + {entry.severity} + + + {ACTION_LABELS[entry.action] ?? entry.action} + + {isFlagged && ( + + Flagged + + )} +
+ +
+ +
+ + Actor:{" "} + {entry.actorId} + {" "} + ({entry.actorType}) + + {entry.resourceType && ( + + Resource:{" "} + + {entry.resourceType} + {entry.resourceId ? ` / ${entry.resourceId}` : ""} + + + )} + {entry.ipAddress && ( + + IP: {entry.ipAddress} + + )} +
+ + {(entry.before || entry.after) && ( +
+ + View change diff + +
+ {entry.before && ( +
+

+ Before +

+
+                                  {JSON.stringify(entry.before, null, 2)}
+                                
+
+ )} + {entry.after && ( +
+

+ After +

+
+                                  {JSON.stringify(entry.after, null, 2)}
+                                
+
+ )} +
+
+ )} +
+ ); + })} +
+ )} + + {/* Pagination */} + {entriesTotal > ENTRIES_LIMIT && ( +
+ + Showing {entriesOffset + 1}– + {Math.min(entriesOffset + ENTRIES_LIMIT, entriesTotal)} of {entriesTotal} + +
+ + +
+
+ )} +
+
+ )} + + {/* ================================================================== */} + {/* TAB: Roles & Permissions */} + {/* ================================================================== */} + {activeTab === "roles" && ( +
+
+
+

Admin members

+
+ + +
+
+ + {admins.length === 0 ? ( +
+ {adminToken ? "No admin members found." : "Add an admin token above to load role data."} +
+ ) : ( +
+ {admins.map((admin) => ( +
+
+
+
+

{admin.name}

+ + {admin.isActive ? "Active" : "Inactive"} + +
+

+ {admin.address} +

+ {admin.email && ( +

+ {admin.email} +

+ )} +
+

+ Added: {formatDate(admin.createdAt)} +

+
+
+ {admin.roles.map((role) => ( + + {role.replace("_", " ")} + + ))} +
+

+ Added by: {admin.addedBy} +

+
+ ))} +
+ )} +
+ + {/* Recent permission change events */} + {recentEvents.length > 0 && ( +
+

+ Recent permission changes +

+
+ {recentEvents.map((ev) => ( +
+
+
+ + {ev.eventType.replace("_", " ")} + + {ev.adminAddress} +
+ +
+

+ Actor: {ev.actorAddress} + {ev.reason && ( + <> · Reason: {ev.reason} + )} +

+
+ ))} +
+
+ )} +
+ )} + + {/* ================================================================== */} + {/* TAB: Sessions */} + {/* ================================================================== */} + {activeTab === "sessions" && ( +
+ {/* Session filters */} +
+

Filters

+
+ + + + +
+ +
+
+

+ {loading + ? "Loading…" + : `${sessionsMeta.total} sessions · ${activeSessionCount} active`} +

+
+ + {/* Sessions list */} +
+
+

Sessions

+ +
+ + {sessions.length === 0 ? ( +
+ {adminToken ? "No sessions found." : "Add an admin token above to load session data."} +
+ ) : ( +
+ {sessions.map((session) => ( +
+
+
+
+ + {session.status} + + + {session.userId} + +
+ {session.deviceName && ( +

+ Device: {session.deviceName} + {session.deviceType ? ` (${session.deviceType})` : ""} +

+ )} + {session.ipAddress && ( +

+ IP: {session.ipAddress} +

+ )} +
+
+

Created: {formatDate(session.createdAt)}

+ {session.lastActiveAt && ( +

Last active: {formatDate(session.lastActiveAt)}

+ )} + {session.expiresAt && ( +

Expires: {formatDate(session.expiresAt)}

+ )} + {session.revokedAt && ( +

+ Revoked: {formatDate(session.revokedAt)} + {session.revokedReason ? ` · ${session.revokedReason}` : ""} +

+ )} +
+
+
+ ))} +
+ )} + + {/* Pagination */} + {sessionsMeta.totalPages > 1 && ( +
+ + Page {sessionsMeta.page} of {sessionsMeta.totalPages} + +
+ + +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f1f96b60..683128e0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -27,6 +27,11 @@ import type { UpdateAlertRoutingRuleRequest, ProvenanceGraph, ProvenanceListItem, + AccessAuditEntry, + AccessAuditStats, + AdminMember, + AdminRotationEvent, + AccessSession, } from "../types"; const API_BASE_URL = "/api/v1"; @@ -708,3 +713,105 @@ export function getProvenanceLineage( if (bridge) params.set("bridge", bridge); return fetchApi(`/provenance/lineage?${params.toString()}`); } + +// Operational Access Audit Console + +export function getAccessAuditEntries( + apiKey: string, + options?: { + actorId?: string; + action?: string; + severity?: string; + from?: string; + to?: string; + limit?: number; + offset?: number; + flagged?: boolean; + } +) { + const params = new URLSearchParams(); + if (options?.actorId) params.set("actorId", options.actorId); + if (options?.action) params.set("action", options.action); + if (options?.severity) params.set("severity", options.severity); + if (options?.from) params.set("from", options.from); + if (options?.to) params.set("to", options.to); + if (options?.limit != null) params.set("limit", String(options.limit)); + if (options?.offset != null) params.set("offset", String(options.offset)); + if (options?.flagged) params.set("flagged", "true"); + const qs = params.toString(); + return fetchApi<{ entries: AccessAuditEntry[]; total: number; limit: number; offset: number; flaggedActions: string[] }>( + `/admin/access-audit/entries${qs ? `?${qs}` : ""}`, + undefined, + apiKey + ); +} + +export function getAccessAuditStats(apiKey: string, from?: string) { + const qs = from ? `?from=${encodeURIComponent(from)}` : ""; + return fetchApi( + `/admin/access-audit/stats${qs}`, + undefined, + apiKey + ); +} + +export function getAccessAuditRoles(apiKey: string, activeOnly = true) { + const qs = activeOnly ? "?activeOnly=true" : "?activeOnly=false"; + return fetchApi<{ admins: AdminMember[]; recentEvents: AdminRotationEvent[] }>( + `/admin/access-audit/roles${qs}`, + undefined, + apiKey + ); +} + +export function getAccessAuditSessions( + apiKey: string, + options?: { + userId?: string; + status?: "active" | "expired" | "revoked"; + page?: number; + limit?: number; + } +) { + const params = new URLSearchParams(); + if (options?.userId) params.set("userId", options.userId); + if (options?.status) params.set("status", options.status); + if (options?.page != null) params.set("page", String(options.page)); + if (options?.limit != null) params.set("limit", String(options.limit)); + const qs = params.toString(); + return fetchApi<{ success: boolean; data: AccessSession[]; meta: { total: number; totalPages: number; page: number; limit: number } }>( + `/admin/access-audit/sessions${qs ? `?${qs}` : ""}`, + undefined, + apiKey + ); +} + +export async function exportAccessAudit( + apiKey: string, + options?: { actorId?: string; action?: string; severity?: string; from?: string; to?: string } +): Promise { + const params = new URLSearchParams(); + if (options?.actorId) params.set("actorId", options.actorId); + if (options?.action) params.set("action", options.action); + if (options?.severity) params.set("severity", options.severity); + if (options?.from) params.set("from", options.from); + if (options?.to) params.set("to", options.to); + const qs = params.toString(); + + const response = await fetch( + `${API_BASE_URL}/admin/access-audit/export${qs ? `?${qs}` : ""}`, + { headers: { "x-api-key": apiKey } } + ); + + if (!response.ok) { + throw new Error(`Export failed: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `access-audit-${Date.now()}.csv`; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ad07fb0d..af19d0c7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -488,3 +488,97 @@ export interface ProvenanceListItem { lastUpdated: string; nodeCount: number; } + +// Operational Access Audit Console types + +export type AccessAuditAction = + | "auth.login" + | "auth.logout" + | "auth.api_key_created" + | "auth.api_key_revoked" + | "admin.config_changed" + | "admin.provider_allowlist_changed" + | "admin.user_permission_changed" + | "admin.retention_policy_changed"; + +export type AccessAuditSeverity = "info" | "warning" | "critical"; + +export interface AccessAuditEntry { + id: string; + action: AccessAuditAction; + actorId: string; + actorType: "user" | "api_key" | "system"; + ipAddress: string | null; + userAgent: string | null; + resourceType: string | null; + resourceId: string | null; + before: Record | null; + after: Record | null; + metadata: Record; + severity: AccessAuditSeverity; + checksum: string; + createdAt: string; +} + +export interface AccessAuditStats { + total: number; + bySeverity: Record; + byAction: Record; + recentCount: number; + activeAdminCount: number; + trackedActions: AccessAuditAction[]; + flaggedActions: AccessAuditAction[]; +} + +export type AdminMemberRole = "super_admin" | "operator" | "auditor" | "viewer"; + +export interface AdminMember { + id: string; + address: string; + name: string; + email: string | null; + roles: AdminMemberRole[]; + isActive: boolean; + addedBy: string; + activatedAt: string | null; + deactivatedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export type AdminRotationEventType = + | "added" + | "removed" + | "activated" + | "deactivated" + | "role_changed"; + +export interface AdminRotationEvent { + id: string; + eventType: AdminRotationEventType; + adminAddress: string; + actorAddress: string; + beforeState: Record | null; + afterState: Record | null; + reason: string | null; + createdAt: string; +} + +export type AccessSessionStatus = "active" | "expired" | "revoked"; + +export interface AccessSession { + id: string; + userId: string; + deviceId: string | null; + deviceName: string | null; + deviceType: string | null; + userAgent: string | null; + ipAddress: string | null; + status: AccessSessionStatus; + expiresAt: string | null; + lastActiveAt: string | null; + revokedAt: string | null; + revokedReason: string | null; + createdAt: string; + updatedAt: string; +}