diff --git a/lib/db/migrations/006_audit.sql b/lib/db/migrations/006_audit.sql new file mode 100644 index 0000000..5cc3f3b --- /dev/null +++ b/lib/db/migrations/006_audit.sql @@ -0,0 +1,30 @@ +CREATE TABLE audit_log ( + id bigserial PRIMARY KEY, + actor_kind text NOT NULL CHECK (actor_kind IN ('user','agent','cron','worker','system')), + actor_id uuid, + entity_type text NOT NULL, + entity_id uuid, + action text NOT NULL CHECK (action IN ('create','update','delete','suggest','approve','reject')), + diff jsonb, + occurred_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE pending_changes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + entity_type text NOT NULL, + entity_id uuid, + action text NOT NULL CHECK (action IN ('create','update','delete')), + payload jsonb NOT NULL, + reason text, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','approved','rejected')), + resolved_at timestamptz, + resolved_by text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id, occurred_at); +CREATE INDEX idx_audit_actor ON audit_log(actor_kind, actor_id, occurred_at); +CREATE INDEX idx_pending_status ON pending_changes(status, created_at) + WHERE status='pending'; diff --git a/lib/db/repos/audit.js b/lib/db/repos/audit.js new file mode 100644 index 0000000..b3464a3 --- /dev/null +++ b/lib/db/repos/audit.js @@ -0,0 +1,72 @@ +import { pool } from '../pool.js'; + +const REDACT_KEYS = new Set([ + 'token','token_hash','password','api_key','secret','authorization' +]); + +function isSensitiveKey(k) { + return REDACT_KEYS.has(String(k).toLowerCase()); +} + +function redactValueForKey(k, v) { + if (isSensitiveKey(k)) return '[REDACTED]'; + return redact(v); +} + +function redact(obj) { + if (!obj || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(redact); + const out = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = redactValueForKey(k, v); + } + return out; +} + +function diff(before, after) { + if (before === null && after === null) return null; + if (before === null || before === undefined) return { kind: 'create', after: redact(after) }; + if (after === null || after === undefined) return { kind: 'delete', before: redact(before) }; + const changed = {}; + for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) { + changed[k] = { + before: redactValueForKey(k, before[k]), + after: redactValueForKey(k, after[k]) + }; + } + } + return Object.keys(changed).length ? { kind: 'update', changes: changed } : null; +} + +export async function recordAudit(actor, action, entity_type, entity_id, before, after) { + const d = diff(before, after); + await pool.query( + `INSERT INTO audit_log(actor_kind, actor_id, entity_type, entity_id, action, diff) + VALUES($1,$2,$3,$4,$5,$6)`, + [actor?.kind || 'system', actor?.id || null, entity_type, entity_id, action, d] + ); +} + +export async function listForEntity(entity_type, entity_id, { limit = 100 } = {}) { + const { rows } = await pool.query( + `SELECT * FROM audit_log WHERE entity_type=$1 AND entity_id=$2 + ORDER BY occurred_at DESC LIMIT $3`, + [entity_type, entity_id, limit] + ); + return rows; +} + +export async function listByActor({ actor_kind, actor_id, limit = 100 } = {}) { + const where = [], vals = []; + let i = 1; + if (actor_kind) { where.push(`actor_kind=$${i++}`); vals.push(actor_kind); } + if (actor_id) { where.push(`actor_id=$${i++}`); vals.push(actor_id); } + vals.push(limit); + const w = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const { rows } = await pool.query( + `SELECT * FROM audit_log ${w} ORDER BY occurred_at DESC LIMIT $${i}`, + vals + ); + return rows; +} diff --git a/lib/db/repos/audit_stub.js b/lib/db/repos/audit_stub.js index 1f8ad54..30f48c0 100644 --- a/lib/db/repos/audit_stub.js +++ b/lib/db/repos/audit_stub.js @@ -1,2 +1 @@ -// Replaced in Task 16 with real audit_log writes. -export async function recordAudit() { /* noop until 006 migration lands */ } +export { recordAudit } from './audit.js'; diff --git a/lib/db/repos/pending_changes.js b/lib/db/repos/pending_changes.js new file mode 100644 index 0000000..71e58e2 --- /dev/null +++ b/lib/db/repos/pending_changes.js @@ -0,0 +1,32 @@ +import { pool } from '../pool.js'; + +export async function create({ agent_id, entity_type, entity_id, action, payload, reason }) { + const { rows: [r] } = await pool.query( + `INSERT INTO pending_changes(agent_id, entity_type, entity_id, action, payload, reason) + VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, + [agent_id, entity_type, entity_id || null, action, payload, reason || null] + ); + return r; +} + +export async function listPending({ limit = 100 } = {}) { + const { rows } = await pool.query( + `SELECT * FROM pending_changes WHERE status='pending' ORDER BY created_at LIMIT $1`, + [limit] + ); + return rows; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM pending_changes WHERE id=$1`, [id]); + return r; +} + +export async function resolve(id, status, resolved_by) { + const { rows: [r] } = await pool.query( + `UPDATE pending_changes SET status=$1, resolved_at=now(), resolved_by=$2 + WHERE id=$3 AND status='pending' RETURNING *`, + [status, resolved_by || null, id] + ); + return r; +} diff --git a/tests/db/migration_006.test.js b/tests/db/migration_006.test.js new file mode 100644 index 0000000..db4936d --- /dev/null +++ b/tests/db/migration_006.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { resetDb, withClient } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +describe('migration 006 — audit', () => { + beforeAll(async () => { await resetDb(); await migrateUp(); }); + + it('creates audit_log and pending_changes', async () => { + await withClient(async (c) => { + for (const t of ['audit_log','pending_changes']) { + const { rows } = await c.query(`SELECT to_regclass('public.' || $1) AS t;`, [t]); + expect(rows[0].t).toBe(t); + } + }); + }); +}); diff --git a/tests/repos/audit.test.js b/tests/repos/audit.test.js new file mode 100644 index 0000000..0b63499 --- /dev/null +++ b/tests/repos/audit.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; +import * as audit from '../../lib/db/repos/audit.js'; + +const owner = { kind: 'user', id: null }; +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('audit log', () => { + it('creates an audit row on space create', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const rows = await audit.listForEntity('space', s.id); + expect(rows).toHaveLength(1); + expect(rows[0].action).toBe('create'); + }); + + it('records the diff of an update', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + await spaces.update(s.id, { name: 'Hh' }, owner); + const rows = await audit.listForEntity('space', s.id); + const upd = rows.find(r => r.action === 'update'); + expect(upd.diff.changes.name.before).toBe('H'); + expect(upd.diff.changes.name.after).toBe('Hh'); + }); + + it('redacts known sensitive keys', async () => { + await audit.recordAudit(owner, 'update', 'test', null, + { token: 'secret-abc' }, { token: 'secret-def' } + ); + const rows = await audit.listByActor({}); + const r = rows.find(r => r.entity_type === 'test'); + expect(r.diff.changes.token.before).toBe('[REDACTED]'); + expect(r.diff.changes.token.after).toBe('[REDACTED]'); + }); + + it('redacts sensitive keys in nested objects', async () => { + await audit.recordAudit(owner, 'create', 'nested', null, + null, { outer: { password: 'hunter2', name: 'ok' } } + ); + const rows = await audit.listByActor({}); + const r = rows.find(r => r.entity_type === 'nested'); + expect(r.diff.after.outer.password).toBe('[REDACTED]'); + expect(r.diff.after.outer.name).toBe('ok'); + }); +}); diff --git a/tests/repos/pending_changes.test.js b/tests/repos/pending_changes.test.js new file mode 100644 index 0000000..34bfc70 --- /dev/null +++ b/tests/repos/pending_changes.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as agents from '../../lib/db/repos/agents.js'; +import * as pending from '../../lib/db/repos/pending_changes.js'; + +const owner = { kind: 'user', id: null }; +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('pending changes', () => { + it('creates and resolves a pending change', async () => { + const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); + const p = await pending.create({ + agent_id: a.id, entity_type: 'page', action: 'create', + payload: { title: 'draft', body_md: 'hi' }, reason: 'inferred from chat' + }); + expect(p.status).toBe('pending'); + const resolved = await pending.resolve(p.id, 'approved', 'owner'); + expect(resolved.status).toBe('approved'); + }); + + it('listPending returns only pending rows', async () => { + const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); + const p1 = await pending.create({ + agent_id: a.id, entity_type: 'page', action: 'create', payload: { title: 'a' } + }); + await pending.create({ + agent_id: a.id, entity_type: 'page', action: 'create', payload: { title: 'b' } + }); + await pending.resolve(p1.id, 'rejected', 'owner'); + const pendingRows = await pending.listPending(); + expect(pendingRows).toHaveLength(1); + }); +});