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
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Tests for the audit after_request hook, auth-route audit calls, and audit API authz."""
import os
import sys
import json
from pathlib import Path
from unittest.mock import patch
import contextlib
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from app import app
from auth_manager import AuthManager
from audit_manager import AuditManager
def _make_auth_manager(tmp_path):
data_dir = str(tmp_path / 'data')
config_dir = str(tmp_path / 'config')
os.makedirs(data_dir, exist_ok=True)
os.makedirs(config_dir, exist_ok=True)
mgr = AuthManager(data_dir=data_dir, config_dir=config_dir)
mgr.create_user('admin', 'AdminPass123!', 'admin')
mgr.create_user('alice', 'AlicePass123!', 'peer')
return mgr
def _login(client, username, password):
return client.post('/api/auth/login',
data=json.dumps({'username': username, 'password': password}),
content_type='application/json')
@contextlib.contextmanager
def _client(auth_mgr, audit_mgr, login_as=None):
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
with patch('app.auth_manager', auth_mgr), \
patch('app.audit_manager', audit_mgr):
import auth_routes
with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True):
with app.test_client() as c:
if login_as == 'admin':
assert _login(c, 'admin', 'AdminPass123!').status_code == 200
elif login_as == 'peer':
assert _login(c, 'alice', 'AlicePass123!').status_code == 200
yield c
@pytest.fixture
def auth_mgr(tmp_path):
return _make_auth_manager(tmp_path)
@pytest.fixture
def audit_mgr(tmp_path):
return AuditManager(data_dir=str(tmp_path / 'auditdata'), config_dir=str(tmp_path / 'auditcfg'))
# ── after_request capture ─────────────────────────────────────────────────────
def test_post_peers_records_peer_create(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
with patch('app.peer_registry') as pr:
pr.add_peer.return_value = {'success': True, 'peer': {'name': 'bob'}}
c.post('/api/peers', json={'name': 'bob'})
res = audit_mgr.query({'action': 'peer.create'})
assert res['total'] >= 1
e = res['entries'][0]
assert e['target_type'] == 'peer'
assert e['method'] == 'POST'
assert e['actor'] == 'admin'
assert e['role'] == 'admin'
def test_4xx_records_failure(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
# missing body -> handler returns 400
c.post('/api/peers', json={})
res = audit_mgr.query({'action': 'peer.create'})
assert res['total'] >= 1
assert res['entries'][0]['result'] == 'failure'
def test_config_update_summary_lists_key_names_only(auth_mgr, audit_mgr):
# The summary is built from request-body key names regardless of the
# handler outcome, so we assert only on the recorded audit entry.
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.put('/api/config', json={'email': {'smtp_password': 'hunter2supersecret', 'smtp_port': 25}})
res = audit_mgr.query({'action': 'config.update'})
assert res['total'] >= 1
summary = res['entries'][0]['summary']
assert 'smtp_port' in summary
assert 'smtp_password' in summary # key NAME is allowed
assert 'hunter2supersecret' not in summary # value never recorded
def test_unmapped_mutating_endpoint_gets_generic_action(auth_mgr, audit_mgr):
# email.send_email is NOT in ROUTE_ACTION_MAP — it must still be recorded
# via the generic "<method>.<path>" fallback so nothing is invisible.
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.post('/api/email/send', json={})
entries = audit_mgr.query({})['entries']
match = [e for e in entries if e['path'] == '/api/email/send']
assert match, 'unmapped mutating endpoint was not audited'
assert match[0]['action'] == 'post./api/email/send'
assert match[0]['target_type'] == 'unknown'
# ── auth routes: never write password ─────────────────────────────────────────
def test_change_password_audited_without_value(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.post('/api/auth/change-password',
json={'old_password': 'AdminPass123!', 'new_password': 'BrandNewPass456!'})
res = audit_mgr.query({'action': 'user.password_change'})
assert res['total'] == 1
raw = json.dumps(res['entries'][0])
assert 'AdminPass123!' not in raw
assert 'BrandNewPass456!' not in raw
assert res['entries'][0]['summary'] == 'password changed'
def test_admin_reset_password_audited_without_value(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.post('/api/auth/admin/reset-password',
json={'username': 'alice', 'new_password': 'ResetPass789!'})
res = audit_mgr.query({'action': 'user.password_reset'})
assert res['total'] == 1
raw = json.dumps(res['entries'][0])
assert 'ResetPass789!' not in raw
assert 'alice' in res['entries'][0]['summary']
def test_auth_login_does_not_write_password(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr) as c:
_login(c, 'admin', 'AdminPass123!')
res = audit_mgr.query({})
for e in res['entries']:
assert 'AdminPass123!' not in json.dumps(e)
# ── audit API authz ───────────────────────────────────────────────────────────
def test_peer_forbidden_on_audit_list(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='peer') as c:
r = c.get('/api/audit')
assert r.status_code == 403
def test_admin_allowed_on_audit_list(auth_mgr, audit_mgr):
audit_mgr.record('admin', 'admin', '', 'peer.create', 'peer', 'bob', '',
'success', 201, 'POST', '/api/peers', '')
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
r = c.get('/api/audit')
assert r.status_code == 200
body = r.get_json()
assert body['total'] >= 1
assert 'entries' in body
def test_audit_verify_endpoint(auth_mgr, audit_mgr):
audit_mgr.record('admin', 'admin', '', 'x', '', '', '', 'success', 200, 'POST', '/api/x', '')
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
r = c.get('/api/audit/verify')
assert r.status_code == 200
assert r.get_json()['ok'] is True
def test_audit_export_csv(auth_mgr, audit_mgr):
audit_mgr.record('admin', 'admin', '', 'peer.create', 'peer', 'bob', '',
'success', 201, 'POST', '/api/peers', '')
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
r = c.get('/api/audit/export?format=csv')
assert r.status_code == 200
assert 'text/csv' in r.content_type
assert b'peer.create' in r.data
+198
View File
@@ -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
+2
View File
@@ -58,6 +58,7 @@ class _BackupBase(unittest.TestCase):
_write(d / 'api' / 'peer_service_credentials.json', '{}')
_write(d / 'api' / 'cell_links.json', '{"link": 1}')
_write(d / 'api' / 'ddns_token', 'tok123')
_write(d / 'api' / 'audit' / 'audit.log', '{"seq": 1, "action": "peer.create"}')
_write(d / 'wireguard' / 'keys' / 'server_private.key', 'PRIV')
_write(d / 'wireguard' / 'wg_confs' / 'wg0.conf', '[Interface]')
_write(d / 'api' / 'wireguard' / 'keys' / 'private.key', 'P2')
@@ -92,6 +93,7 @@ class TestBackupInclude(_BackupBase):
'data/api/peer_service_credentials.json',
'data/api/cell_links.json',
'data/api/ddns_token',
'data/api/audit/audit.log',
'data/wireguard/keys/server_private.key',
'data/wireguard/wg_confs/wg0.conf',
'data/api/wireguard/keys/private.key',