/** * Audit log viewer routes * * Exposes: * GET /api/v1/audit-logs — paginated audit entries (auth-gated) * GET /api/v1/audit-logs/actions — distinct action prefixes (for filter dropdowns) * DELETE /api/v1/audit-logs — clear the audit log (admin-gated) * * The frontend at status/js/audit-log.js already calls /api/v1/audit-logs * with {limit, offset, action=}. Before this route existed the * frontend silently 404'd (see STATE.md Queue item #1, DC-050). * * Auth: same as the rest of /api/v1 — handled by the global middleware * (the router is mounted under the auth-gated apiRouter in app.js). * * @module routes/audit-log */ const express = require('express'); const { success, errorResponse } = require('../src/utils/responses'); // Action prefixes that the dashboard's filter dropdown offers + that the // `action` query parameter will accept. Curated, NOT derived from current // log contents — see /audit-logs/actions for the live set. const ACTION_PREFIX_WHITELIST = [ 'service', 'container', 'caddy', 'dns', 'backup', 'config', 'auth', 'totp', 'update', 'monitoring', 'site', 'arr', 'tailscale', ]; const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/; function parseInt10(value, fallback) { const n = parseInt(value, 10); return Number.isFinite(n) ? n : fallback; } function isValidActionPrefix(value) { return ACTION_PREFIX_WHITELIST.includes(value); } function isValidIso(value) { if (typeof value !== 'string' || value.length < 10) return false; return ISO8601_RE.test(value); } // Parse an ISO 8601 string into ms-since-epoch. Returns NaN for invalid // input — callers must pre-validate with isValidIso(). Used to compare // timestamps numerically (lexicographic compare breaks when the two // strings use different offset formats). function toEpochMs(iso) { const ms = Date.parse(iso); return ms; } module.exports = function({ asyncHandler, auditLogger }) { if (!auditLogger || typeof auditLogger.query !== 'function') { throw new Error('audit-log route requires auditLogger with query()'); } const router = express.Router(); // GET /audit-logs?limit=50&offset=0&action=&since=&until=&outcome= router.get('/audit-logs', asyncHandler(async (req, res) => { const limit = Math.min(Math.max(parseInt10(req.query.limit, 50), 1), 500); const offset = Math.max(parseInt10(req.query.offset, 0), 0); const actionPrefix = typeof req.query.action === 'string' && req.query.action.length > 0 ? req.query.action : null; const sinceRaw = typeof req.query.since === 'string' && req.query.since.length > 0 ? req.query.since : null; const untilRaw = typeof req.query.until === 'string' && req.query.until.length > 0 ? req.query.until : null; const outcome = typeof req.query.outcome === 'string' && req.query.outcome.length > 0 ? req.query.outcome : null; if (actionPrefix !== null && !isValidActionPrefix(actionPrefix)) { return errorResponse(res, 400, `action must be one of: ${ACTION_PREFIX_WHITELIST.join(', ')}`); } if (sinceRaw !== null && !isValidIso(sinceRaw)) { return errorResponse(res, 400, 'since must be ISO 8601 (e.g. 2026-08-17T00:00:00Z)'); } if (untilRaw !== null && !isValidIso(untilRaw)) { return errorResponse(res, 400, 'until must be ISO 8601 (e.g. 2026-08-18T00:00:00Z)'); } if (outcome !== null && !['success', 'failure', 'unknown'].includes(outcome)) { return errorResponse(res, 400, 'outcome must be one of: success, failure, unknown'); } const sinceMs = sinceRaw !== null ? toEpochMs(sinceRaw) : null; const untilMs = untilRaw !== null ? toEpochMs(untilRaw) : null; if (sinceMs !== null && untilMs !== null && sinceMs > untilMs) { return errorResponse(res, 400, 'since must be <= until'); } // Pull the FULL store (capped at MAX_ENTRIES by audit-logger) so // date + outcome filters see the whole log, not the newest-N-only slice. // The store is bounded by design; a 1000-entry in-memory filter pass is // cheap (~tens of ms) and correct. Read the env-tunable MAX_ENTRIES so // operators who raise AUDIT_MAX_ENTRIES get correct filter coverage. const MAX_AUDIT_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10); const allEntries = await auditLogger.query({ limit: MAX_AUDIT_ENTRIES, offset: 0, action: actionPrefix || undefined, }); let filtered = allEntries; if (sinceMs !== null) { filtered = filtered.filter((e) => { const t = toEpochMs(e.timestamp); return Number.isFinite(t) && t >= sinceMs; }); } if (untilMs !== null) { filtered = filtered.filter((e) => { const t = toEpochMs(e.timestamp); return Number.isFinite(t) && t <= untilMs; }); } if (outcome !== null) { filtered = filtered.filter((e) => (e.outcome || 'unknown') === outcome); } const total = filtered.length; const page = filtered.slice(offset, offset + limit); return success(res, { entries: page, total, limit, offset, // truncated: true tells the caller the total is bounded by the // store's MAX_AUDIT_ENTRIES — the operator can see the whole log // but if more entries have been written since the last clear, // older rows are dropped at write-time, not at read-time. truncated: allEntries.length >= MAX_AUDIT_ENTRIES, hasMore: offset + page.length < total, filters: { action: actionPrefix, since: sinceRaw, until: untilRaw, outcome }, }); }, 'audit-logs-list')); // GET /audit-logs/actions — return the distinct action prefixes present // in the current log, INTERSECTED with the whitelist so the dropdown // only offers prefixes the GET /audit-logs filter will actually accept. router.get('/audit-logs/actions', asyncHandler(async (req, res) => { const maxAudit = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10); const entries = await auditLogger.query({ limit: maxAudit, offset: 0 }); const seen = new Set(); for (const e of entries) { if (!e.action) continue; const dot = e.action.indexOf('.'); const prefix = dot > 0 ? e.action.slice(0, dot) : e.action; // Only surface prefixes that are also in the whitelist — otherwise // the dropdown would offer a prefix that GET /audit-logs would 400. if (ACTION_PREFIX_WHITELIST.includes(prefix)) seen.add(prefix); } const prefixes = Array.from(seen).sort(); return success(res, { prefixes }); }, 'audit-logs-actions')); // DELETE /audit-logs — clear the audit log. The frontend's "Clear Log" // button already calls DELETE /api/v1/audit-logs (status/js/audit-log.js). // Body must include { confirm: 'CLEAR' } as an opt-in guard against // accidental destructive calls. // // Forensic integrity: clear() wipes audit-log.json to []. A naive // "log audit.clear before clear()" leaves zero trace because clear() // runs after — the new entry is wiped with the rest. Fix: write the // audit.clear entry FIRST so it's in the buffer, then clear() the // store, then RE-INJECT the audit.clear entry as the single surviving // row. The viewer shows "1 entry: audit.clear by at " — a // visible forensic breadcrumb that the log was just wiped. router.delete('/audit-logs', asyncHandler(async (req, res) => { const confirm = req.body?.confirm; if (confirm !== 'CLEAR') { return errorResponse(res, 400, 'destructive op: pass { confirm: "CLEAR" } in JSON body'); } const ip = req.ip || req.socket?.remoteAddress || ''; const userAttrs = (req.user && req.user.id) ? { userId: req.user.id, userRole: req.user.role || null, userEmail: req.user.email || null, } : {}; const clearEntry = { action: 'audit.clear', resource: 'audit-log.json', outcome: 'success', ip, details: { confirmedBy: req.body?.confirmedBy || 'dashboard', ...userAttrs, }, }; // Write the clear entry FIRST so it lands at index 0 of the buffer. // Failure is non-fatal — the operator still wants the log cleared. try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ } // Now wipe the store. The just-written audit.clear entry is wiped too. await auditLogger.clear(); // Re-inject the audit.clear entry so the forensic breadcrumb survives. // This is the difference between "log wiped, zero trace" and // "log wiped, viewer shows one entry: audit.clear by X at T". try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ } return success(res, { cleared: true }); }, 'audit-logs-clear')); return router; };