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).
211 lines
8.7 KiB
JavaScript
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;
|
|
}; |