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:
+137
-18
@@ -1,17 +1,20 @@
|
||||
// ========== 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() {
|
||||
// Inject modal HTML
|
||||
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>
|
||||
<p class="modal-subtitle">
|
||||
Track all actions performed through the API.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
||||
<label class="text-muted-sm">Filter:</label>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<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;">
|
||||
<option value="">All Actions</option>
|
||||
<option value="">All</option>
|
||||
<option value="service">Services</option>
|
||||
<option value="container">Containers</option>
|
||||
<option value="caddy">Caddy</option>
|
||||
@@ -20,6 +23,16 @@
|
||||
<option value="config">Config</option>
|
||||
<option value="auth">Auth</option>
|
||||
</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>
|
||||
<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>
|
||||
@@ -45,26 +58,84 @@
|
||||
const refreshBtn = document.getElementById('audit-refresh-btn');
|
||||
const clearBtn = document.getElementById('audit-clear-btn');
|
||||
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 loadMoreBtn = document.getElementById('audit-load-more');
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
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;
|
||||
filterNonce++;
|
||||
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 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) {
|
||||
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';
|
||||
return;
|
||||
}
|
||||
@@ -72,20 +143,29 @@
|
||||
let html = '';
|
||||
if (!append) {
|
||||
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) {
|
||||
const ok = e.outcome === 'success';
|
||||
const actor = actorLabel(e);
|
||||
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-weight: 500;">${escapeHtml(e.action || '-')}</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>';
|
||||
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>';
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
// Append rows to existing table
|
||||
const table = container.querySelector('table');
|
||||
if (table) table.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
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
|
||||
container.querySelectorAll('.audit-row').forEach(row => {
|
||||
@@ -113,10 +194,34 @@
|
||||
});
|
||||
});
|
||||
} 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>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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', () => {
|
||||
modal?.classList.add('show');
|
||||
loadAudit(false);
|
||||
@@ -124,12 +229,26 @@
|
||||
wireModal(modal, cancelBtn);
|
||||
refreshBtn?.addEventListener('click', () => 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));
|
||||
|
||||
clearBtn?.addEventListener('click', async () => {
|
||||
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
|
||||
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();
|
||||
if (data.success) loadAudit(false);
|
||||
else showNotification('Error: ' + (data.error || 'Clear failed'), 'error');
|
||||
@@ -137,4 +256,4 @@
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user