feat(audit): add platform security audit log
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.
This commit is contained in:
@@ -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);
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user