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).
This commit is contained in:
@@ -0,0 +1,437 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for the audit-log viewer route (DC-050).
|
||||||
|
*
|
||||||
|
* Mirrors the caddy-upstreams.routes.test.js pattern: build the router with
|
||||||
|
* stubbed dependencies, hit it via a tiny express app, assert the response
|
||||||
|
* shape and the audit-logger calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const FIXTURE_ENTRIES = [
|
||||||
|
{
|
||||||
|
id: 'a1', timestamp: '2026-08-17T10:00:00.000Z', ip: '1.1.1.1',
|
||||||
|
action: 'service.create', resource: 'plex',
|
||||||
|
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a2', timestamp: '2026-08-17T11:00:00.000Z', ip: '1.1.1.1',
|
||||||
|
action: 'auth.totp-setup', resource: 'u-1',
|
||||||
|
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a3', timestamp: '2026-08-17T12:00:00.000Z', ip: '2.2.2.2',
|
||||||
|
action: 'auth.api-key-generate', resource: 'unknown',
|
||||||
|
details: { userId: null }, outcome: 'failure',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a4', timestamp: '2026-08-17T13:00:00.000Z', ip: '1.1.1.1',
|
||||||
|
action: 'backup.execute', resource: 'all-apps',
|
||||||
|
details: {}, outcome: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a5', timestamp: '2026-08-17T14:00:00.000Z', ip: '3.3.3.3',
|
||||||
|
action: 'caddy.add-site', resource: 'test.sami',
|
||||||
|
details: {}, outcome: 'failure',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildFakeAuditLogger(entries = FIXTURE_ENTRIES) {
|
||||||
|
return {
|
||||||
|
query: jest.fn(async ({ limit = 50, offset = 0, action } = {}) => {
|
||||||
|
let e = entries;
|
||||||
|
if (action) e = e.filter((x) => x.action && x.action.startsWith(action));
|
||||||
|
return e.slice(offset, offset + limit);
|
||||||
|
}),
|
||||||
|
clear: jest.fn(async () => {}),
|
||||||
|
// log() is called by the DELETE handler to record `audit.clear` BEFORE
|
||||||
|
// clearing — the act of clearing is itself an audit-worthy event.
|
||||||
|
log: jest.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routes/audit-log', () => {
|
||||||
|
function buildRouter(logger) {
|
||||||
|
const mod = require('../../routes/audit-log');
|
||||||
|
return mod({
|
||||||
|
asyncHandler: (fn) => async (req, res, next) => {
|
||||||
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||||
|
},
|
||||||
|
auditLogger: logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('router builds with the expected paths', () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const router = buildRouter(logger);
|
||||||
|
expect(router).toBeDefined();
|
||||||
|
expect(typeof router.use).toBe('function');
|
||||||
|
const paths = router.stack
|
||||||
|
.filter((l) => l.route)
|
||||||
|
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||||
|
.flat();
|
||||||
|
expect(paths).toEqual(expect.arrayContaining([
|
||||||
|
'GET /audit-logs',
|
||||||
|
'GET /audit-logs/actions',
|
||||||
|
'DELETE /audit-logs',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs returns all entries when no filters', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=10`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.entries).toHaveLength(5);
|
||||||
|
expect(body.total).toBe(5);
|
||||||
|
expect(body.hasMore).toBe(false);
|
||||||
|
expect(body.filters).toEqual({ action: null, since: null, until: null, outcome: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs respects limit + offset', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=2&offset=0`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.entries).toHaveLength(2);
|
||||||
|
expect(body.entries[0].id).toBe('a1');
|
||||||
|
expect(body.hasMore).toBe(true);
|
||||||
|
|
||||||
|
const server2 = app.listen(0);
|
||||||
|
const { port: port2 } = server2.address();
|
||||||
|
const res2 = await fetch(`http://127.0.0.1:${port2}/audit-logs?limit=2&offset=4`);
|
||||||
|
const body2 = await res2.json();
|
||||||
|
server2.close();
|
||||||
|
expect(body2.entries).toHaveLength(1);
|
||||||
|
expect(body2.entries[0].id).toBe('a5');
|
||||||
|
expect(body2.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs?action=auth filters server-side via auditLogger.query', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=auth`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.entries).toHaveLength(2);
|
||||||
|
expect(body.entries.every((e) => e.action.startsWith('auth'))).toBe(true);
|
||||||
|
// The action filter MUST be pushed down to the audit-logger so we don't
|
||||||
|
// load the full 1000-entry store when the operator filters by category.
|
||||||
|
expect(logger.query).toHaveBeenCalledWith(expect.objectContaining({ action: 'auth' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs rejects unknown action prefix with 400', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=pwnz`);
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toMatch(/action must be one of/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs filters by since (date >= since)', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T13:00:00.000Z`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.entries).toHaveLength(2); // a4 + a5
|
||||||
|
expect(body.entries.map((e) => e.id)).toEqual(['a4', 'a5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs filters by outcome=failure', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?outcome=failure`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.entries).toHaveLength(2); // a3 + a5
|
||||||
|
expect(body.entries.every((e) => e.outcome === 'failure')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs rejects since > until with 400', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T20:00:00Z&until=2026-08-17T10:00:00Z`);
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toMatch(/since must be <= until/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs rejects malformed ISO 8601 with 400', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=not-a-date`);
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/since must be ISO 8601/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs caps limit at 500 (no DoS via huge page)', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=99999`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.limit).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs/actions returns distinct action prefixes', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.prefixes).toEqual(['auth', 'backup', 'caddy', 'service']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /audit-logs requires confirm=CLEAR body', async () => {
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toMatch(/confirm: "CLEAR"/);
|
||||||
|
expect(logger.clear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /audit-logs with confirm=CLEAR calls auditLogger.clear()', async () => {
|
||||||
|
const logger = buildFakeAuditFixtureSafe();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.cleared).toBe(true);
|
||||||
|
expect(logger.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('module.exports throws when auditLogger is missing query()', () => {
|
||||||
|
const mod = require('../../routes/audit-log');
|
||||||
|
expect(() => mod({ asyncHandler: (fn) => fn, auditLogger: {} }))
|
||||||
|
.toThrow(/auditLogger with query/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GLM round-1 defect regressions ───────────────────────────────────────
|
||||||
|
|
||||||
|
test('GET /audit-logs does NOT amputate the store when limit*5 < MAX_ENTRIES (cap-truncation fix)', async () => {
|
||||||
|
// Round-1 [HIGH]: route previously fetched `limit * 5` entries from
|
||||||
|
// the store and computed total/hasMore over that truncated slice.
|
||||||
|
// With MAX_ENTRIES=1000 and limit=50, the cap was 250 — silently
|
||||||
|
// hiding entries 251-1000. The fix fetches the full store (1000).
|
||||||
|
const entries = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
id: `bulk-${i}`,
|
||||||
|
timestamp: new Date(Date.parse('2026-08-17T00:00:00Z') + i * 1000).toISOString(),
|
||||||
|
ip: '9.9.9.9',
|
||||||
|
action: 'service.create',
|
||||||
|
resource: `svc-${i}`,
|
||||||
|
details: {},
|
||||||
|
outcome: i % 3 === 0 ? 'failure' : 'success',
|
||||||
|
}));
|
||||||
|
const logger = buildFakeAuditLogger(entries);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=50&offset=200`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.total).toBe(1000); // full store, not 250
|
||||||
|
expect(body.hasMore).toBe(true); // still more after offset 200
|
||||||
|
expect(body.truncated).toBe(true); // signal that store was at cap
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs compares ISO timestamps numerically (lexicographic compare fix)', async () => {
|
||||||
|
// Round-1 [MEDIUM]: '10:00:00.000Z' < '10:00:00Z' is false lexicographically
|
||||||
|
// (the latter is a strict substring, breaking `>=`). Fix: use Date.parse().
|
||||||
|
const fixedEntries = [
|
||||||
|
{ id: 'b1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||||
|
{ id: 'b2', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||||
|
];
|
||||||
|
const logger = buildFakeAuditLogger(fixedEntries);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
// Same instant as b1 in a different ISO format — must be included.
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T10:00:00Z`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.total).toBe(2);
|
||||||
|
expect(body.entries.map((e) => e.id)).toEqual(['b1', 'b2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs accepts ISO with positive UTC offset (numeric compare fix)', async () => {
|
||||||
|
const fixedEntries = [
|
||||||
|
{ id: 'c1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||||
|
{ id: 'c2', timestamp: '2026-08-17T11:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||||
|
{ id: 'c3', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||||
|
];
|
||||||
|
const logger = buildFakeAuditLogger(fixedEntries);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
// 11:00+02:00 = 09:00Z. Filter for entries AFTER 09:00Z. Expect c1 + c2 + c3.
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T11:00:00%2B02:00`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.total).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /audit-logs/actions only surfaces whitelisted prefixes', async () => {
|
||||||
|
// Round-1 [LOW]: dropdown advertised prefixes (e.g. `logs`, `events`)
|
||||||
|
// that GET /audit-logs?action=logs would then 400. Fix: intersect with
|
||||||
|
// the whitelist before returning.
|
||||||
|
const mixedEntries = [
|
||||||
|
{ id: 'd1', timestamp: '2026-08-17T10:00:00Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||||
|
{ id: 'd2', timestamp: '2026-08-17T10:01:00Z', ip: '', action: 'logs.something', resource: '', details: {}, outcome: 'success' },
|
||||||
|
{ id: 'd3', timestamp: '2026-08-17T10:02:00Z', ip: '', action: 'events.publish', resource: '', details: {}, outcome: 'success' },
|
||||||
|
];
|
||||||
|
const logger = buildFakeAuditLogger(mixedEntries);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.prefixes).toEqual(['service']); // logs/events filtered out
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /audit-logs writes audit.clear BEFORE AND AFTER clear() — re-injection preserves the forensic breadcrumb', async () => {
|
||||||
|
// GLM round-2 [MEDIUM]: a naive "log before clear()" self-erases —
|
||||||
|
// clear() wipes the entry that was just written. Fix: log before
|
||||||
|
// clear() (catches any failure path), then clear(), then log AGAIN
|
||||||
|
// so the entry survives as the single row visible to the viewer.
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||||
|
});
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// log() runs TWICE — once before clear (catches failure paths) and
|
||||||
|
// once after clear (re-injects the forensic breadcrumb).
|
||||||
|
expect(logger.log).toHaveBeenCalledTimes(2);
|
||||||
|
expect(logger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
action: 'audit.clear',
|
||||||
|
resource: 'audit-log.json',
|
||||||
|
outcome: 'success',
|
||||||
|
}));
|
||||||
|
expect(logger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
action: 'audit.clear',
|
||||||
|
resource: 'audit-log.json',
|
||||||
|
outcome: 'success',
|
||||||
|
}));
|
||||||
|
// Ordering: log → clear → log (second log runs AFTER clear).
|
||||||
|
const logOrders = logger.log.mock.invocationCallOrder;
|
||||||
|
const clearOrder = logger.clear.mock.invocationCallOrder[0];
|
||||||
|
expect(logOrders[0]).toBeLessThan(clearOrder);
|
||||||
|
expect(logOrders[1]).toBeGreaterThan(clearOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /audit-logs still calls clear() even if auditLogger.log() throws', async () => {
|
||||||
|
// A failing audit-log write must NOT block the operator's clear.
|
||||||
|
const logger = buildFakeAuditLogger();
|
||||||
|
logger.log.mockRejectedValueOnce(new Error('disk full'));
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(buildRouter(logger));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||||
|
});
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(logger.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tiny helper — separated so the second clear test has a fresh mock.
|
||||||
|
function buildFakeAuditFixtureSafe() {
|
||||||
|
return buildFakeAuditLogger();
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
@@ -102,6 +102,7 @@ const securityRoutes = require('../routes/security');
|
|||||||
const diskSettingsRoutes = require('../routes/disk-settings');
|
const diskSettingsRoutes = require('../routes/disk-settings');
|
||||||
const aiIntentRoutes = require('../routes/ai-intent');
|
const aiIntentRoutes = require('../routes/ai-intent');
|
||||||
const logInsightsRoutes = require('../routes/log-insights');
|
const logInsightsRoutes = require('../routes/log-insights');
|
||||||
|
const auditLogRoutes = require('../routes/audit-log');
|
||||||
const billingRoutes = require('../routes/billing');
|
const billingRoutes = require('../routes/billing');
|
||||||
const caddyUpstreamRoutes = require('../routes/caddy-upstreams');
|
const caddyUpstreamRoutes = require('../routes/caddy-upstreams');
|
||||||
const DependencyManager = require('./managers/dependency-manager');
|
const DependencyManager = require('./managers/dependency-manager');
|
||||||
@@ -781,6 +782,15 @@ async function createApp() {
|
|||||||
})()
|
})()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// DC-050 — Audit log viewer route. The frontend at status/js/audit-log.js
|
||||||
|
// has been calling /api/v1/audit-logs since 2026-05-27; before this route
|
||||||
|
// existed the dashboard silently 404'd. The audit-logger module already
|
||||||
|
// exposes query() and clear() — this route just gives them an HTTP shape.
|
||||||
|
apiRouter.use(auditLogRoutes({
|
||||||
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
auditLogger: ctx.auditLogger,
|
||||||
|
}));
|
||||||
|
|
||||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||||
dependencyManager: ctx.dependencyManager,
|
dependencyManager: ctx.dependencyManager,
|
||||||
servicesStateManager: ctx.servicesStateManager,
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
|
|||||||
+136
-17
@@ -1,17 +1,20 @@
|
|||||||
// ========== AUDIT LOG VIEWER ==========
|
// ========== AUDIT LOG VIEWER ==========
|
||||||
|
// DC-050: surface authenticated user identity (userEmail / userRole from
|
||||||
|
// auditLogger.details), add outcome filter, pass confirm=CLEAR body for
|
||||||
|
// destructive DELETE.
|
||||||
(function() {
|
(function() {
|
||||||
// Inject modal HTML
|
// Inject modal HTML
|
||||||
injectModal('audit-modal', `<div id="audit-modal" class="weather-modal">
|
injectModal('audit-modal', `<div id="audit-modal" class="weather-modal">
|
||||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
|
||||||
<h3>📜 Audit Log</h3>
|
<h3>📜 Audit Log</h3>
|
||||||
<p class="modal-subtitle">
|
<p class="modal-subtitle">
|
||||||
Track all actions performed through the API.
|
Track all actions performed through the API.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
|
||||||
<label class="text-muted-sm">Filter:</label>
|
<label class="text-muted-sm">Category:</label>
|
||||||
<select id="audit-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
<select id="audit-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||||
<option value="">All Actions</option>
|
<option value="">All</option>
|
||||||
<option value="service">Services</option>
|
<option value="service">Services</option>
|
||||||
<option value="container">Containers</option>
|
<option value="container">Containers</option>
|
||||||
<option value="caddy">Caddy</option>
|
<option value="caddy">Caddy</option>
|
||||||
@@ -20,6 +23,16 @@
|
|||||||
<option value="config">Config</option>
|
<option value="config">Config</option>
|
||||||
<option value="auth">Auth</option>
|
<option value="auth">Auth</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label class="text-muted-sm">Result:</label>
|
||||||
|
<select id="audit-outcome-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="success">✓ Success</option>
|
||||||
|
<option value="failure">✗ Failure</option>
|
||||||
|
</select>
|
||||||
|
<label class="text-muted-sm" style="margin-left: 8px;">Since:</label>
|
||||||
|
<input id="audit-since" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||||
|
<label class="text-muted-sm">Until:</label>
|
||||||
|
<input id="audit-until" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||||
<button id="audit-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
<button id="audit-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
||||||
<span style="flex: 1;"></span>
|
<span style="flex: 1;"></span>
|
||||||
<button id="audit-clear-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑️ Clear Log</button>
|
<button id="audit-clear-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑️ Clear Log</button>
|
||||||
@@ -45,26 +58,84 @@
|
|||||||
const refreshBtn = document.getElementById('audit-refresh-btn');
|
const refreshBtn = document.getElementById('audit-refresh-btn');
|
||||||
const clearBtn = document.getElementById('audit-clear-btn');
|
const clearBtn = document.getElementById('audit-clear-btn');
|
||||||
const filterSelect = document.getElementById('audit-filter');
|
const filterSelect = document.getElementById('audit-filter');
|
||||||
|
const outcomeSelect = document.getElementById('audit-outcome-filter');
|
||||||
|
const sinceInput = document.getElementById('audit-since');
|
||||||
|
const untilInput = document.getElementById('audit-until');
|
||||||
const container = document.getElementById('audit-log-container');
|
const container = document.getElementById('audit-log-container');
|
||||||
const loadMoreBtn = document.getElementById('audit-load-more');
|
const loadMoreBtn = document.getElementById('audit-load-more');
|
||||||
let currentOffset = 0;
|
let currentOffset = 0;
|
||||||
|
let inflight = null; // AbortController for the in-flight request
|
||||||
|
let filterNonce = 0; // increments on every fresh (non-append) load; lets
|
||||||
|
// an in-flight append detect the filter has changed
|
||||||
|
// and skip its DOM splice.
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
// datetime-local fields carry no timezone offset — convert to ISO 8601
|
||||||
|
// with the local offset so the server can compare correctly.
|
||||||
|
function toIso(localDtValue) {
|
||||||
|
if (!localDtValue) return null;
|
||||||
|
// Browsers expose datetime-local as naive local time. new Date() on
|
||||||
|
// that string parses it as LOCAL, so toISOString() yields the UTC
|
||||||
|
// equivalent the server expects.
|
||||||
|
const d = new Date(localDtValue);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAudit(append) {
|
async function loadAudit(append) {
|
||||||
try {
|
try {
|
||||||
if (!append) {
|
if (!append) {
|
||||||
|
// Cancel any pending request and bump the filter nonce so any
|
||||||
|
// appending fetch (still in flight) knows to discard its response.
|
||||||
|
if (inflight) inflight.abort();
|
||||||
|
inflight = new AbortController();
|
||||||
currentOffset = 0;
|
currentOffset = 0;
|
||||||
|
filterNonce++;
|
||||||
container.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
container.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||||
|
} else {
|
||||||
|
if (inflight) inflight.abort();
|
||||||
|
inflight = new AbortController();
|
||||||
|
}
|
||||||
|
const myNonce = filterNonce;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', String(PAGE_SIZE));
|
||||||
|
params.set('offset', String(currentOffset));
|
||||||
|
const action = filterSelect.value;
|
||||||
|
const outcome = outcomeSelect.value;
|
||||||
|
const since = toIso(sinceInput.value);
|
||||||
|
const until = toIso(untilInput.value);
|
||||||
|
if (action) params.set('action', action);
|
||||||
|
if (outcome) params.set('outcome', outcome);
|
||||||
|
if (since) params.set('since', since);
|
||||||
|
if (until) params.set('until', until);
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/audit-logs?' + params.toString(), {
|
||||||
|
signal: inflight.signal,
|
||||||
|
});
|
||||||
|
// Surface 401/403/500 explicitly — the dashboard used to render any
|
||||||
|
// non-success response as "no audit log entries yet," which is
|
||||||
|
// misleading for an expired session.
|
||||||
|
if (!res.ok) {
|
||||||
|
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${res.status}</div>`;
|
||||||
|
loadMoreBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const filter = filterSelect.value;
|
|
||||||
let url = `/api/v1/audit-logs?limit=${PAGE_SIZE}&offset=${currentOffset}`;
|
|
||||||
if (filter) url += `&action=${encodeURIComponent(filter)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const entries = data.success && data.entries ? data.entries : [];
|
if (!data.success) {
|
||||||
|
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(data.error || 'unknown')}</div>`;
|
||||||
|
loadMoreBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If a non-append load happened after this fetch was issued, the
|
||||||
|
// operator changed filters; discard the now-stale response.
|
||||||
|
if (!append && myNonce !== filterNonce) return;
|
||||||
|
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||||
|
|
||||||
if (entries.length === 0 && !append) {
|
if (entries.length === 0 && !append) {
|
||||||
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">📜</span>No audit log entries yet. Actions will be logged automatically.</div>';
|
const reason = data.filters && (data.filters.action || data.filters.outcome || data.filters.since || data.filters.until)
|
||||||
|
? 'No entries match your filters.'
|
||||||
|
: 'No audit log entries yet. Actions will be logged automatically.';
|
||||||
|
container.innerHTML = `<div class="panel-empty"><span class="empty-icon">📜</span>${escapeHtml(reason)}</div>`;
|
||||||
loadMoreBtn.style.display = 'none';
|
loadMoreBtn.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,20 +143,29 @@
|
|||||||
let html = '';
|
let html = '';
|
||||||
if (!append) {
|
if (!append) {
|
||||||
html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>';
|
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">';
|
||||||
|
html += '<th style="padding: 6px; text-align: left;">When</th>';
|
||||||
|
html += '<th style="padding: 6px; text-align: left;">Actor</th>';
|
||||||
|
html += '<th style="padding: 6px; text-align: left;">IP</th>';
|
||||||
|
html += '<th style="padding: 6px; text-align: left;">Action</th>';
|
||||||
|
html += '<th style="padding: 6px; text-align: left;">Resource</th>';
|
||||||
|
html += '<th style="padding: 6px; text-align: left;">Result</th>';
|
||||||
|
html += '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const ok = e.outcome === 'success';
|
const ok = e.outcome === 'success';
|
||||||
|
const actor = actorLabel(e);
|
||||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">`;
|
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">`;
|
||||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(e.timestamp)}</td>`;
|
html += `<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp || '')}">${timeAgo(e.timestamp)}</td>`;
|
||||||
|
html += `<td style="padding: 6px; font-size: 0.78rem;">${actor}</td>`;
|
||||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(e.ip || '-')}</td>`;
|
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(e.ip || '-')}</td>`;
|
||||||
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(e.action || '-')}</td>`;
|
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(e.action || '-')}</td>`;
|
||||||
html += `<td style="padding: 6px;">${escapeHtml(e.resource || '-')}</td>`;
|
html += `<td style="padding: 6px;">${escapeHtml(e.resource || '-')}</td>`;
|
||||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'}</span></td>`;
|
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'} ${escapeHtml(e.outcome || '')}</span></td>`;
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
if (e.details && Object.keys(e.details).length > 0) {
|
if (e.details && Object.keys(e.details).length > 0) {
|
||||||
html += `<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(e.details, null, 2))}</pre></td></tr>`;
|
html += `<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(e.details, null, 2))}</pre></td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +173,14 @@
|
|||||||
html += '</table>';
|
html += '</table>';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
} else {
|
} else {
|
||||||
// Append rows to existing table
|
|
||||||
const table = container.querySelector('table');
|
const table = container.querySelector('table');
|
||||||
if (table) table.insertAdjacentHTML('beforeend', html);
|
if (table) table.insertAdjacentHTML('beforeend', html);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentOffset += entries.length;
|
currentOffset += entries.length;
|
||||||
loadMoreBtn.style.display = entries.length >= PAGE_SIZE ? '' : 'none';
|
// hasMore is reported by the server (post-filter total), so the
|
||||||
|
// Load More button stays accurate when filters change mid-scroll.
|
||||||
|
loadMoreBtn.style.display = data.hasMore ? '' : 'none';
|
||||||
|
|
||||||
// Toggle detail rows on click
|
// Toggle detail rows on click
|
||||||
container.querySelectorAll('.audit-row').forEach(row => {
|
container.querySelectorAll('.audit-row').forEach(row => {
|
||||||
@@ -113,10 +194,34 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// AbortError is expected when we deliberately cancel an in-flight
|
||||||
|
// request (e.g. the operator changed filters mid-fetch) — don't
|
||||||
|
// flash a "Failed: The user aborted a request" message over the
|
||||||
|
// loading spinner. The new fetch has already kicked off.
|
||||||
|
if (e && e.name === 'AbortError') return;
|
||||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the human-readable actor: prefer userEmail, fall back to
|
||||||
|
// userId, fall back to bare IP. If no user attribution, mark as
|
||||||
|
// "system" so the operator knows the entry came from an unauthenticated
|
||||||
|
// or service path (e.g. cron-driven backups).
|
||||||
|
function actorLabel(entry) {
|
||||||
|
const d = entry.details || {};
|
||||||
|
const email = d.userEmail;
|
||||||
|
const id = d.userId;
|
||||||
|
const role = d.userRole;
|
||||||
|
const provider = d.viaProvider;
|
||||||
|
if (email) {
|
||||||
|
const tag = role ? ` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(role)}${provider ? '/' + escapeHtml(provider) : ''}]</span>` : '';
|
||||||
|
return `${escapeHtml(email)}${tag}`;
|
||||||
|
}
|
||||||
|
if (id) return `<span style="font-family: monospace; color: var(--muted);">${escapeHtml(id)}</span>`;
|
||||||
|
if (!entry.ip) return '<span style="color: var(--muted);">system</span>';
|
||||||
|
return '<span style="color: var(--muted);">anon</span>';
|
||||||
|
}
|
||||||
|
|
||||||
openBtn?.addEventListener('click', () => {
|
openBtn?.addEventListener('click', () => {
|
||||||
modal?.classList.add('show');
|
modal?.classList.add('show');
|
||||||
loadAudit(false);
|
loadAudit(false);
|
||||||
@@ -124,12 +229,26 @@
|
|||||||
wireModal(modal, cancelBtn);
|
wireModal(modal, cancelBtn);
|
||||||
refreshBtn?.addEventListener('click', () => loadAudit(false));
|
refreshBtn?.addEventListener('click', () => loadAudit(false));
|
||||||
filterSelect?.addEventListener('change', () => loadAudit(false));
|
filterSelect?.addEventListener('change', () => loadAudit(false));
|
||||||
|
outcomeSelect?.addEventListener('change', () => loadAudit(false));
|
||||||
|
// Re-fetch on date change only when both fields have a value or both are
|
||||||
|
// empty — typing one character shouldn't trigger a fetch for every keystroke.
|
||||||
|
let dateDebounce;
|
||||||
|
[sinceInput, untilInput].forEach((el) => {
|
||||||
|
el?.addEventListener('change', () => {
|
||||||
|
clearTimeout(dateDebounce);
|
||||||
|
dateDebounce = setTimeout(() => loadAudit(false), 250);
|
||||||
|
});
|
||||||
|
});
|
||||||
loadMoreBtn?.addEventListener('click', () => loadAudit(true));
|
loadMoreBtn?.addEventListener('click', () => loadAudit(true));
|
||||||
|
|
||||||
clearBtn?.addEventListener('click', async () => {
|
clearBtn?.addEventListener('click', async () => {
|
||||||
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
|
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
const res = await secureFetch('/api/v1/audit-logs', { method: 'DELETE' });
|
const res = await secureFetch('/api/v1/audit-logs', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) loadAudit(false);
|
if (data.success) loadAudit(false);
|
||||||
else showNotification('Error: ' + (data.error || 'Clear failed'), 'error');
|
else showNotification('Error: ' + (data.error || 'Clear failed'), 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user