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
+208
View File
@@ -47,6 +47,7 @@ from managers import (
service_registry,
service_composer,
account_manager,
audit_manager,
firewall_manager, EventType,
)
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
@@ -318,6 +319,210 @@ def log_request(response):
logger.info(f"{ctx.get('method')} {ctx.get('path')} {ctx.get('status')}")
return response
# ── Audit trail ─────────────────────────────────────────────────────────────
# Mutating endpoints that must NOT be audited: read-shaped POSTs (searches,
# exports, port checks, history clears) and namespaces handled elsewhere.
_NO_AUDIT_ENDPOINTS = frozenset({
# Read-shaped POSTs / diagnostics — not state changes worth auditing.
'services.search_logs',
'services.export_logs',
'services.rotate_logs',
'wireguard.check_wireguard_port',
'wireguard.test_wireguard_connectivity',
'wireguard.get_peer_config',
'wireguard.get_peer_status',
'wireguard.refresh_external_ip',
'network.test_network',
'routing.test_routing_connectivity',
'clear_health_history',
'peers.ip_update',
})
# Map (METHOD, endpoint) -> (action, target_type, target_id_view_arg).
# target_id_view_arg names a view_arg used as the target id, or None for a
# resource-level action. Endpoint is request.url_rule.endpoint
# ('<blueprint>.<func>' for blueprint routes, '<func>' for app routes).
ROUTE_ACTION_MAP = {
# config
('PUT', 'config.update_config'): ('config.update', 'config', None),
('POST', 'config.apply_pending_config'): ('config.apply', 'config', None),
('DELETE', 'config.cancel_pending_config'): ('config.cancel_pending', 'config', None),
('POST', 'config.import_config'): ('config.import', 'config', None),
('POST', 'config.create_config_backup'): ('backup.create', 'backup', None),
('POST', 'config.restore_config'): ('backup.restore', 'backup', 'backup_id'),
('POST', 'config.upload_backup'): ('backup.upload', 'backup', None),
('DELETE', 'config.delete_config_backup'): ('backup.delete', 'backup', 'backup_id'),
# ddns
('PUT', 'config.update_ddns_config'): ('ddns.update', 'ddns', None),
('POST', 'config.ddns_register'): ('ddns.register', 'ddns', None),
('POST', 'config.ddns_sync_records'): ('ddns.sync', 'ddns', None),
# peers
('POST', 'peers.add_peer'): ('peer.create', 'peer', None),
('PUT', 'peers.update_peer'): ('peer.update', 'peer', 'peer_name'),
('PUT', 'peers.set_peer_route_via'): ('peer.route_via', 'peer', 'peer_name'),
('DELETE', 'peers.remove_peer'): ('peer.delete', 'peer', 'peer_name'),
('POST', 'peers.register_peer'): ('peer.register', 'peer', None),
('DELETE', 'peers.unregister_peer'): ('peer.unregister', 'peer', 'peer_name'),
('PUT', 'peers.update_peer_ip_registry'): ('peer.update_ip', 'peer', 'peer_name'),
('POST', 'peers.clear_peer_reinstall'): ('peer.clear_reinstall', 'peer', 'peer_name'),
# wireguard
('POST', 'wireguard.generate_peer_keys'): ('wireguard.peer_keys', 'wireguard', None),
('POST', 'wireguard.add_wireguard_peer'): ('wireguard.peer_add', 'wireguard', None),
('DELETE', 'wireguard.remove_wireguard_peer'): ('wireguard.peer_remove', 'wireguard', None),
('PUT', 'wireguard.update_peer_ip'): ('wireguard.peer_ip', 'wireguard', None),
('POST', 'wireguard.setup_network'): ('wireguard.network_setup', 'wireguard', None),
('PUT', 'wireguard.set_wireguard_endpoint'): ('wireguard.endpoint', 'wireguard', None),
('POST', 'wireguard.apply_wireguard_enforcement'): ('wireguard.apply_enforcement', 'wireguard', None),
# services (catalog + bus)
('POST', 'services.restart_service_containers'): ('service.restart', 'service', 'service_id'),
('POST', 'services.reconfigure_service'): ('service.reconfigure', 'service', 'service_id'),
('POST', 'services.provision_service_account'): ('account.create', 'account', 'service_id'),
('DELETE', 'services.deprovision_service_account'): ('account.delete', 'account', 'service_id'),
('POST', 'services.start_service'): ('service.start', 'service', 'service_name'),
('POST', 'services.stop_service'): ('service.stop', 'service', 'service_name'),
('POST', 'services.restart_service'): ('service.restart', 'service', 'service_name'),
# service store
('POST', 'service_store.install_service'): ('service.install', 'service', 'service_id'),
('DELETE', 'service_store.remove_service'): ('service.remove', 'service', 'service_id'),
('POST', 'service_store.refresh_index'): ('service.store_refresh', 'service', None),
# built-in service accounts (email / calendar / files)
('POST', 'email.create_email_user'): ('account.create', 'account', None),
('DELETE', 'email.delete_email_user'): ('account.delete', 'account', 'username'),
('POST', 'calendar.create_calendar_user'): ('account.create', 'account', None),
('DELETE', 'calendar.delete_calendar_user'): ('account.delete', 'account', 'username'),
('POST', 'files.create_file_user'): ('account.create', 'account', None),
('DELETE', 'files.delete_file_user'): ('account.delete', 'account', 'username'),
# vault / certs / secrets / trust
('POST', 'vault.generate_certificate'): ('vault.cert_issue', 'certificate', None),
('DELETE', 'vault.revoke_certificate'): ('vault.cert_revoke', 'certificate', 'common_name'),
('POST', 'vault.store_secret'): ('vault.secret_store', 'secret', None),
('DELETE', 'vault.delete_secret'): ('vault.secret_delete', 'secret', 'name'),
('POST', 'vault.add_trusted_key'): ('vault.trust_key_add', 'trust', None),
('DELETE', 'vault.remove_trusted_key'): ('vault.trust_key_remove', 'trust', 'name'),
# caddy
('POST', 'caddy_cert_renew'): ('caddy.cert_renew', 'caddy', None),
('POST', 'caddy_upload_custom_cert'): ('caddy.custom_cert', 'caddy', None),
# connectivity
('POST', 'connectivity_upload_wireguard'): ('connection.exit_wireguard', 'connection', None),
('POST', 'connectivity_upload_openvpn'): ('connection.exit_openvpn', 'connection', None),
('POST', 'connectivity_configure_sshuttle'): ('connection.exit_sshuttle', 'connection', None),
('POST', 'connectivity_configure_proxy'): ('connection.exit_proxy', 'connection', None),
('PUT', 'connectivity_set_peer_exit'): ('connection.peer_exit_set', 'peer', 'peer_name'),
# egress
('PUT', 'egress_set_service_exit'): ('egress.service_exit_set', 'service', 'service_id'),
# cells
('POST', 'cells.add_cell_connection'): ('cell.create', 'cell', None),
('DELETE', 'cells.remove_cell_connection'): ('cell.delete', 'cell', 'cell_name'),
('PUT', 'cells.update_cell_permissions'): ('cell.permissions_set', 'cell', 'cell_name'),
('PUT', 'cells.set_exit_offer'): ('cell.exit_offer', 'cell', 'cell_name'),
# network / dns
('POST', 'network.add_dns_record'): ('network.dns_record_add', 'dns', None),
('DELETE', 'network.remove_dns_record'): ('network.dns_record_remove', 'dns', None),
# routing
('POST', 'routing.setup_routing'): ('network.routing_setup', 'routing', None),
('POST', 'routing.add_nat_rule'): ('network.nat_add', 'routing', None),
('DELETE', 'routing.remove_nat_rule'): ('network.nat_remove', 'routing', 'rule_id'),
('POST', 'routing.add_peer_route'): ('network.peer_route_add', 'routing', None),
('DELETE', 'routing.remove_peer_route'): ('network.peer_route_remove', 'routing', 'peer_name'),
('POST', 'routing.add_firewall_rule'): ('network.firewall_add', 'routing', None),
('DELETE', 'routing.remove_firewall_rule'): ('network.firewall_remove', 'routing', 'rule_id'),
('POST', 'routing.add_exit_node'): ('network.exit_node_add', 'routing', None),
('POST', 'routing.add_bridge_route'): ('network.bridge_add', 'routing', None),
('POST', 'routing.add_split_route'): ('network.split_add', 'routing', None),
# containers
('POST', 'containers.create_container'): ('container.create', 'container', None),
('DELETE', 'containers.remove_container'): ('container.remove', 'container', 'name'),
('POST', 'containers.restart_container'): ('container.restart', 'container', 'name'),
('POST', 'containers.start_container'): ('container.start', 'container', 'name'),
('POST', 'containers.stop_container'): ('container.stop', 'container', 'name'),
}
def _audit_actor_ip():
"""Derive (actor, role, ip) for the current request, mirroring is_local_request's
trust model: the last X-Forwarded-For entry (appended by Caddy) over remote_addr."""
actor = session.get('username', 'anonymous')
role = session.get('role', 'system')
ip = request.remote_addr or ''
xff = request.headers.get('X-Forwarded-For', '')
if xff:
last = xff.split(',')[-1].strip()
if last:
ip = last
return actor, role, ip
def _audit_map_action(method, endpoint, view_args, path):
"""Resolve (action, target_type, target_id) for a mutating request."""
spec = ROUTE_ACTION_MAP.get((method, endpoint))
view_args = view_args or {}
if spec:
action, target_type, id_arg = spec
target_id = str(view_args.get(id_arg, '')) if id_arg else ''
return action, target_type, target_id
# Unmapped: emit a generic action so nothing is invisible.
return f"{method.lower()}.{path}", 'unknown', ''
def _audit_summary(action):
"""Build a redacted summary for the current request.
For config.update only, list the changed config KEY NAMES (never values).
Request bodies are never recorded.
"""
if action != 'config.update':
return ''
try:
from audit_manager import AuditManager
body = request.get_json(silent=True)
if not isinstance(body, dict):
return ''
keys = []
for section, val in body.items():
if isinstance(val, dict):
keys.extend(f"{section}.{k}" for k in val.keys())
else:
keys.append(str(section))
return AuditManager.summarize_keys(keys)
except Exception:
return ''
@app.after_request
def audit_request(response):
"""Append an audit entry for mutating /api/* requests. Never raises."""
try:
method = request.method
if method not in ('POST', 'PUT', 'DELETE', 'PATCH'):
return response
path = request.path
if not path.startswith('/api/'):
return response
if (path.startswith('/api/auth/') or path.startswith('/api/setup/')
or path.startswith('/api/cells/peer-sync/')):
return response
rule = request.url_rule
endpoint = rule.endpoint if rule is not None else ''
if endpoint in _NO_AUDIT_ENDPOINTS:
return response
actor, role, ip = _audit_actor_ip()
action, target_type, target_id = _audit_map_action(
method, endpoint, request.view_args, path)
status = response.status_code
ctx = request_context.get({})
summary = _audit_summary(action)
audit_manager.record(
actor=actor, role=role, ip=ip, action=action,
target_type=target_type, target_id=target_id, summary=summary,
result='success' if status < 400 else 'failure',
status=status, method=method, path=path,
request_id=ctx.get('request_id', ''),
)
except Exception as e:
logger.warning(f"audit_request hook failed: {e}")
return response
@app.teardown_request
def clear_log_context(exc):
request_context.set({})
@@ -564,6 +769,9 @@ app.register_blueprint(_config_bp)
from routes.service_store import store_bp
app.register_blueprint(store_bp)
from routes.audit import bp as _audit_bp
app.register_blueprint(_audit_bp)
# Re-export config helpers so existing test imports/patches keep working
from routes.config import (
_set_pending_restart, _clear_pending_restart,