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,
+330
View File
@@ -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}
+32
View File
@@ -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})
+4 -1
View File
@@ -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
View File
@@ -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',
]
+69
View File
@@ -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