From 8b50fb103699dc5274f590ec0bac649b62ef7fd3 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Wed, 10 Jun 2026 20:19:38 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20audit/change=20log=20=E2=80=94=20owner-?= =?UTF-8?q?visible=20record=20of=20who=20changed=20what?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/app.py | 208 +++++++++++++++++ api/audit_manager.py | 330 +++++++++++++++++++++++++++ api/auth_routes.py | 32 +++ api/config_manager.py | 5 +- api/managers.py | 5 +- api/routes/audit.py | 69 ++++++ tests/test_audit_hook_routes.py | 180 +++++++++++++++ tests/test_audit_manager.py | 198 ++++++++++++++++ tests/test_config_backup_overhaul.py | 2 + webui/src/App.jsx | 4 + webui/src/pages/Activity.jsx | 209 +++++++++++++++++ webui/src/services/api.js | 6 + 12 files changed, 1246 insertions(+), 2 deletions(-) create mode 100644 api/audit_manager.py create mode 100644 api/routes/audit.py create mode 100644 tests/test_audit_hook_routes.py create mode 100644 tests/test_audit_manager.py create mode 100644 webui/src/pages/Activity.jsx diff --git a/api/app.py b/api/app.py index 0a6ff64..32e4554 100644 --- a/api/app.py +++ b/api/app.py @@ -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 +# ('.' for blueprint routes, '' 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, diff --git a/api/audit_manager.py b/api/audit_manager.py new file mode 100644 index 0000000..fa13e98 --- /dev/null +++ b/api/audit_manager.py @@ -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} diff --git a/api/auth_routes.py b/api/auth_routes.py index f2a268f..0ac13e0 100644 --- a/api/auth_routes.py +++ b/api/auth_routes.py @@ -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}) diff --git a/api/config_manager.py b/api/config_manager.py index 8938630..70eb373 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -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). diff --git a/api/managers.py b/api/managers.py index 67e4904..bcfbb80 100644 --- a/api/managers.py +++ b/api/managers.py @@ -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', ] diff --git a/api/routes/audit.py b/api/routes/audit.py new file mode 100644 index 0000000..c73fd9c --- /dev/null +++ b/api/routes/audit.py @@ -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 diff --git a/tests/test_audit_hook_routes.py b/tests/test_audit_hook_routes.py new file mode 100644 index 0000000..3f41b0d --- /dev/null +++ b/tests/test_audit_hook_routes.py @@ -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 "." 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 diff --git a/tests/test_audit_manager.py b/tests/test_audit_manager.py new file mode 100644 index 0000000..5a8346e --- /dev/null +++ b/tests/test_audit_manager.py @@ -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 diff --git a/tests/test_config_backup_overhaul.py b/tests/test_config_backup_overhaul.py index 833d4f5..e2b8364 100644 --- a/tests/test_config_backup_overhaul.py +++ b/tests/test_config_backup_overhaul.py @@ -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', diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 1bcc22a..4df479f 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -19,6 +19,7 @@ import { RefreshCw, AlertTriangle, User, + History, } from 'lucide-react'; import { healthAPI, cellAPI, servicesAPI } from './services/api'; import { ConfigProvider } from './contexts/ConfigContext'; @@ -45,6 +46,7 @@ import EmailPage from './pages/services/EmailPage'; import CalendarPage from './pages/services/CalendarPage'; import FilesPage from './pages/services/FilesPage'; import Connectivity from './pages/Connectivity'; +import ActivityPage from './pages/Activity'; import Setup from './pages/Setup'; import SetupGuard from './components/SetupGuard'; @@ -268,6 +270,7 @@ function AppCore() { { name: 'Cell Network', href: '/cell-network', icon: Link2 }, { name: 'Connectivity', href: '/connectivity', icon: Network }, { name: 'Logs', href: '/logs', icon: Activity }, + { name: 'Activity', href: '/activity', icon: History }, { name: 'Settings', href: '/settings', icon: SettingsIcon }, { name: 'Account', href: '/account', icon: User }, ]; @@ -382,6 +385,7 @@ function AppCore() { } /> } /> } /> + } /> } /> diff --git a/webui/src/pages/Activity.jsx b/webui/src/pages/Activity.jsx new file mode 100644 index 0000000..845c7aa --- /dev/null +++ b/webui/src/pages/Activity.jsx @@ -0,0 +1,209 @@ +import { useState, useEffect, useCallback } from 'react'; +import { History, RefreshCw, Download, ShieldCheck, ShieldAlert, Search } from 'lucide-react'; +import { auditAPI } from '../services/api'; + +const RESULTS = ['', 'success', 'failure']; +const PAGE_SIZE = 100; + +function relativeTime(ts) { + if (!ts) return ''; + const then = Date.parse(ts.endsWith('Z') ? ts : ts + 'Z'); + if (Number.isNaN(then)) return ts; + const secs = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; + return `${Math.floor(secs / 86400)}d ago`; +} + +function RoleBadge({ role }) { + const cls = role === 'admin' + ? 'bg-purple-100 text-purple-700' + : role === 'peer' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'; + return {role || 'system'}; +} + +function ResultPill({ result }) { + const ok = result === 'success'; + return ( + {result} + ); +} + +export default function Activity() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [verify, setVerify] = useState(null); + + const [actor, setActor] = useState(''); + const [action, setAction] = useState(''); + const [targetType, setTargetType] = useState(''); + const [result, setResult] = useState(''); + const [since, setSince] = useState(''); + const [until, setUntil] = useState(''); + const [text, setText] = useState(''); + + const buildParams = useCallback((ofs) => { + const p = { limit: PAGE_SIZE, offset: ofs }; + if (actor) p.actor = actor; + if (action) p.action = action; + if (targetType) p.target_type = targetType; + if (result) p.result = result; + if (since) p.since = since; + if (until) p.until = until; + return p; + }, [actor, action, targetType, result, since, until]); + + const load = useCallback(async (ofs = 0) => { + setLoading(true); + try { + const res = await auditAPI.list(buildParams(ofs)); + setEntries(res.data.entries || []); + setTotal(res.data.total || 0); + setOffset(ofs); + } catch (e) { + setEntries([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [buildParams]); + + useEffect(() => { load(0); }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const runVerify = async () => { + try { setVerify((await auditAPI.verify()).data); } catch { setVerify({ ok: false }); } + }; + + const exportCsv = async () => { + try { + const res = await auditAPI.exportCsv(buildParams(0)); + const url = URL.createObjectURL(new Blob([res.data], { type: 'text/csv' })); + const a = document.createElement('a'); + a.href = url; + a.download = 'audit.csv'; + a.click(); + URL.revokeObjectURL(url); + } catch { /* ignore */ } + }; + + const filtered = text + ? entries.filter(e => + JSON.stringify(e).toLowerCase().includes(text.toLowerCase())) + : entries; + + return ( +
+
+
+

+ Activity +

+

Append-only audit trail — who changed what, when.

+
+
+ + + +
+
+ + {verify != null && ( +
+ {verify.ok + ? 'Chain verified — no tampering detected.' + : `Chain broken${verify.broken_at_seq != null ? ` at seq ${verify.broken_at_seq}` : ''}.`} +
+ )} + +
+
+ setActor(e.target.value)} /> + setAction(e.target.value)} /> + setTargetType(e.target.value)} /> + + setSince(e.target.value ? `${e.target.value}T00:00:00Z` : '')} /> + setUntil(e.target.value ? `${e.target.value}T23:59:59Z` : '')} /> + +
+ setText(e.target.value)} /> +
+
+
+ +
+ + + + {['When', 'Who', 'Action', 'Target', 'Summary', 'Result'].map(h => + )} + + + + {filtered.map((e, i) => ( + + + + + + + + + ))} + {!filtered.length && ( + + )} + +
{h}
+
{relativeTime(e.ts)}
+
{String(e.ts || '').replace('T', ' ').replace('Z', '')}
+
+
{e.actor}
+
+ {e.ip &&
{e.ip}
} +
{e.action} +
{e.target_type}
+ {e.target_id &&
{e.target_id}
} +
{e.summary}
+ {loading ? 'Loading…' : 'No activity recorded.'} +
+
+ +
+ {total} entries · showing {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} +
+ + +
+
+
+ ); +} diff --git a/webui/src/services/api.js b/webui/src/services/api.js index dd2097e..40565d9 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -341,6 +341,12 @@ export const logsAPI = { setVerbosity: (levels) => api.put('/api/logs/verbosity', levels), }; +export const auditAPI = { + list: (params) => api.get('/api/audit', { params }), + exportCsv: (params) => api.get('/api/audit/export', { params, responseType: 'blob' }), + verify: () => api.get('/api/audit/verify'), +}; + // DDNS API export const ddnsAPI = { checkName: (name) => api.get(`/api/ddns/check/${name}`),