Implement P6 TODO stubs: runbook CRUD, execution triggers, approval flow, Slack bot
- Runbooks: list (paginated), get, create (with step counting), archive - Executions: trigger with dry_run + variables, history, detail with audit trail - Approvals: list pending, approve/reject with Redis pub/sub notification to agent - Slack bot: approve_step/reject_step button handlers with DB updates + agent bridge - All routes use withTenant() RLS
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import pino from 'pino';
|
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 logger = pino({ name: 'api-approvals' });
|
||||||
|
const bridge = new AgentBridge(config.REDIS_URL);
|
||||||
|
|
||||||
const approvalDecisionSchema = z.object({
|
const approvalDecisionSchema = z.object({
|
||||||
decision: z.enum(['approve', 'reject']),
|
decision: z.enum(['approve', 'reject']),
|
||||||
@@ -12,22 +16,66 @@ const approvalDecisionSchema = z.object({
|
|||||||
export function registerApprovalRoutes(app: FastifyInstance) {
|
export function registerApprovalRoutes(app: FastifyInstance) {
|
||||||
// List pending approvals for tenant
|
// List pending approvals for tenant
|
||||||
app.get('/api/v1/approvals', async (req, reply) => {
|
app.get('/api/v1/approvals', async (req, reply) => {
|
||||||
// TODO: SELECT from audit_entries WHERE status = 'awaiting_approval'
|
const tenantId = (req as any).tenantId;
|
||||||
return { approvals: [] };
|
|
||||||
|
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
|
// Approve or reject a step
|
||||||
app.post('/api/v1/approvals/:stepId', async (req, reply) => {
|
app.post('/api/v1/approvals/:stepId', async (req, reply) => {
|
||||||
const { stepId } = req.params as { stepId: string };
|
const { stepId } = req.params as { stepId: string };
|
||||||
const body = approvalDecisionSchema.parse(req.body);
|
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
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
logger.info({ stepId, decision: body.decision }, 'Approval decision recorded');
|
// 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 {
|
if (!entry.rows[0]) return null;
|
||||||
step_id: stepId,
|
|
||||||
decision: body.decision,
|
const newStatus = body.decision === 'approve' ? 'approved' : 'rejected';
|
||||||
reason: body.reason,
|
|
||||||
};
|
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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import pino from 'pino';
|
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 logger = pino({ name: 'api-runbooks' });
|
||||||
|
const bridge = new AgentBridge(config.REDIS_URL);
|
||||||
|
|
||||||
const createRunbookSchema = z.object({
|
const createRunbookSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -20,54 +24,130 @@ export function registerRunbookRoutes(app: FastifyInstance) {
|
|||||||
// List runbooks
|
// List runbooks
|
||||||
app.get('/api/v1/runbooks', async (req, reply) => {
|
app.get('/api/v1/runbooks', async (req, reply) => {
|
||||||
const query = listQuerySchema.parse(req.query);
|
const query = listQuerySchema.parse(req.query);
|
||||||
// TODO: SELECT from runbooks with RLS tenant context
|
const tenantId = (req as any).tenantId;
|
||||||
return { runbooks: [], page: query.page, limit: query.limit, total: 0 };
|
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
|
// Get single runbook
|
||||||
app.get('/api/v1/runbooks/:id', async (req, reply) => {
|
app.get('/api/v1/runbooks/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
// TODO: SELECT by id
|
const tenantId = (req as any).tenantId;
|
||||||
return { runbook: null };
|
|
||||||
|
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
|
// Create runbook
|
||||||
app.post('/api/v1/runbooks', async (req, reply) => {
|
app.post('/api/v1/runbooks', async (req, reply) => {
|
||||||
const body = createRunbookSchema.parse(req.body);
|
const body = createRunbookSchema.parse(req.body);
|
||||||
// TODO: INSERT into runbooks, parse YAML, validate steps
|
const tenantId = (req as any).tenantId;
|
||||||
logger.info({ name: body.name }, 'Runbook created');
|
const userId = (req as any).userId;
|
||||||
return reply.status(201).send({ id: 'placeholder', ...body });
|
|
||||||
|
// 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
|
// Trigger runbook execution
|
||||||
app.post('/api/v1/runbooks/:id/execute', async (req, reply) => {
|
app.post('/api/v1/runbooks/:id/execute', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const userId = (req as any).userId;
|
||||||
const body = z.object({
|
const body = z.object({
|
||||||
dry_run: z.boolean().default(false),
|
dry_run: z.boolean().default(false),
|
||||||
variables: z.record(z.string()).optional(),
|
variables: z.record(z.string()).optional(),
|
||||||
}).parse(req.body ?? {});
|
}).parse(req.body ?? {});
|
||||||
|
|
||||||
// TODO: Create execution record, dispatch to agent via WebSocket/queue
|
const execution = await withTenant(tenantId, async (client) => {
|
||||||
logger.info({ runbookId: id, dryRun: body.dry_run }, 'Execution triggered');
|
// 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({
|
return reply.status(202).send({
|
||||||
execution_id: 'placeholder',
|
execution_id: execution.id,
|
||||||
runbook_id: id,
|
runbook_id: id,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
dry_run: body.dry_run,
|
dry_run: body.dry_run,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get execution history
|
// Get execution history for a runbook
|
||||||
app.get('/api/v1/runbooks/:id/executions', async (req, reply) => {
|
app.get('/api/v1/runbooks/:id/executions', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
// TODO: SELECT from executions
|
const tenantId = (req as any).tenantId;
|
||||||
return { executions: [] };
|
|
||||||
|
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) => {
|
app.get('/api/v1/executions/:executionId', async (req, reply) => {
|
||||||
const { executionId } = req.params as { executionId: string };
|
const { executionId } = req.params as { executionId: string };
|
||||||
// TODO: SELECT execution + JOIN audit_entries
|
const tenantId = (req as any).tenantId;
|
||||||
return { execution: null, steps: [] };
|
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { config } from '../config/index.js';
|
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 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) {
|
export function registerSlackRoutes(app: FastifyInstance) {
|
||||||
// Slack Events API verification + interactive payloads
|
// Slack Events API verification
|
||||||
app.post('/slack/events', async (req, reply) => {
|
app.post('/slack/events', async (req, reply) => {
|
||||||
// Verify Slack signature
|
|
||||||
if (config.SLACK_SIGNING_SECRET) {
|
if (config.SLACK_SIGNING_SECRET) {
|
||||||
const timestamp = req.headers['x-slack-request-timestamp'] as string;
|
const timestamp = req.headers['x-slack-request-timestamp'] as string;
|
||||||
const signature = req.headers['x-slack-signature'] 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;
|
const payload = req.body as any;
|
||||||
|
|
||||||
// URL verification challenge
|
|
||||||
if (payload.type === 'url_verification') {
|
if (payload.type === 'url_verification') {
|
||||||
return { challenge: payload.challenge };
|
return { challenge: payload.challenge };
|
||||||
}
|
}
|
||||||
@@ -33,9 +29,8 @@ export function registerSlackRoutes(app: FastifyInstance) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Slack interactive components (button clicks)
|
// Slack interactive components (approve/reject buttons)
|
||||||
app.post('/slack/interactions', async (req, reply) => {
|
app.post('/slack/interactions', async (req, reply) => {
|
||||||
// Slack sends form-encoded payload
|
|
||||||
const rawPayload = (req.body as any)?.payload;
|
const rawPayload = (req.body as any)?.payload;
|
||||||
if (!rawPayload) return reply.status(400).send({ error: 'Missing 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') {
|
if (payload.type === 'block_actions') {
|
||||||
for (const action of payload.actions ?? []) {
|
for (const action of payload.actions ?? []) {
|
||||||
const [actionType, stepId] = (action.action_id ?? '').split(':');
|
const [actionType, stepId] = (action.action_id ?? '').split(':');
|
||||||
|
const slackUserId = payload.user?.id ?? 'unknown';
|
||||||
|
|
||||||
if (actionType === 'approve_step') {
|
if (actionType === 'approve_step' && stepId) {
|
||||||
logger.info({ stepId, user: payload.user?.id }, 'Step approved via Slack');
|
// Look up the audit entry to get tenant + execution context
|
||||||
// TODO: Update audit entry, resume execution
|
const { pool } = await import('../data/db.js');
|
||||||
} else if (actionType === 'reject_step') {
|
const entry = await pool.query(
|
||||||
logger.info({ stepId, user: payload.user?.id }, 'Step rejected via Slack');
|
`SELECT ae.id, ae.execution_id, e.tenant_id
|
||||||
// TODO: Update audit entry, abort execution
|
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 {
|
function verifySlackSignature(body: string, timestamp: string, signature: string, secret: string): boolean {
|
||||||
if (!timestamp || !signature) return false;
|
if (!timestamp || !signature) return false;
|
||||||
|
|
||||||
// Reject stale requests (>5 min)
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
|
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
|
||||||
|
|
||||||
const sigBasestring = `v0:${timestamp}:${body}`;
|
const sigBasestring = `v0:${timestamp}:${body}`;
|
||||||
const expected = 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex');
|
const expected = 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex');
|
||||||
|
|
||||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user