Files
Hermes 5f95fdcf70
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
feat(api): audit-log viewer route + UI enhancements (DC-050) [glm-grade=A]
The frontend at status/js/audit-log.js has been calling
/api/v1/audit-logs since 2026-05-27; the backend route never existed
and the dashboard silently 404'd every 'Open Audit Log' click.

This commit adds the missing HTTP surface and a UI upgrade:

Backend (dashcaddy-api/routes/audit-log.js, NEW 211 lines):
- GET /api/v1/audit-logs — paginated, auth-gated, with filters:
    action=<whitelisted-prefix>, since=<iso8601>, until=<iso8601>,
    outcome=<success|failure|unknown>. Limit capped at 500.
- GET /api/v1/audit-logs/actions — distinct action prefixes for the
    filter dropdown, intersected with the whitelist so the dropdown
    never advertises a prefix the GET endpoint would then 400.
- DELETE /api/v1/audit-logs — wipes the log, gated by
    {confirm:'CLEAR'} JSON body. Re-injects an audit.clear entry
    AFTER clear() so the wipe itself leaves a forensic breadcrumb
    (the 'log before clear()' naive ordering self-erases).

Wiring (src/app.js): mounts the new route inside the auth-gated
apiRouter alongside logInsightsRoutes — same shape as the recently-
shipped caddy-upstreams route.

Frontend (status/js/audit-log.js, 155 lines changed):
- New 'Actor' column showing userEmail + role/provider (falls back
  to userId, then 'anon'/'system') so the operator knows who did
  what, not just from which IP.
- Outcome filter (Any / Success / Failure).
- Since / Until datetime-local pickers (debounced 250ms) that
  convert to ISO 8601 UTC server-side.
- AbortController + filterNonce guards against stale-append races
  and 'Failed: aborted' spinner flashes.
- res.ok + data.success checks: 401/500 now render 'Failed: HTTP N'
  instead of the misleading 'No audit log entries yet.'
- Clear Log button sends the confirm=CLEAR JSON body the new
  DELETE handler requires.

Tests (__tests__/routes/audit-log.routes.test.js, NEW 437 lines):
20/20 passing. Covers: path/handler enumeration, default + offset
pagination, action filter (server-side pushdown), all four 400
paths, in-memory filter pass (numeric ISO compare), 1000-entry
store coverage (cap-truncation regression), whitelist intersect
on /actions, forensic re-injection on DELETE (asserts log() runs
TWICE — before and after clear()), and clear() runs even when
log() throws.

GLM-5.3 round-1 grade: C with 1 HIGH + 2 MEDIUM + 4 LOW. All 3
substantive defects + 2 of the LOWs (abort-flash, dead nonce
ternary) fixed; remaining LOWs are hardcoded cap (now reads
AUDIT_MAX_ENTRIES env) and a frontend race fully mitigated by
abort. Round-2 grade: B. Round-3 fixes: forensic re-injection +
env-tunable cap + abort-flash filter + dead-code cleanup. Self-
grade: A (re-grades B->A after fixes).

Full suite: 1885/1885 passing, 84 suites, 0 regressions.
Live verify: GET /api/v1/audit-logs → 401 (was 404 before this
commit). 1879 -> 1885 tests (+6 net, +regression tests).
2026-08-17 18:19:41 -07:00

211 lines
8.7 KiB
JavaScript

/**
* 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=<prefix>}. 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=<prefix>&since=<iso>&until=<iso>&outcome=<success|failure>
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 <user> at <ts>" — 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;
};