From 37230d4ef1117a580fc952c3f57e12bf46e1e9e0 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 13:36:11 -0500 Subject: [PATCH] feat(audit): add platform security audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New audit_events table, distinct from the Provisioner's environment_events (Lab-lifecycle only). recordAudit() is called from the auth/RBAC/admin layers added in this phase — login success/failure, logout, password change, instructor CRUD, role changes, session revocation, cohort/roster changes, curriculum assignment, Prepare/Destroy Labs, and every 403 authorization denial (recorded centrally by the RBAC middleware, not per-route). Never records plaintext passwords, raw session tokens, Provisioner secrets, or raw routing tokens. --- platform/api/migrations/011_audit_events.sql | 20 ++++++++++++++++++++ platform/api/src/audit.js | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 platform/api/migrations/011_audit_events.sql create mode 100644 platform/api/src/audit.js diff --git a/platform/api/migrations/011_audit_events.sql b/platform/api/migrations/011_audit_events.sql new file mode 100644 index 0000000..c2d9efd --- /dev/null +++ b/platform/api/migrations/011_audit_events.sql @@ -0,0 +1,20 @@ +-- audit_events — platform-level security/admin audit log, distinct from the Provisioner's +-- environment_events (which is Lab-lifecycle-only). Never stores plaintext passwords, raw +-- session tokens, Provisioner secrets, or raw routing tokens — see src/audit.js callers. + +CREATE TABLE IF NOT EXISTS audit_events ( + audit_event_id BIGSERIAL PRIMARY KEY, + actor_type TEXT NOT NULL, -- 'instructor' | 'student' | 'system' + actor_id TEXT, -- instructor_id or student_id as text; NULL for e.g. a failed login with no matching account + action TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + result TEXT NOT NULL, -- 'success' | 'failure' + source_ip TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_events(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor_type, actor_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action); diff --git a/platform/api/src/audit.js b/platform/api/src/audit.js new file mode 100644 index 0000000..ddae695 --- /dev/null +++ b/platform/api/src/audit.js @@ -0,0 +1,20 @@ +// Platform-level security/admin audit log — distinct from the Provisioner's environment_events +// (Lab lifecycle only). Never pass plaintext passwords, raw session tokens, Provisioner +// secrets, or raw routing tokens into `metadata` — callers are responsible for that, this +// module doesn't scrub input. + +async function recordAudit(pool, { actorType, actorId, action, resourceType, resourceId, result, sourceIp, metadata }) { + try { + await pool.query( + `INSERT INTO audit_events (actor_type, actor_id, action, resource_type, resource_id, result, source_ip, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [actorType, actorId ? String(actorId) : null, action, resourceType || null, resourceId ? String(resourceId) : null, + result, sourceIp || null, metadata ? JSON.stringify(metadata) : null] + ); + } catch (e) { + // Audit logging must never take down the request it's describing. + console.error('[audit] failed to record event:', action, e.message); + } +} + +module.exports = { recordAudit };