feat: real audit_log with redaction + pending_changes; replace stub

This commit is contained in:
root
2026-05-31 11:04:53 +10:00
parent 47ea0768fd
commit 10902bc6ac
7 changed files with 231 additions and 2 deletions

View 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
View 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;
}

View File

@@ -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';

View 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;
}