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:
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for AuditManager and the audit capture hook / routes."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
|
||||
|
||||
from audit_manager import AuditManager
|
||||
|
||||
|
||||
# ── manager fixture ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def audit(tmp_path):
|
||||
return AuditManager(data_dir=str(tmp_path / 'data'), config_dir=str(tmp_path / 'config'))
|
||||
|
||||
|
||||
def _lines(audit):
|
||||
with open(audit._audit_file, 'r', encoding='utf-8') as f:
|
||||
return [l for l in f.read().splitlines() if l.strip()]
|
||||
|
||||
|
||||
# ── record / schema ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_record_writes_one_jsonl_line(audit):
|
||||
entry = audit.record('admin', 'admin', '10.0.0.1', 'peer.create',
|
||||
'peer', 'bob', 'created', 'success', 201, 'POST', '/api/peers', 'req-1')
|
||||
lines = _lines(audit)
|
||||
assert len(lines) == 1
|
||||
parsed = json.loads(lines[0])
|
||||
for field in ('ts', 'actor', 'role', 'ip', 'action', 'target_type', 'target_id',
|
||||
'summary', 'result', 'status', 'method', 'path', 'request_id',
|
||||
'seq', 'prev_hash', 'hash'):
|
||||
assert field in parsed
|
||||
assert parsed['actor'] == 'admin'
|
||||
assert parsed['action'] == 'peer.create'
|
||||
assert parsed['ts'].endswith('Z') # UTC ISO
|
||||
|
||||
|
||||
def test_result_derived_from_status(audit):
|
||||
e = audit.record('a', 'admin', '', 'x', '', '', '', 'bogus', 500, 'POST', '/api/x', '')
|
||||
assert e['result'] == 'failure'
|
||||
e2 = audit.record('a', 'admin', '', 'x', '', '', '', 'bogus', 200, 'POST', '/api/x', '')
|
||||
assert e2['result'] == 'success'
|
||||
|
||||
|
||||
# ── redaction ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_summarize_keys_lists_names_only(audit):
|
||||
summary = AuditManager.summarize_keys(['network.dns_port', 'email.smtp_password', 'wireguard.private_key'])
|
||||
# KEY NAMES are present (they are names, not values)...
|
||||
assert 'dns_port' in summary
|
||||
assert 'smtp_password' in summary
|
||||
# ...but no actual value material
|
||||
assert 'changed:' in summary
|
||||
|
||||
|
||||
def test_secret_values_never_appear(audit):
|
||||
secret_b64 = 'A' * 60 + '=='
|
||||
bcrypt = '$2b$12$abcdefghijklmnopqrstuv'
|
||||
age = 'AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQ'
|
||||
e = audit.record('admin', 'admin', '', 'config.update', 'config', '',
|
||||
f'token={secret_b64} hash={bcrypt} key={age}', 'success', 200,
|
||||
'PUT', '/api/config', '')
|
||||
raw = _lines(audit)[0]
|
||||
assert secret_b64 not in raw
|
||||
assert bcrypt not in raw
|
||||
assert age not in raw
|
||||
assert 'REDACTED' in e['summary']
|
||||
|
||||
|
||||
# ── append-only ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_append_only_prior_unchanged(audit):
|
||||
audit.record('a', 'admin', '', 'one', '', '', 's1', 'success', 200, 'POST', '/api/a', '')
|
||||
first = _lines(audit)[0]
|
||||
audit.record('b', 'admin', '', 'two', '', '', 's2', 'success', 200, 'POST', '/api/b', '')
|
||||
lines = _lines(audit)
|
||||
assert len(lines) == 2
|
||||
assert lines[0] == first # prior line byte-for-byte unchanged
|
||||
assert json.loads(lines[1])['seq'] == 2
|
||||
|
||||
|
||||
# ── hash chain ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_hash_chain_links(audit):
|
||||
e1 = audit.record('a', 'admin', '', 'one', '', '', '', 'success', 200, 'POST', '/api/a', '')
|
||||
e2 = audit.record('b', 'admin', '', 'two', '', '', '', 'success', 200, 'POST', '/api/b', '')
|
||||
assert e1['prev_hash'] == ''
|
||||
assert e2['prev_hash'] == e1['hash']
|
||||
assert audit.verify_chain() == {'ok': True, 'broken_at_seq': None}
|
||||
|
||||
|
||||
def test_tamper_detected(audit):
|
||||
audit.record('a', 'admin', '', 'one', '', '', 'orig', 'success', 200, 'POST', '/api/a', '')
|
||||
audit.record('b', 'admin', '', 'two', '', '', 'orig2', 'success', 200, 'POST', '/api/b', '')
|
||||
lines = _lines(audit)
|
||||
tampered = json.loads(lines[0])
|
||||
tampered['summary'] = 'HACKED'
|
||||
lines[0] = json.dumps(tampered)
|
||||
with open(audit._audit_file, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(lines) + '\n')
|
||||
res = audit.verify_chain()
|
||||
assert res['ok'] is False
|
||||
assert res['broken_at_seq'] == 1
|
||||
|
||||
|
||||
def test_chain_can_be_disabled(tmp_path):
|
||||
a = AuditManager(data_dir=str(tmp_path / 'd'), config_dir=str(tmp_path / 'c'), tamper_chain=False)
|
||||
e = a.record('a', 'admin', '', 'one', '', '', '', 'success', 200, 'POST', '/api/a', '')
|
||||
assert e['hash'] == ''
|
||||
assert a.verify_chain().get('disabled') is True
|
||||
|
||||
|
||||
# ── rotation ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_rotation_rolls_and_chain_continues(tmp_path):
|
||||
a = AuditManager(data_dir=str(tmp_path / 'd'), config_dir=str(tmp_path / 'c'))
|
||||
a.MAX_FILE_SIZE = 2048 # tiny so a few records trigger rotation
|
||||
for i in range(60):
|
||||
a.record('admin', 'admin', '', f'act{i}', 'thing', str(i),
|
||||
'x' * 40, 'success', 200, 'POST', '/api/x', '')
|
||||
assert os.path.exists(a._audit_file + '.1'), 'rotation did not occur'
|
||||
# Chain spans live + rotated segments and stays intact across rotation.
|
||||
assert a.verify_chain() == {'ok': True, 'broken_at_seq': None}
|
||||
q = a.query({}, limit=1000)
|
||||
seqs = [e['seq'] for e in q['entries']]
|
||||
# Newest-first ordering preserved across segment boundaries.
|
||||
assert seqs == sorted(seqs, reverse=True)
|
||||
# The newest record (seq 60) is always retained; order is never lost.
|
||||
assert seqs[0] == 60
|
||||
# Retained seqs form a contiguous run ending at the newest (older entries
|
||||
# beyond BACKUP_COUNT segments are pruned, as designed).
|
||||
assert seqs == list(range(60, 60 - len(seqs), -1))
|
||||
|
||||
|
||||
# ── concurrency ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_concurrent_records_intact(audit):
|
||||
N = 50
|
||||
|
||||
def worker(i):
|
||||
audit.record('admin', 'admin', '', f'act{i}', 'thing', str(i),
|
||||
'', 'success', 200, 'POST', '/api/x', '')
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
lines = _lines(audit)
|
||||
assert len(lines) == N
|
||||
for l in lines:
|
||||
json.loads(l) # every line is valid JSON
|
||||
assert audit.verify_chain()['ok'] is True
|
||||
|
||||
|
||||
# ── filters + pagination ──────────────────────────────────────────────────────
|
||||
|
||||
def test_filters_and_pagination(audit):
|
||||
for i in range(10):
|
||||
audit.record('admin' if i % 2 == 0 else 'alice', 'admin', '',
|
||||
'peer.create' if i < 5 else 'peer.delete',
|
||||
'peer', f'p{i}', '', 'success' if i != 3 else 'failure',
|
||||
200, 'POST', '/api/peers', '')
|
||||
res = audit.query({'actor': 'alice'})
|
||||
assert all(e['actor'] == 'alice' for e in res['entries'])
|
||||
res = audit.query({'action': 'peer.delete'})
|
||||
assert res['total'] == 5
|
||||
res = audit.query({'result': 'failure'})
|
||||
assert res['total'] == 1
|
||||
page = audit.query({}, limit=3, offset=0)
|
||||
assert len(page['entries']) == 3
|
||||
assert page['total'] == 10
|
||||
assert page['next_offset'] == 3
|
||||
|
||||
|
||||
def test_export_csv(audit):
|
||||
audit.record('admin', 'admin', '1.2.3.4', 'peer.create', 'peer', 'bob',
|
||||
'created', 'success', 201, 'POST', '/api/peers', 'r1')
|
||||
csv = audit.export_csv({})
|
||||
lines = csv.strip().splitlines()
|
||||
assert lines[0].startswith('ts,actor,role,ip,action')
|
||||
assert 'peer.create' in csv
|
||||
assert 'bob' in csv
|
||||
|
||||
|
||||
def test_write_failure_does_not_raise(audit):
|
||||
with patch('os.open', side_effect=OSError('disk full')):
|
||||
result = audit.record('a', 'admin', '', 'x', '', '', '', 'success', 200, 'POST', '/api/x', '')
|
||||
assert result is None # swallowed, never raised
|
||||
Reference in New Issue
Block a user