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,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Audit Manager for Personal Internet Cell.
|
||||
|
||||
Owner-visible, append-only audit trail of WHO (actor + role + ip) did WHAT
|
||||
(action) to WHICH target, WHEN, with a redacted summary. Storage is a JSONL
|
||||
file with a per-entry SHA-256 hash chain so tampering is detectable. Request
|
||||
bodies and secret values are never written; summaries only ever list changed
|
||||
config KEY NAMES, never their values.
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import re
|
||||
import csv
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from base_service_manager import BaseServiceManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
# Keys whose values must never be recorded — name-only in summaries.
|
||||
_SECRET_KEY_RE = re.compile(r'(pass|secret|key|token|private|cred|otp|psk)', re.IGNORECASE)
|
||||
# Final scrub of anything that looks like base64 key material / encoded blobs.
|
||||
_BASE64_BLOCK_RE = re.compile(r'[A-Za-z0-9+/]{40,}={0,2}')
|
||||
# bcrypt and age secret prefixes.
|
||||
_SECRET_PREFIX_RE = re.compile(
|
||||
r'(\$2[aby]\$[^\s]+|AGE-SECRET-KEY-[^\s]+|age1[^\s]+|-----BEGIN[^\n]+)'
|
||||
)
|
||||
|
||||
_VALID_RESULTS = ('success', 'failure')
|
||||
|
||||
|
||||
class AuditManager(BaseServiceManager):
|
||||
"""Append-only, hash-chained audit trail."""
|
||||
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB before rotation
|
||||
BACKUP_COUNT = 10 # audit.log.1 .. audit.log.10
|
||||
|
||||
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
|
||||
tamper_chain: bool = True):
|
||||
super().__init__('audit', data_dir=data_dir, config_dir=config_dir)
|
||||
self.tamper_chain = tamper_chain
|
||||
self._lock = threading.RLock()
|
||||
self._audit_dir = os.path.join(self.data_dir, 'api', 'audit')
|
||||
self._audit_file = os.path.join(self._audit_dir, 'audit.log')
|
||||
self._seq = 0
|
||||
self._prev_hash = ''
|
||||
self.safe_makedirs(self._audit_dir)
|
||||
self._load_chain_state()
|
||||
|
||||
# ── chain bootstrap ─────────────────────────────────────────────────────
|
||||
def _load_chain_state(self) -> None:
|
||||
"""Recover seq + prev_hash from the last line of the live file."""
|
||||
try:
|
||||
if not os.path.exists(self._audit_file):
|
||||
return
|
||||
last = None
|
||||
with open(self._audit_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
last = line
|
||||
if last:
|
||||
entry = json.loads(last)
|
||||
self._seq = int(entry.get('seq', 0))
|
||||
self._prev_hash = entry.get('hash', '') or ''
|
||||
except Exception as e:
|
||||
logger.warning(f"audit: could not load chain state: {e}")
|
||||
|
||||
# ── redaction ───────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _scrub(text: str) -> str:
|
||||
"""Strip anything resembling a secret value from a summary string."""
|
||||
if not text:
|
||||
return ''
|
||||
text = _SECRET_PREFIX_RE.sub('[REDACTED]', text)
|
||||
text = _BASE64_BLOCK_RE.sub('[REDACTED]', text)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _redact(cls, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Enforce the redaction rules on a built entry before write.
|
||||
|
||||
- summary is scrubbed of base64/secret-prefixed blobs.
|
||||
- any string field is scrubbed too (defence in depth).
|
||||
Request bodies are never present — the caller passes only a summary.
|
||||
"""
|
||||
for field in ('summary', 'target_id', 'action', 'path'):
|
||||
val = entry.get(field)
|
||||
if isinstance(val, str):
|
||||
entry[field] = cls._scrub(val)
|
||||
return entry
|
||||
|
||||
@classmethod
|
||||
def summarize_keys(cls, keys: List[str]) -> str:
|
||||
"""Build a redacted summary listing changed config KEY NAMES only.
|
||||
|
||||
Secret-looking key names are kept (they are names, not values) but the
|
||||
whole string is still scrubbed of any accidental value material.
|
||||
"""
|
||||
names = [str(k) for k in keys if k is not None]
|
||||
return cls._scrub('changed: ' + ', '.join(names)) if names else 'no changes'
|
||||
|
||||
# ── hashing ─────────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _canonical(entry: Dict[str, Any]) -> str:
|
||||
return json.dumps(entry, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
def _hash_entry(self, entry_without_hash: Dict[str, Any]) -> str:
|
||||
return hashlib.sha256(self._canonical(entry_without_hash).encode('utf-8')).hexdigest()
|
||||
|
||||
# ── recording ───────────────────────────────────────────────────────────
|
||||
def record(self, actor: str, role: str, ip: str, action: str,
|
||||
target_type: str = '', target_id: str = '', summary: str = '',
|
||||
result: str = 'success', status: int = 200, method: str = '',
|
||||
path: str = '', request_id: str = '') -> Optional[Dict[str, Any]]:
|
||||
"""Append one redacted, hash-chained JSON line. Never raises."""
|
||||
try:
|
||||
with self._lock:
|
||||
self._maybe_rotate()
|
||||
self._seq += 1
|
||||
if result not in _VALID_RESULTS:
|
||||
result = 'success' if int(status or 200) < 400 else 'failure'
|
||||
entry: Dict[str, Any] = {
|
||||
'ts': _utcnow_iso(),
|
||||
'actor': actor or 'anonymous',
|
||||
'role': role or 'system',
|
||||
'ip': ip or '',
|
||||
'action': action or '',
|
||||
'target_type': target_type or '',
|
||||
'target_id': target_id or '',
|
||||
'summary': summary or '',
|
||||
'result': result,
|
||||
'status': int(status or 0),
|
||||
'method': method or '',
|
||||
'path': path or '',
|
||||
'request_id': request_id or '',
|
||||
'seq': self._seq,
|
||||
'prev_hash': self._prev_hash if self.tamper_chain else '',
|
||||
}
|
||||
entry = self._redact(entry)
|
||||
if self.tamper_chain:
|
||||
entry['hash'] = self._hash_entry(entry)
|
||||
else:
|
||||
entry['hash'] = ''
|
||||
self._append_line(json.dumps(entry, ensure_ascii=False))
|
||||
self._prev_hash = entry['hash']
|
||||
return entry
|
||||
except Exception as e:
|
||||
logger.warning(f"audit.record failed: {e}")
|
||||
return None
|
||||
|
||||
def _append_line(self, line: str) -> None:
|
||||
self.safe_makedirs(self._audit_dir)
|
||||
fd = os.open(self._audit_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (line + '\n').encode('utf-8'))
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.chmod(self._audit_file, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ── rotation ────────────────────────────────────────────────────────────
|
||||
def _maybe_rotate(self) -> None:
|
||||
try:
|
||||
if not os.path.exists(self._audit_file):
|
||||
return
|
||||
if os.path.getsize(self._audit_file) < self.MAX_FILE_SIZE:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
# audit.log.(N-1) -> audit.log.N, ... audit.log -> audit.log.1
|
||||
for i in range(self.BACKUP_COUNT - 1, 0, -1):
|
||||
src = f"{self._audit_file}.{i}"
|
||||
dst = f"{self._audit_file}.{i + 1}"
|
||||
if os.path.exists(src):
|
||||
try:
|
||||
os.replace(src, dst)
|
||||
except OSError as e:
|
||||
logger.warning(f"audit rotate {src}->{dst}: {e}")
|
||||
try:
|
||||
os.replace(self._audit_file, f"{self._audit_file}.1")
|
||||
except OSError as e:
|
||||
logger.warning(f"audit rotate live->.1: {e}")
|
||||
|
||||
def _segment_files(self) -> List[str]:
|
||||
"""Live file first (newest), then rotated segments .1 .. .N (older)."""
|
||||
files = []
|
||||
if os.path.exists(self._audit_file):
|
||||
files.append(self._audit_file)
|
||||
for i in range(1, self.BACKUP_COUNT + 1):
|
||||
seg = f"{self._audit_file}.{i}"
|
||||
if os.path.exists(seg):
|
||||
files.append(seg)
|
||||
return files
|
||||
|
||||
# ── reading / filtering ─────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _matches(entry: Dict[str, Any], filters: Dict[str, Any]) -> bool:
|
||||
for field in ('actor', 'action', 'target_type', 'target_id', 'result'):
|
||||
want = filters.get(field)
|
||||
if want and str(entry.get(field, '')) != str(want):
|
||||
return False
|
||||
since = filters.get('since')
|
||||
until = filters.get('until')
|
||||
ts = entry.get('ts', '')
|
||||
if since and ts < since:
|
||||
return False
|
||||
if until and ts > until:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _read_all(self, filters: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return matching entries, newest-first across all segments."""
|
||||
results: List[Dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for seg in self._segment_files():
|
||||
try:
|
||||
with open(seg, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
continue
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if self._matches(entry, filters):
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
def query(self, filters: Optional[Dict[str, Any]] = None,
|
||||
limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
||||
filters = filters or {}
|
||||
try:
|
||||
limit = max(1, min(int(limit), 1000))
|
||||
except (TypeError, ValueError):
|
||||
limit = 100
|
||||
try:
|
||||
offset = max(0, int(offset))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
entries = self._read_all(filters)
|
||||
total = len(entries)
|
||||
page = entries[offset:offset + limit]
|
||||
next_offset = offset + limit if offset + limit < total else None
|
||||
return {'entries': page, 'total': total, 'next_offset': next_offset}
|
||||
|
||||
def export_csv(self, filters: Optional[Dict[str, Any]] = None) -> str:
|
||||
filters = filters or {}
|
||||
entries = self._read_all(filters)
|
||||
fields = ['ts', 'actor', 'role', 'ip', 'action', 'target_type',
|
||||
'target_id', 'summary', 'result', 'status', 'method', 'path',
|
||||
'request_id', 'seq']
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(fields)
|
||||
for e in entries:
|
||||
writer.writerow([e.get(f, '') for f in fields])
|
||||
return buf.getvalue()
|
||||
|
||||
# ── integrity ───────────────────────────────────────────────────────────
|
||||
def verify_chain(self) -> Dict[str, Any]:
|
||||
"""Walk all segments oldest-first; verify each entry's hash + link."""
|
||||
if not self.tamper_chain:
|
||||
return {'ok': True, 'broken_at_seq': None, 'disabled': True}
|
||||
with self._lock:
|
||||
segs = list(reversed(self._segment_files())) # oldest -> newest
|
||||
prev_hash = ''
|
||||
first = True # oldest available record: its predecessor may be pruned
|
||||
for seg in segs:
|
||||
try:
|
||||
with open(seg, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
continue
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return {'ok': False, 'broken_at_seq': None}
|
||||
stored_hash = entry.get('hash', '')
|
||||
# Don't fail the prev_hash link on the very first available
|
||||
# record — older segments may have rotated off the end.
|
||||
if not first and entry.get('prev_hash', '') != prev_hash:
|
||||
return {'ok': False, 'broken_at_seq': entry.get('seq')}
|
||||
recomputed = self._hash_entry({k: v for k, v in entry.items() if k != 'hash'})
|
||||
if recomputed != stored_hash:
|
||||
return {'ok': False, 'broken_at_seq': entry.get('seq')}
|
||||
prev_hash = stored_hash
|
||||
first = False
|
||||
return {'ok': True, 'broken_at_seq': None}
|
||||
|
||||
# ── BaseServiceManager interface ────────────────────────────────────────
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
size = 0
|
||||
try:
|
||||
if os.path.exists(self._audit_file):
|
||||
size = os.path.getsize(self._audit_file)
|
||||
except OSError:
|
||||
pass
|
||||
return {
|
||||
'running': True,
|
||||
'tamper_chain': self.tamper_chain,
|
||||
'seq': self._seq,
|
||||
'file': self._audit_file,
|
||||
'file_size': size,
|
||||
}
|
||||
|
||||
def test_connectivity(self) -> Dict[str, Any]:
|
||||
return {'success': True}
|
||||
@@ -20,6 +20,30 @@ auth_manager = None # type: ignore
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
|
||||
|
||||
|
||||
def _audit(action, target_type, target_id, summary, result, status):
|
||||
"""Record an explicit audit entry for auth actions the generic hook skips.
|
||||
|
||||
Never raises and never includes any password value.
|
||||
"""
|
||||
try:
|
||||
from app import audit_manager
|
||||
ip = request.remote_addr or ''
|
||||
xff = request.headers.get('X-Forwarded-For', '')
|
||||
if xff:
|
||||
last = xff.split(',')[-1].strip()
|
||||
if last:
|
||||
ip = last
|
||||
audit_manager.record(
|
||||
actor=session.get('username', 'anonymous'),
|
||||
role=session.get('role', 'system'),
|
||||
ip=ip, action=action, target_type=target_type, target_id=target_id,
|
||||
summary=summary, result=result, status=status,
|
||||
method=request.method, path=request.path,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def require_auth(role=None):
|
||||
"""Decorator that enforces session authentication and an optional role."""
|
||||
def deco(fn):
|
||||
@@ -124,7 +148,11 @@ def change_password():
|
||||
username = session.get('username')
|
||||
ok = auth_manager.change_password(username, old_pw, new_pw)
|
||||
if not ok:
|
||||
_audit('user.password_change', 'user', username or '',
|
||||
'password changed', 'failure', 400)
|
||||
return jsonify({'error': 'Password change failed'}), 400
|
||||
_audit('user.password_change', 'user', username or '',
|
||||
'password changed', 'success', 200)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@@ -142,7 +170,11 @@ def admin_reset_password():
|
||||
return jsonify({'error': 'new_password must be at least 10 characters'}), 400
|
||||
ok = auth_manager.set_password_admin(username, new_pw)
|
||||
if not ok:
|
||||
_audit('user.password_reset', 'user', username,
|
||||
f'admin reset password for peer {username}', 'failure', 400)
|
||||
return jsonify({'error': 'Reset failed (user not found?)'}), 400
|
||||
_audit('user.password_reset', 'user', username,
|
||||
f'admin reset password for peer {username}', 'success', 200)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
|
||||
@@ -520,6 +520,8 @@ class ConfigManager:
|
||||
'api/peer_service_credentials.json',
|
||||
'api/cell_links.json',
|
||||
'api/ddns_token',
|
||||
# Append-only audit trail (who changed what) + rotated segments
|
||||
'api/audit',
|
||||
# WireGuard key material (server + peers) and live confs
|
||||
'wireguard/keys',
|
||||
'wireguard/wg_confs',
|
||||
@@ -688,8 +690,9 @@ class ConfigManager:
|
||||
for rel in ('api/peers.json', 'api/peer_service_credentials.json'):
|
||||
self._restore_data_path(backup_path, rel)
|
||||
|
||||
# (4) Cell-to-cell links / permissions
|
||||
# (4) Cell-to-cell links / permissions + audit trail
|
||||
self._restore_data_path(backup_path, 'api/cell_links.json')
|
||||
self._restore_data_path(backup_path, 'api/audit')
|
||||
|
||||
# (5) Caddy issued certs/ACME, DNS Corefile + zones (generated files are
|
||||
# reapplied below, but restoring them gives a correct starting point).
|
||||
|
||||
+4
-1
@@ -34,6 +34,7 @@ from connectivity_manager import ConnectivityManager
|
||||
from service_registry import ServiceRegistry
|
||||
from service_composer import ServiceComposer
|
||||
from account_manager import AccountManager
|
||||
from audit_manager import AuditManager
|
||||
|
||||
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
||||
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
|
||||
@@ -125,6 +126,8 @@ egress_manager = EgressManager(
|
||||
)
|
||||
service_store_manager.egress_manager = egress_manager
|
||||
|
||||
audit_manager = AuditManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
|
||||
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager,
|
||||
network_manager=network_manager)
|
||||
|
||||
@@ -154,7 +157,7 @@ __all__ = [
|
||||
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
|
||||
'ddns_manager', 'service_store_manager', 'connectivity_manager',
|
||||
'service_registry', 'service_composer', 'account_manager',
|
||||
'egress_manager',
|
||||
'egress_manager', 'audit_manager',
|
||||
'firewall_manager', 'EventType',
|
||||
'DATA_DIR', 'CONFIG_DIR',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Audit trail API (admin-only).
|
||||
|
||||
Not added to app._PEER_READABLE_PATHS, so enforce_auth blocks peer-role
|
||||
sessions with 403. Routes are thin — all logic lives in AuditManager.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
bp = Blueprint('audit', __name__)
|
||||
|
||||
|
||||
def _filters_from_args():
|
||||
args = request.args
|
||||
filters = {}
|
||||
for field in ('actor', 'action', 'target_type', 'target_id', 'result', 'since', 'until'):
|
||||
val = args.get(field)
|
||||
if val:
|
||||
filters[field] = val
|
||||
return filters
|
||||
|
||||
|
||||
@bp.route('/api/audit', methods=['GET'])
|
||||
def list_audit():
|
||||
try:
|
||||
from app import audit_manager
|
||||
try:
|
||||
limit = int(request.args.get('limit', 100))
|
||||
except (TypeError, ValueError):
|
||||
limit = 100
|
||||
try:
|
||||
offset = int(request.args.get('offset', 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
result = audit_manager.query(_filters_from_args(), limit=limit, offset=offset)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"list_audit: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/audit/export', methods=['GET'])
|
||||
def export_audit():
|
||||
try:
|
||||
from app import audit_manager
|
||||
fmt = request.args.get('format', 'csv')
|
||||
if fmt != 'csv':
|
||||
return jsonify({'error': 'only csv format is supported'}), 400
|
||||
csv_text = audit_manager.export_csv(_filters_from_args())
|
||||
return Response(
|
||||
csv_text,
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename="audit.csv"'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"export_audit: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/audit/verify', methods=['GET'])
|
||||
def verify_audit():
|
||||
try:
|
||||
from app import audit_manager
|
||||
return jsonify(audit_manager.verify_chain())
|
||||
except Exception as e:
|
||||
logger.error(f"verify_audit: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
Reference in New Issue
Block a user