feat: audit/change log — owner-visible record of who changed what
Unit Tests / test (push) Successful in 12m47s
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`),
|
||||
|
||||
Reference in New Issue
Block a user