diff --git a/products/06-runbook-automation/saas/src/api/approvals.ts b/products/06-runbook-automation/saas/src/api/approvals.ts index 7a0d67b..d2b1d7e 100644 --- a/products/06-runbook-automation/saas/src/api/approvals.ts +++ b/products/06-runbook-automation/saas/src/api/approvals.ts @@ -1,8 +1,12 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import pino from 'pino'; +import { withTenant } from '../data/db.js'; +import { AgentBridge } from '../bridge/agent-bridge.js'; +import { config } from '../config/index.js'; const logger = pino({ name: 'api-approvals' }); +const bridge = new AgentBridge(config.REDIS_URL); const approvalDecisionSchema = z.object({ decision: z.enum(['approve', 'reject']), @@ -12,22 +16,66 @@ const approvalDecisionSchema = z.object({ export function registerApprovalRoutes(app: FastifyInstance) { // List pending approvals for tenant app.get('/api/v1/approvals', async (req, reply) => { - // TODO: SELECT from audit_entries WHERE status = 'awaiting_approval' - return { approvals: [] }; + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query( + `SELECT ae.id, ae.execution_id, ae.step_index, ae.command, ae.safety_level, ae.started_at, + e.runbook_id, r.name as runbook_name + FROM audit_entries ae + JOIN executions e ON ae.execution_id = e.id + JOIN runbooks r ON e.runbook_id = r.id + WHERE ae.status = 'awaiting_approval' + ORDER BY ae.started_at DESC`, + ); + }); + + return { approvals: result.rows }; }); // Approve or reject a step app.post('/api/v1/approvals/:stepId', async (req, reply) => { const { stepId } = req.params as { stepId: string }; const body = approvalDecisionSchema.parse(req.body); + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; - // TODO: Update audit entry, notify agent via WebSocket/Redis pub-sub - logger.info({ stepId, decision: body.decision }, 'Approval decision recorded'); + 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 + WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`, + [stepId], + ); - return { - step_id: stepId, - decision: body.decision, - reason: body.reason, - }; + if (!entry.rows[0]) return null; + + const newStatus = body.decision === 'approve' ? 'approved' : 'rejected'; + + await client.query( + `UPDATE audit_entries SET status = $1, approved_by = $2, approval_method = 'api' + WHERE id = $3`, + [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`, + [entry.rows[0].execution_id], + ); + } + + return entry.rows[0]; + }); + + 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'); + return { step_id: stepId, decision: body.decision, reason: body.reason }; }); } diff --git a/products/06-runbook-automation/saas/src/api/runbooks.ts b/products/06-runbook-automation/saas/src/api/runbooks.ts index 5784b29..9407cfa 100644 --- a/products/06-runbook-automation/saas/src/api/runbooks.ts +++ b/products/06-runbook-automation/saas/src/api/runbooks.ts @@ -1,8 +1,12 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import pino from 'pino'; +import { withTenant } from '../data/db.js'; +import { AgentBridge } from '../bridge/agent-bridge.js'; +import { config } from '../config/index.js'; const logger = pino({ name: 'api-runbooks' }); +const bridge = new AgentBridge(config.REDIS_URL); const createRunbookSchema = z.object({ name: z.string().min(1).max(200), @@ -20,54 +24,130 @@ export function registerRunbookRoutes(app: FastifyInstance) { // List runbooks app.get('/api/v1/runbooks', async (req, reply) => { const query = listQuerySchema.parse(req.query); - // TODO: SELECT from runbooks with RLS tenant context - return { runbooks: [], page: query.page, limit: query.limit, total: 0 }; + const tenantId = (req as any).tenantId; + const offset = (query.page - 1) * query.limit; + + const result = await withTenant(tenantId, async (client) => { + let sql = 'SELECT id, name, description, step_count, status, created_by, created_at, updated_at FROM runbooks WHERE 1=1'; + const params: any[] = []; + let idx = 1; + + if (query.status) { sql += ` AND status = $${idx++}`; params.push(query.status); } + sql += ` ORDER BY updated_at DESC LIMIT $${idx++} OFFSET $${idx++}`; + params.push(query.limit, offset); + + const rows = await client.query(sql, params); + const total = await client.query('SELECT COUNT(*)::int as count FROM runbooks' + (query.status ? ` WHERE status = '${query.status}'` : '')); + return { rows: rows.rows, total: total.rows[0]?.count ?? 0 }; + }); + + return { runbooks: result.rows, page: query.page, limit: query.limit, total: result.total }; }); // Get single runbook app.get('/api/v1/runbooks/:id', async (req, reply) => { const { id } = req.params as { id: string }; - // TODO: SELECT by id - return { runbook: null }; + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query('SELECT * FROM runbooks WHERE id = $1', [id]); + }); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Runbook not found' }); + return { runbook: result.rows[0] }; }); // Create runbook app.post('/api/v1/runbooks', async (req, reply) => { const body = createRunbookSchema.parse(req.body); - // TODO: INSERT into runbooks, parse YAML, validate steps - logger.info({ name: body.name }, 'Runbook created'); - return reply.status(201).send({ id: 'placeholder', ...body }); + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + + // Count steps in YAML (basic: count lines starting with "- description:" or "- command:") + const stepCount = (body.yaml_content.match(/^\s*-\s+(description|command):/gm) ?? []).length; + + const result = await withTenant(tenantId, async (client) => { + return client.query( + `INSERT INTO runbooks (tenant_id, name, description, yaml_content, step_count, created_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [tenantId, body.name, body.description ?? '', body.yaml_content, Math.max(1, Math.ceil(stepCount / 2)), userId], + ); + }); + + logger.info({ name: body.name, tenantId }, 'Runbook created'); + return reply.status(201).send({ runbook: result.rows[0] }); }); // Trigger runbook execution app.post('/api/v1/runbooks/:id/execute', async (req, reply) => { const { id } = req.params as { id: string }; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; const body = z.object({ dry_run: z.boolean().default(false), variables: z.record(z.string()).optional(), }).parse(req.body ?? {}); - // TODO: Create execution record, dispatch to agent via WebSocket/queue - logger.info({ runbookId: id, dryRun: body.dry_run }, 'Execution triggered'); + const execution = await withTenant(tenantId, async (client) => { + // Verify runbook exists + const rb = await client.query('SELECT id, name FROM runbooks WHERE id = $1', [id]); + if (!rb.rows[0]) return null; + + // Create execution record + const exec = await client.query( + `INSERT INTO executions (tenant_id, runbook_id, triggered_by, trigger_source, dry_run, variables) + VALUES ($1, $2, $3, 'api', $4, $5) RETURNING *`, + [tenantId, id, userId, body.dry_run, JSON.stringify(body.variables ?? {})], + ); + + return exec.rows[0]; + }); + + if (!execution) return reply.status(404).send({ error: 'Runbook not found' }); + + logger.info({ runbookId: id, executionId: execution.id, dryRun: body.dry_run }, 'Execution triggered'); + return reply.status(202).send({ - execution_id: 'placeholder', + execution_id: execution.id, runbook_id: id, status: 'pending', dry_run: body.dry_run, }); }); - // Get execution history + // Get execution history for a runbook app.get('/api/v1/runbooks/:id/executions', async (req, reply) => { const { id } = req.params as { id: string }; - // TODO: SELECT from executions - return { executions: [] }; + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query( + `SELECT id, triggered_by, trigger_source, dry_run, status, started_at, completed_at, created_at + FROM executions WHERE runbook_id = $1 ORDER BY created_at DESC LIMIT 50`, + [id], + ); + }); + + return { executions: result.rows }; }); - // Get execution detail (with step-by-step audit trail) + // Get execution detail with step-by-step audit trail app.get('/api/v1/executions/:executionId', async (req, reply) => { const { executionId } = req.params as { executionId: string }; - // TODO: SELECT execution + JOIN audit_entries - return { execution: null, steps: [] }; + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + const execution = await client.query('SELECT * FROM executions WHERE id = $1', [executionId]); + const steps = await client.query( + `SELECT step_index, command, safety_level, status, approved_by, approval_method, + exit_code, started_at, completed_at, duration_ms + FROM audit_entries WHERE execution_id = $1 ORDER BY step_index`, + [executionId], + ); + return { execution: execution.rows[0] ?? null, steps: steps.rows }; + }); + + if (!result.execution) return reply.status(404).send({ error: 'Execution not found' }); + return result; }); } diff --git a/products/06-runbook-automation/saas/src/slackbot/handler.ts b/products/06-runbook-automation/saas/src/slackbot/handler.ts index a9a7731..144daee 100644 --- a/products/06-runbook-automation/saas/src/slackbot/handler.ts +++ b/products/06-runbook-automation/saas/src/slackbot/handler.ts @@ -2,17 +2,15 @@ import type { FastifyInstance } from 'fastify'; import pino from 'pino'; import crypto from 'node:crypto'; import { config } from '../config/index.js'; +import { withTenant } from '../data/db.js'; +import { AgentBridge } from '../bridge/agent-bridge.js'; const logger = pino({ name: 'slackbot' }); +const bridge = new AgentBridge(config.REDIS_URL); -/** - * Slack interactive message handler. - * Receives button clicks for approve/reject from Slack Block Kit messages. - */ export function registerSlackRoutes(app: FastifyInstance) { - // Slack Events API verification + interactive payloads + // Slack Events API verification app.post('/slack/events', async (req, reply) => { - // Verify Slack signature if (config.SLACK_SIGNING_SECRET) { const timestamp = req.headers['x-slack-request-timestamp'] as string; const signature = req.headers['x-slack-signature'] as string; @@ -24,8 +22,6 @@ export function registerSlackRoutes(app: FastifyInstance) { } const payload = req.body as any; - - // URL verification challenge if (payload.type === 'url_verification') { return { challenge: payload.challenge }; } @@ -33,9 +29,8 @@ export function registerSlackRoutes(app: FastifyInstance) { return { ok: true }; }); - // Slack interactive components (button clicks) + // Slack interactive components (approve/reject buttons) app.post('/slack/interactions', async (req, reply) => { - // Slack sends form-encoded payload const rawPayload = (req.body as any)?.payload; if (!rawPayload) return reply.status(400).send({ error: 'Missing payload' }); @@ -49,13 +44,59 @@ export function registerSlackRoutes(app: FastifyInstance) { if (payload.type === 'block_actions') { for (const action of payload.actions ?? []) { const [actionType, stepId] = (action.action_id ?? '').split(':'); + const slackUserId = payload.user?.id ?? 'unknown'; - if (actionType === 'approve_step') { - logger.info({ stepId, user: payload.user?.id }, 'Step approved via Slack'); - // TODO: Update audit entry, resume execution - } else if (actionType === 'reject_step') { - logger.info({ stepId, user: payload.user?.id }, 'Step rejected via Slack'); - // TODO: Update audit entry, abort execution + if (actionType === 'approve_step' && stepId) { + // Look up the audit entry to get tenant + execution context + const { pool } = await import('../data/db.js'); + const entry = await pool.query( + `SELECT ae.id, ae.execution_id, e.tenant_id + FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id + WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`, + [stepId], + ); + + if (entry.rows[0]) { + const { tenant_id, execution_id } = entry.rows[0]; + + await withTenant(tenant_id, async (client) => { + await client.query( + `UPDATE audit_entries SET status = 'approved', approved_by = $1, approval_method = 'slack_button' + WHERE id = $2`, + [slackUserId, stepId], + ); + }); + + await bridge.sendApproval(tenant_id, execution_id, stepId, 'approve'); + logger.info({ stepId, user: slackUserId }, 'Step approved via Slack'); + } + } else if (actionType === 'reject_step' && stepId) { + const { pool } = await import('../data/db.js'); + const entry = await pool.query( + `SELECT ae.id, ae.execution_id, e.tenant_id + FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id + WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`, + [stepId], + ); + + if (entry.rows[0]) { + const { tenant_id, execution_id } = entry.rows[0]; + + await withTenant(tenant_id, async (client) => { + await client.query( + `UPDATE audit_entries SET status = 'rejected', approved_by = $1, approval_method = 'slack_button' + WHERE id = $2`, + [slackUserId, stepId], + ); + await client.query( + `UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`, + [execution_id], + ); + }); + + await bridge.sendApproval(tenant_id, execution_id, stepId, 'reject'); + logger.info({ stepId, user: slackUserId }, 'Step rejected via Slack'); + } } } } @@ -66,13 +107,10 @@ export function registerSlackRoutes(app: FastifyInstance) { function verifySlackSignature(body: string, timestamp: string, signature: string, secret: string): boolean { if (!timestamp || !signature) return false; - - // Reject stale requests (>5 min) const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false; const sigBasestring = `v0:${timestamp}:${body}`; const expected = 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex'); - return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); }