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:
+208
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user