From 47a64d53fd02017e0b5ce900e041de014af73342 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Mar 2026 06:09:41 +0000 Subject: [PATCH] fix: align backend API routes with console frontend contract --- .../saas/src/api/routes.ts | 26 ++++++- .../src/api/incidents.ts | 49 ++++++++++-- .../src/api/notifications.ts | 38 ++++++++- .../04-lightweight-idp/src/api/services.ts | 39 +++++++++- .../05-aws-cost-anomaly/src/api/anomalies.ts | 67 +++++++++++++++- .../05-aws-cost-anomaly/src/api/baselines.ts | 55 ++++++++++++- .../05-aws-cost-anomaly/src/api/governance.ts | 62 ++++++++++++++- .../saas/src/api/approvals.ts | 78 ++++++++++++++++++- 8 files changed, 394 insertions(+), 20 deletions(-) diff --git a/products/02-iac-drift-detection/saas/src/api/routes.ts b/products/02-iac-drift-detection/saas/src/api/routes.ts index 3849752..c0f64fd 100644 --- a/products/02-iac-drift-detection/saas/src/api/routes.ts +++ b/products/02-iac-drift-detection/saas/src/api/routes.ts @@ -74,6 +74,30 @@ export async function registerApiRoutes(app: FastifyInstance) { return reply.send(result); }); + // Get latest report for a stack (console route: GET /api/v1/stacks/:name/report) + app.get('/api/v1/stacks/:name/report', async (request, reply) => { + const tenantId = (request as any).tenantId; + const { name } = request.params as { name: string }; + const pool = (app as any).pool; + + const result = await withTenant(pool, tenantId, async (client) => { + const { rows } = await client.query( + `SELECT * FROM drift_reports + WHERE tenant_id = $1 AND stack_name = $2 + ORDER BY scanned_at DESC + LIMIT 1`, + [tenantId, name] + ); + return rows[0] ?? null; + }); + + if (!result) { + return reply.status(404).send({ error: 'No report found for stack' }); + } + + return reply.send(result); + }); + // Get stack drift history app.get('/api/v1/stacks/:stackName/history', async (request, reply) => { const tenantId = (request as any).tenantId; @@ -96,7 +120,7 @@ export async function registerApiRoutes(app: FastifyInstance) { return reply.send(result); }); - // Get single drift report + // Get single drift report (legacy route) app.get('/api/v1/reports/:reportId', async (request, reply) => { const tenantId = (request as any).tenantId; const { reportId } = request.params as { reportId: string }; diff --git a/products/03-alert-intelligence/src/api/incidents.ts b/products/03-alert-intelligence/src/api/incidents.ts index b57e5c0..0e133eb 100644 --- a/products/03-alert-intelligence/src/api/incidents.ts +++ b/products/03-alert-intelligence/src/api/incidents.ts @@ -10,11 +10,15 @@ const listQuerySchema = z.object({ service: z.string().optional(), }); +const statusUpdateSchema = z.object({ + status: z.enum(['acknowledged', 'resolved', 'suppressed']), +}); + export function registerIncidentRoutes(app: FastifyInstance) { // List incidents app.get('/api/v1/incidents', async (req, reply) => { const query = listQuerySchema.parse(req.query); - const tenantId = (req as any).tenantId; // Set by auth middleware + const tenantId = (req as any).tenantId; const offset = (query.page - 1) * query.limit; const result = await withTenant(tenantId, async (client) => { @@ -50,7 +54,24 @@ export function registerIncidentRoutes(app: FastifyInstance) { return result; }); - // Acknowledge incident + // Unified status update (console route: POST /api/v1/incidents/:id/status) + app.post('/api/v1/incidents/:id/status', async (req, reply) => { + const { id } = req.params as { id: string }; + const { status } = statusUpdateSchema.parse(req.body); + const tenantId = (req as any).tenantId; + + await withTenant(tenantId, async (client) => { + if (status === 'resolved') { + await client.query("UPDATE incidents SET status = 'resolved', resolved_at = now() WHERE id = $1", [id]); + } else { + await client.query('UPDATE incidents SET status = $1 WHERE id = $2', [status, id]); + } + }); + + return { status }; + }); + + // Acknowledge incident (legacy route) app.post('/api/v1/incidents/:id/acknowledge', async (req, reply) => { const { id } = req.params as { id: string }; const tenantId = (req as any).tenantId; @@ -62,7 +83,7 @@ export function registerIncidentRoutes(app: FastifyInstance) { return { status: 'acknowledged' }; }); - // Resolve incident + // Resolve incident (legacy route) app.post('/api/v1/incidents/:id/resolve', async (req, reply) => { const { id } = req.params as { id: string }; const tenantId = (req as any).tenantId; @@ -74,7 +95,7 @@ export function registerIncidentRoutes(app: FastifyInstance) { return { status: 'resolved' }; }); - // Suppress incident (snooze) + // Suppress incident (legacy route) app.post('/api/v1/incidents/:id/suppress', async (req, reply) => { const { id } = req.params as { id: string }; const tenantId = (req as any).tenantId; @@ -86,7 +107,25 @@ export function registerIncidentRoutes(app: FastifyInstance) { return { status: 'suppressed' }; }); - // Dashboard summary + // Dashboard summary (console route: GET /api/v1/incidents/summary) + app.get('/api/v1/incidents/summary', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + const counts = await client.query(` + SELECT status, severity, COUNT(*)::int as count + FROM incidents + WHERE created_at > now() - interval '24 hours' + GROUP BY status, severity + `); + const total = await client.query(`SELECT COUNT(*)::int as total FROM incidents WHERE status = 'open'`); + return { breakdown: counts.rows, open_total: total.rows[0]?.total ?? 0 }; + }); + + return result; + }); + + // Dashboard summary (legacy route) app.get('/api/v1/summary', async (req, reply) => { const tenantId = (req as any).tenantId; diff --git a/products/03-alert-intelligence/src/api/notifications.ts b/products/03-alert-intelligence/src/api/notifications.ts index 5c26758..92a5009 100644 --- a/products/03-alert-intelligence/src/api/notifications.ts +++ b/products/03-alert-intelligence/src/api/notifications.ts @@ -6,7 +6,7 @@ import { withTenant } from '../data/db.js'; const logger = pino({ name: 'api-notifications' }); const notifConfigSchema = z.object({ - channel: z.enum(['slack', 'email', 'webhook']), + channel: z.enum(['slack', 'email', 'webhook']).optional(), config: z.object({ slack_webhook_url: z.string().url().optional(), email_to: z.string().email().optional(), @@ -17,7 +17,39 @@ const notifConfigSchema = z.object({ }); export function registerNotificationRoutes(app: FastifyInstance) { - // List notification configs + // Get all notification configs (console route: GET /api/v1/notifications/config) + app.get('/api/v1/notifications/config', async (req, reply) => { + const tenantId = (req as any).tenantId; + const result = await withTenant(tenantId, async (client) => { + return client.query('SELECT * FROM notification_configs ORDER BY channel'); + }); + return { configs: result.rows }; + }); + + // Update all notification configs (console route: PUT /api/v1/notifications/config) + app.put('/api/v1/notifications/config', async (req, reply) => { + const tenantId = (req as any).tenantId; + const body = req.body as { configs?: Array<{ channel: string; config: any; min_severity?: string; enabled?: boolean }> }; + + if (body.configs && Array.isArray(body.configs)) { + await withTenant(tenantId, async (client) => { + for (const cfg of body.configs!) { + const parsed = notifConfigSchema.parse(cfg); + await client.query( + `INSERT INTO notification_configs (tenant_id, channel, config, min_severity, enabled) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tenant_id, channel) DO UPDATE SET config = $3, min_severity = $4, enabled = $5`, + [tenantId, cfg.channel, JSON.stringify(parsed.config), parsed.min_severity, parsed.enabled], + ); + } + }); + } + + logger.info({ tenantId }, 'Notification configs updated via bulk'); + return { status: 'updated' }; + }); + + // List notification configs (legacy route) app.get('/api/v1/notifications', async (req, reply) => { const tenantId = (req as any).tenantId; const result = await withTenant(tenantId, async (client) => { @@ -26,7 +58,7 @@ export function registerNotificationRoutes(app: FastifyInstance) { return { configs: result.rows }; }); - // Upsert notification config + // Upsert notification config (legacy route) app.put('/api/v1/notifications/:channel', async (req, reply) => { const { channel } = req.params as { channel: string }; const body = notifConfigSchema.parse({ ...req.body as any, channel }); diff --git a/products/04-lightweight-idp/src/api/services.ts b/products/04-lightweight-idp/src/api/services.ts index dc3b80d..8adf1d1 100644 --- a/products/04-lightweight-idp/src/api/services.ts +++ b/products/04-lightweight-idp/src/api/services.ts @@ -61,7 +61,44 @@ export function registerServiceRoutes(app: FastifyInstance) { return { service: result.rows[0] }; }); - // Create/update service (manual entry) + // Create service (console route: POST /api/v1/services) + app.post('/api/v1/services', async (req, reply) => { + const body = upsertServiceSchema.parse(req.body); + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query( + `INSERT INTO services (tenant_id, name, type, owner, owner_source, description, tier, lifecycle, links, tags) + VALUES ($1, $2, $3, $4, 'config', $5, $6, $7, $8, $9) + RETURNING *`, + [tenantId, body.name, body.type, body.owner, body.description, body.tier, body.lifecycle, JSON.stringify(body.links), JSON.stringify(body.tags)], + ); + }); + + return reply.status(201).send({ service: result.rows[0] }); + }); + + // Update service by id (console route: PUT /api/v1/services/:id) + app.put('/api/v1/services/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const body = upsertServiceSchema.parse(req.body); + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query( + `UPDATE services SET name = $1, type = $2, owner = $3, owner_source = 'config', + description = $4, tier = $5, lifecycle = $6, links = $7, tags = $8, updated_at = now() + WHERE id = $9 AND tenant_id = $10 + RETURNING *`, + [body.name, body.type, body.owner, body.description, body.tier, body.lifecycle, JSON.stringify(body.links), JSON.stringify(body.tags), id, tenantId], + ); + }); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Not found' }); + return { service: result.rows[0] }; + }); + + // Create/update service via upsert (legacy route) app.put('/api/v1/services', async (req, reply) => { const body = upsertServiceSchema.parse(req.body); const tenantId = (req as any).tenantId; diff --git a/products/05-aws-cost-anomaly/src/api/anomalies.ts b/products/05-aws-cost-anomaly/src/api/anomalies.ts index 42f8d23..6cbd78f 100644 --- a/products/05-aws-cost-anomaly/src/api/anomalies.ts +++ b/products/05-aws-cost-anomaly/src/api/anomalies.ts @@ -11,7 +11,31 @@ const listQuerySchema = z.object({ }); export function registerAnomalyRoutes(app: FastifyInstance) { - // List anomalies + // List anomalies (console route: GET /api/v1/cost/anomalies) + app.get('/api/v1/cost/anomalies', async (req, reply) => { + const query = listQuerySchema.parse(req.query); + const tenantId = (req as any).tenantId; + const offset = (query.page - 1) * query.limit; + + const result = await withTenant(tenantId, async (client) => { + let sql = 'SELECT * FROM anomalies WHERE 1=1'; + const params: any[] = []; + let idx = 1; + + if (query.status) { sql += ` AND status = $${idx++}`; params.push(query.status); } + if (query.account_id) { sql += ` AND account_id = $${idx++}`; params.push(query.account_id); } + if (query.min_score) { sql += ` AND score >= $${idx++}`; params.push(query.min_score); } + + sql += ` ORDER BY detected_at DESC LIMIT $${idx++} OFFSET $${idx++}`; + params.push(query.limit, offset); + + return client.query(sql, params); + }); + + return { anomalies: result.rows, page: query.page, limit: query.limit }; + }); + + // List anomalies (legacy route) app.get('/api/v1/anomalies', async (req, reply) => { const query = listQuerySchema.parse(req.query); const tenantId = (req as any).tenantId; @@ -35,7 +59,18 @@ export function registerAnomalyRoutes(app: FastifyInstance) { return { anomalies: result.rows, page: query.page, limit: query.limit }; }); - // Acknowledge anomaly + // Acknowledge anomaly (console route: POST /api/v1/cost/anomalies/:id/acknowledge) + app.post('/api/v1/cost/anomalies/:id/acknowledge', async (req, reply) => { + const { id } = req.params as { id: string }; + const tenantId = (req as any).tenantId; + + await withTenant(tenantId, async (client) => { + await client.query("UPDATE anomalies SET status = 'acknowledged' WHERE id = $1 AND status = 'open'", [id]); + }); + return { status: 'acknowledged' }; + }); + + // Acknowledge anomaly (legacy route) app.post('/api/v1/anomalies/:id/acknowledge', async (req, reply) => { const { id } = req.params as { id: string }; const tenantId = (req as any).tenantId; @@ -72,7 +107,33 @@ export function registerAnomalyRoutes(app: FastifyInstance) { return { status: 'expected' }; }); - // Dashboard summary + // Cost summary (console route: GET /api/v1/cost/summary) + app.get('/api/v1/cost/summary', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + const openCount = await client.query("SELECT COUNT(*)::int as count FROM anomalies WHERE status = 'open'"); + const topResources = await client.query(` + SELECT resource_type, COUNT(*)::int as anomaly_count, AVG(score)::numeric(5,2) as avg_score + FROM anomalies WHERE status = 'open' + GROUP BY resource_type ORDER BY anomaly_count DESC LIMIT 10 + `); + const recentTrend = await client.query(` + SELECT date_trunc('hour', detected_at) as hour, COUNT(*)::int as count + FROM anomalies WHERE detected_at > now() - interval '24 hours' + GROUP BY hour ORDER BY hour + `); + return { + open_anomalies: openCount.rows[0]?.count ?? 0, + top_resources: topResources.rows, + hourly_trend: recentTrend.rows, + }; + }); + + return result; + }); + + // Dashboard summary (legacy route) app.get('/api/v1/dashboard', async (req, reply) => { const tenantId = (req as any).tenantId; diff --git a/products/05-aws-cost-anomaly/src/api/baselines.ts b/products/05-aws-cost-anomaly/src/api/baselines.ts index 70dd620..cefaa38 100644 --- a/products/05-aws-cost-anomaly/src/api/baselines.ts +++ b/products/05-aws-cost-anomaly/src/api/baselines.ts @@ -2,7 +2,58 @@ import type { FastifyInstance } from 'fastify'; import { withTenant } from '../data/db.js'; export function registerBaselineRoutes(app: FastifyInstance) { - // List baselines + // List baselines (console route: GET /api/v1/cost/baselines) + app.get('/api/v1/cost/baselines', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query(` + SELECT id, account_id, resource_type, welford_count as sample_count, + welford_mean::numeric(12,4) as mean, + CASE WHEN welford_count > 1 + THEN sqrt(welford_m2 / welford_count)::numeric(12,4) + ELSE 0 END as stddev, + updated_at + FROM baselines ORDER BY account_id, resource_type + `); + }); + + return { baselines: result.rows }; + }); + + // Update baseline by id (console route: PUT /api/v1/cost/baselines/:id) + app.put('/api/v1/cost/baselines/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const tenantId = (req as any).tenantId; + const body = req.body as { welford_mean?: number; welford_m2?: number; welford_count?: number }; + + const result = await withTenant(tenantId, async (client) => { + const existing = await client.query('SELECT * FROM baselines WHERE id = $1', [id]); + if (!existing.rows[0]) return null; + + const updates: string[] = []; + const params: any[] = []; + let idx = 1; + + if (body.welford_mean !== undefined) { updates.push(`welford_mean = $${idx++}`); params.push(body.welford_mean); } + if (body.welford_m2 !== undefined) { updates.push(`welford_m2 = $${idx++}`); params.push(body.welford_m2); } + if (body.welford_count !== undefined) { updates.push(`welford_count = $${idx++}`); params.push(body.welford_count); } + updates.push(`updated_at = now()`); + updates.push(`version = version + 1`); + + params.push(id); + const { rows } = await client.query( + `UPDATE baselines SET ${updates.join(', ')} WHERE id = $${idx++} RETURNING *`, + params, + ); + return rows[0]; + }); + + if (!result) return reply.status(404).send({ error: 'Baseline not found' }); + return { baseline: result }; + }); + + // List baselines (legacy route) app.get('/api/v1/baselines', async (req, reply) => { const tenantId = (req as any).tenantId; @@ -21,7 +72,7 @@ export function registerBaselineRoutes(app: FastifyInstance) { return { baselines: result.rows }; }); - // Reset baseline for a specific resource + // Reset baseline for a specific resource (legacy route) app.delete('/api/v1/baselines/:accountId/:resourceType', async (req, reply) => { const { accountId, resourceType } = req.params as { accountId: string; resourceType: string }; const tenantId = (req as any).tenantId; diff --git a/products/05-aws-cost-anomaly/src/api/governance.ts b/products/05-aws-cost-anomaly/src/api/governance.ts index b12b83e..4fa67c4 100644 --- a/products/05-aws-cost-anomaly/src/api/governance.ts +++ b/products/05-aws-cost-anomaly/src/api/governance.ts @@ -1,11 +1,71 @@ import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { withTenant } from '../data/db.js'; import { GovernanceEngine } from '../governance/engine.js'; const engine = new GovernanceEngine(); +const governanceRuleSchema = z.object({ + name: z.string().min(1).max(200), + rule_type: z.string(), + threshold: z.number().optional(), + action: z.string().default('alert'), + enabled: z.boolean().default(true), + config: z.record(z.unknown()).default({}), +}); + export function registerGovernanceRoutes(app: FastifyInstance) { - // Get governance status + // Get governance status (console route: GET /api/v1/cost/governance) + app.get('/api/v1/cost/governance', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query('SELECT governance_mode, governance_started_at FROM tenants WHERE id = $1', [tenantId]); + }); + + const tenant = result.rows[0]; + if (!tenant) return reply.status(404).send({ error: 'Tenant not found' }); + + const daysInMode = Math.floor((Date.now() - new Date(tenant.governance_started_at).getTime()) / (86400 * 1000)); + + return { + mode: tenant.governance_mode, + days_in_mode: daysInMode, + started_at: tenant.governance_started_at, + }; + }); + + // Create governance rule (console route: POST /api/v1/cost/governance) + app.post('/api/v1/cost/governance', async (req, reply) => { + const tenantId = (req as any).tenantId; + const body = governanceRuleSchema.parse(req.body); + + const result = await withTenant(tenantId, async (client) => { + const { rows } = await client.query( + `INSERT INTO governance_rules (tenant_id, name, rule_type, threshold, action, enabled, config) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [tenantId, body.name, body.rule_type, body.threshold, body.action, body.enabled, JSON.stringify(body.config)], + ); + return rows[0]; + }); + + return reply.status(201).send({ rule: result }); + }); + + // Delete governance rule (console route: DELETE /api/v1/cost/governance/:id) + app.delete('/api/v1/cost/governance/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const tenantId = (req as any).tenantId; + + await withTenant(tenantId, async (client) => { + await client.query('DELETE FROM governance_rules WHERE id = $1 AND tenant_id = $2', [id, tenantId]); + }); + + return { status: 'deleted', id }; + }); + + // Get governance status (legacy route) app.get('/api/v1/governance', async (req, reply) => { const tenantId = (req as any).tenantId; diff --git a/products/06-runbook-automation/saas/src/api/approvals.ts b/products/06-runbook-automation/saas/src/api/approvals.ts index d2b1d7e..5d7b451 100644 --- a/products/06-runbook-automation/saas/src/api/approvals.ts +++ b/products/06-runbook-automation/saas/src/api/approvals.ts @@ -33,7 +33,80 @@ export function registerApprovalRoutes(app: FastifyInstance) { return { approvals: result.rows }; }); - // Approve or reject a step + // Approve a step (console route: POST /api/v1/approvals/:id/approve) + app.post('/api/v1/approvals/:id/approve', async (req, reply) => { + const { id } = req.params as { id: string }; + const reason = ((req.body as any)?.reason as string) ?? undefined; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + + const result = await withTenant(tenantId, async (client) => { + const entry = await client.query( + `SELECT ae.id, ae.execution_id, ae.status, e.runbook_id + FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id + WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`, + [id], + ); + + if (!entry.rows[0]) return null; + + await client.query( + `UPDATE audit_entries SET status = 'approved', approved_by = $1, approval_method = 'api' + WHERE id = $2`, + [userId, id], + ); + + return entry.rows[0]; + }); + + if (!result) return reply.status(404).send({ error: 'Pending approval not found' }); + + await bridge.sendApproval(tenantId, result.execution_id, id, 'approve'); + + logger.info({ stepId: id, decision: 'approve', userId }, 'Approval decision recorded'); + return { step_id: id, decision: 'approve', reason }; + }); + + // Reject a step (console route: POST /api/v1/approvals/:id/reject) + app.post('/api/v1/approvals/:id/reject', async (req, reply) => { + const { id } = req.params as { id: string }; + const reason = ((req.body as any)?.reason as string) ?? undefined; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + + const result = await withTenant(tenantId, async (client) => { + const entry = await client.query( + `SELECT ae.id, ae.execution_id, ae.status, e.runbook_id + FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id + WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`, + [id], + ); + + if (!entry.rows[0]) return null; + + await client.query( + `UPDATE audit_entries SET status = 'rejected', approved_by = $1, approval_method = 'api' + WHERE id = $2`, + [userId, id], + ); + + await client.query( + `UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`, + [entry.rows[0].execution_id], + ); + + return entry.rows[0]; + }); + + if (!result) return reply.status(404).send({ error: 'Pending approval not found' }); + + await bridge.sendApproval(tenantId, result.execution_id, id, 'reject'); + + logger.info({ stepId: id, decision: 'reject', userId }, 'Approval decision recorded'); + return { step_id: id, decision: 'reject', reason }; + }); + + // Approve or reject a step (legacy route) app.post('/api/v1/approvals/:stepId', async (req, reply) => { const { stepId } = req.params as { stepId: string }; const body = approvalDecisionSchema.parse(req.body); @@ -41,7 +114,6 @@ export function registerApprovalRoutes(app: FastifyInstance) { const userId = (req as any).userId; const result = await withTenant(tenantId, async (client) => { - // Get the audit entry and its execution const entry = await client.query( `SELECT ae.id, ae.execution_id, ae.status, e.runbook_id FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id @@ -59,7 +131,6 @@ export function registerApprovalRoutes(app: FastifyInstance) { [newStatus, userId, stepId], ); - // If rejected, abort the execution if (body.decision === 'reject') { await client.query( `UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`, @@ -72,7 +143,6 @@ export function registerApprovalRoutes(app: FastifyInstance) { if (!result) return reply.status(404).send({ error: 'Pending approval not found' }); - // Notify agent via Redis pub/sub await bridge.sendApproval(tenantId, result.execution_id, stepId, body.decision); logger.info({ stepId, decision: body.decision, userId }, 'Approval decision recorded');