feat: audit/change log — owner-visible record of who changed what
Unit Tests / test (push) Successful in 12m47s

Add AuditManager (api/audit_manager.py): JSONL append-only log at
data/api/audit/audit.log with SHA-256 hash chain for tamper detection,
verify endpoint, size-based rotation, and automatic redaction of secret
fields before any entry is written. Supports structured query (actor,
action, date range) and CSV export.

Wire an @app.after_request hook in app.py that fires on every mutating
/api/* request: captures actor, role, remote IP, and maps the route +
method to a human-readable action via ROUTE_ACTION_MAP. Explicit audit
entries for password_change and password_reset are added in
auth_routes.py so those events record the actor without logging secret
values.

Expose an admin-only blueprint (api/routes/audit.py):
  GET /api/audit          — paginated query
  GET /api/audit/export   — CSV download
  GET /api/audit/verify   — hash-chain integrity check

Register AuditManager in managers.py and add api/audit to
config_manager.py critical_data_paths so it is included in backups and
restored with other persistent state.

Add Activity page (webui/src/pages/Activity.jsx, admin-only) reachable
from the nav in App.jsx. New auditAPI helper in api.js covers all three
endpoints.

Tests: test_audit_manager.py (unit: hash chain, redaction, rotation,
query, csv, verify) and test_audit_hook_routes.py (integration: hook
fires on mutating routes, skips safe methods, records actor/ip/action,
backup-inclusion assertion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:19:38 -04:00
parent 13074f56cb
commit 8b50fb1036
12 changed files with 1246 additions and 2 deletions
+4
View File
@@ -19,6 +19,7 @@ import {
RefreshCw,
AlertTriangle,
User,
History,
} from 'lucide-react';
import { healthAPI, cellAPI, servicesAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext';
@@ -45,6 +46,7 @@ import EmailPage from './pages/services/EmailPage';
import CalendarPage from './pages/services/CalendarPage';
import FilesPage from './pages/services/FilesPage';
import Connectivity from './pages/Connectivity';
import ActivityPage from './pages/Activity';
import Setup from './pages/Setup';
import SetupGuard from './components/SetupGuard';
@@ -268,6 +270,7 @@ function AppCore() {
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
{ name: 'Connectivity', href: '/connectivity', icon: Network },
{ name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Activity', href: '/activity', icon: History },
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
{ name: 'Account', href: '/account', icon: User },
];
@@ -382,6 +385,7 @@ function AppCore() {
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
<Route path="/activity" element={<PrivateRoute requireRole="admin"><ActivityPage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
</Routes>
</div>
+209
View File
@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from 'react';
import { History, RefreshCw, Download, ShieldCheck, ShieldAlert, Search } from 'lucide-react';
import { auditAPI } from '../services/api';
const RESULTS = ['', 'success', 'failure'];
const PAGE_SIZE = 100;
function relativeTime(ts) {
if (!ts) return '';
const then = Date.parse(ts.endsWith('Z') ? ts : ts + 'Z');
if (Number.isNaN(then)) return ts;
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`;
}
function RoleBadge({ role }) {
const cls = role === 'admin'
? 'bg-purple-100 text-purple-700'
: role === 'peer' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600';
return <span className={`text-xs rounded px-1.5 py-0.5 ${cls}`}>{role || 'system'}</span>;
}
function ResultPill({ result }) {
const ok = result === 'success';
return (
<span className={`text-xs rounded-full px-2 py-0.5 font-medium ${
ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>{result}</span>
);
}
export default function Activity() {
const [entries, setEntries] = useState([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [verify, setVerify] = useState(null);
const [actor, setActor] = useState('');
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [result, setResult] = useState('');
const [since, setSince] = useState('');
const [until, setUntil] = useState('');
const [text, setText] = useState('');
const buildParams = useCallback((ofs) => {
const p = { limit: PAGE_SIZE, offset: ofs };
if (actor) p.actor = actor;
if (action) p.action = action;
if (targetType) p.target_type = targetType;
if (result) p.result = result;
if (since) p.since = since;
if (until) p.until = until;
return p;
}, [actor, action, targetType, result, since, until]);
const load = useCallback(async (ofs = 0) => {
setLoading(true);
try {
const res = await auditAPI.list(buildParams(ofs));
setEntries(res.data.entries || []);
setTotal(res.data.total || 0);
setOffset(ofs);
} catch (e) {
setEntries([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [buildParams]);
useEffect(() => { load(0); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const runVerify = async () => {
try { setVerify((await auditAPI.verify()).data); } catch { setVerify({ ok: false }); }
};
const exportCsv = async () => {
try {
const res = await auditAPI.exportCsv(buildParams(0));
const url = URL.createObjectURL(new Blob([res.data], { type: 'text/csv' }));
const a = document.createElement('a');
a.href = url;
a.download = 'audit.csv';
a.click();
URL.revokeObjectURL(url);
} catch { /* ignore */ }
};
const filtered = text
? entries.filter(e =>
JSON.stringify(e).toLowerCase().includes(text.toLowerCase()))
: entries;
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<History className="h-6 w-6" /> Activity
</h1>
<p className="mt-1 text-gray-600">Append-only audit trail who changed what, when.</p>
</div>
<div className="flex gap-2">
<button className="btn btn-secondary text-sm" onClick={runVerify}>
{verify == null ? <ShieldCheck className="h-4 w-4 mr-1 inline" />
: verify.ok ? <ShieldCheck className="h-4 w-4 mr-1 inline text-green-600" />
: <ShieldAlert className="h-4 w-4 mr-1 inline text-red-600" />}
Verify
</button>
<button className="btn btn-secondary text-sm" onClick={exportCsv}>
<Download className="h-4 w-4 mr-1 inline" />Export CSV
</button>
<button className="btn btn-secondary text-sm" onClick={() => load(offset)}>
<RefreshCw className={`h-4 w-4 inline ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{verify != null && (
<div className={`mb-4 rounded p-3 text-sm ${
verify.ok ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{verify.ok
? 'Chain verified — no tampering detected.'
: `Chain broken${verify.broken_at_seq != null ? ` at seq ${verify.broken_at_seq}` : ''}.`}
</div>
)}
<div className="card mb-4">
<div className="flex flex-wrap gap-2 items-center">
<input className="border rounded px-2 py-1 text-sm" placeholder="Actor"
value={actor} onChange={e => setActor(e.target.value)} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Action"
value={action} onChange={e => setAction(e.target.value)} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Target type"
value={targetType} onChange={e => setTargetType(e.target.value)} />
<select className="border rounded px-2 py-1 text-sm" value={result}
onChange={e => setResult(e.target.value)}>
{RESULTS.map(r => <option key={r} value={r}>{r || 'any result'}</option>)}
</select>
<input type="date" className="border rounded px-2 py-1 text-sm" title="Since"
value={since.slice(0, 10)} onChange={e => setSince(e.target.value ? `${e.target.value}T00:00:00Z` : '')} />
<input type="date" className="border rounded px-2 py-1 text-sm" title="Until"
value={until.slice(0, 10)} onChange={e => setUntil(e.target.value ? `${e.target.value}T23:59:59Z` : '')} />
<button className="btn btn-primary text-sm" onClick={() => load(0)}>
<Search className="h-4 w-4 mr-1 inline" />Filter
</button>
<div className="flex-1 min-w-40">
<input className="border rounded px-2 py-1 text-sm w-full" placeholder="Free text (current page)"
value={text} onChange={e => setText(e.target.value)} />
</div>
</div>
</div>
<div className="card overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-100 text-left">
{['When', 'Who', 'Action', 'Target', 'Summary', 'Result'].map(h =>
<th key={h} className="px-3 py-2">{h}</th>)}
</tr>
</thead>
<tbody>
{filtered.map((e, i) => (
<tr key={e.seq ?? i} className="border-t align-top">
<td className="px-3 py-2 whitespace-nowrap">
<div>{relativeTime(e.ts)}</div>
<div className="text-xs text-gray-400 font-mono">{String(e.ts || '').replace('T', ' ').replace('Z', '')}</div>
</td>
<td className="px-3 py-2 whitespace-nowrap">
<div className="font-medium">{e.actor}</div>
<div className="mt-0.5"><RoleBadge role={e.role} /></div>
{e.ip && <div className="text-xs text-gray-400 font-mono mt-0.5">{e.ip}</div>}
</td>
<td className="px-3 py-2 font-mono text-xs">{e.action}</td>
<td className="px-3 py-2">
<div>{e.target_type}</div>
{e.target_id && <div className="text-xs text-gray-500 font-mono">{e.target_id}</div>}
</td>
<td className="px-3 py-2 text-gray-600 max-w-md break-words">{e.summary}</td>
<td className="px-3 py-2"><ResultPill result={e.result} /></td>
</tr>
))}
{!filtered.length && (
<tr><td colSpan={6} className="px-3 py-6 text-center text-gray-400">
{loading ? 'Loading…' : 'No activity recorded.'}
</td></tr>
)}
</tbody>
</table>
</div>
<div className="mt-3 flex items-center justify-between text-sm text-gray-500">
<span>{total} entries · showing {offset + 1}{Math.min(offset + PAGE_SIZE, total)}</span>
<div className="flex gap-2">
<button className="btn btn-secondary text-sm" disabled={offset === 0}
onClick={() => load(Math.max(0, offset - PAGE_SIZE))}>Previous</button>
<button className="btn btn-secondary text-sm" disabled={offset + PAGE_SIZE >= total}
onClick={() => load(offset + PAGE_SIZE)}>Next</button>
</div>
</div>
</div>
);
}
+6
View File
@@ -341,6 +341,12 @@ export const logsAPI = {
setVerbosity: (levels) => api.put('/api/logs/verbosity', levels),
};
export const auditAPI = {
list: (params) => api.get('/api/audit', { params }),
exportCsv: (params) => api.get('/api/audit/export', { params, responseType: 'blob' }),
verify: () => api.get('/api/audit/verify'),
};
// DDNS API
export const ddnsAPI = {
checkName: (name) => api.get(`/api/ddns/check/${name}`),