feat: real audit_log with redaction + pending_changes; replace stub
This commit is contained in:
30
lib/db/migrations/006_audit.sql
Normal file
30
lib/db/migrations/006_audit.sql
Normal file
@@ -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';
|
||||||
72
lib/db/repos/audit.js
Normal file
72
lib/db/repos/audit.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,2 +1 @@
|
|||||||
// Replaced in Task 16 with real audit_log writes.
|
export { recordAudit } from './audit.js';
|
||||||
export async function recordAudit() { /* noop until 006 migration lands */ }
|
|
||||||
|
|||||||
32
lib/db/repos/pending_changes.js
Normal file
32
lib/db/repos/pending_changes.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
16
tests/db/migration_006.test.js
Normal file
16
tests/db/migration_006.test.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
tests/repos/audit.test.js
Normal file
46
tests/repos/audit.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/repos/pending_changes.test.js
Normal file
34
tests/repos/pending_changes.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user