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_registry,
|
||||||
service_composer,
|
service_composer,
|
||||||
account_manager,
|
account_manager,
|
||||||
|
audit_manager,
|
||||||
firewall_manager, EventType,
|
firewall_manager, EventType,
|
||||||
)
|
)
|
||||||
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
|
# 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')}")
|
logger.info(f"{ctx.get('method')} {ctx.get('path')} {ctx.get('status')}")
|
||||||
return response
|
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
|
@app.teardown_request
|
||||||
def clear_log_context(exc):
|
def clear_log_context(exc):
|
||||||
request_context.set({})
|
request_context.set({})
|
||||||
@@ -564,6 +769,9 @@ app.register_blueprint(_config_bp)
|
|||||||
from routes.service_store import store_bp
|
from routes.service_store import store_bp
|
||||||
app.register_blueprint(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
|
# Re-export config helpers so existing test imports/patches keep working
|
||||||
from routes.config import (
|
from routes.config import (
|
||||||
_set_pending_restart, _clear_pending_restart,
|
_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')
|
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):
|
def require_auth(role=None):
|
||||||
"""Decorator that enforces session authentication and an optional role."""
|
"""Decorator that enforces session authentication and an optional role."""
|
||||||
def deco(fn):
|
def deco(fn):
|
||||||
@@ -124,7 +148,11 @@ def change_password():
|
|||||||
username = session.get('username')
|
username = session.get('username')
|
||||||
ok = auth_manager.change_password(username, old_pw, new_pw)
|
ok = auth_manager.change_password(username, old_pw, new_pw)
|
||||||
if not ok:
|
if not ok:
|
||||||
|
_audit('user.password_change', 'user', username or '',
|
||||||
|
'password changed', 'failure', 400)
|
||||||
return jsonify({'error': 'Password change failed'}), 400
|
return jsonify({'error': 'Password change failed'}), 400
|
||||||
|
_audit('user.password_change', 'user', username or '',
|
||||||
|
'password changed', 'success', 200)
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +170,11 @@ def admin_reset_password():
|
|||||||
return jsonify({'error': 'new_password must be at least 10 characters'}), 400
|
return jsonify({'error': 'new_password must be at least 10 characters'}), 400
|
||||||
ok = auth_manager.set_password_admin(username, new_pw)
|
ok = auth_manager.set_password_admin(username, new_pw)
|
||||||
if not ok:
|
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
|
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})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -520,6 +520,8 @@ class ConfigManager:
|
|||||||
'api/peer_service_credentials.json',
|
'api/peer_service_credentials.json',
|
||||||
'api/cell_links.json',
|
'api/cell_links.json',
|
||||||
'api/ddns_token',
|
'api/ddns_token',
|
||||||
|
# Append-only audit trail (who changed what) + rotated segments
|
||||||
|
'api/audit',
|
||||||
# WireGuard key material (server + peers) and live confs
|
# WireGuard key material (server + peers) and live confs
|
||||||
'wireguard/keys',
|
'wireguard/keys',
|
||||||
'wireguard/wg_confs',
|
'wireguard/wg_confs',
|
||||||
@@ -688,8 +690,9 @@ class ConfigManager:
|
|||||||
for rel in ('api/peers.json', 'api/peer_service_credentials.json'):
|
for rel in ('api/peers.json', 'api/peer_service_credentials.json'):
|
||||||
self._restore_data_path(backup_path, rel)
|
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/cell_links.json')
|
||||||
|
self._restore_data_path(backup_path, 'api/audit')
|
||||||
|
|
||||||
# (5) Caddy issued certs/ACME, DNS Corefile + zones (generated files are
|
# (5) Caddy issued certs/ACME, DNS Corefile + zones (generated files are
|
||||||
# reapplied below, but restoring them gives a correct starting point).
|
# 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_registry import ServiceRegistry
|
||||||
from service_composer import ServiceComposer
|
from service_composer import ServiceComposer
|
||||||
from account_manager import AccountManager
|
from account_manager import AccountManager
|
||||||
|
from audit_manager import AuditManager
|
||||||
|
|
||||||
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
||||||
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
|
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
|
||||||
@@ -125,6 +126,8 @@ egress_manager = EgressManager(
|
|||||||
)
|
)
|
||||||
service_store_manager.egress_manager = egress_manager
|
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,
|
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager,
|
||||||
network_manager=network_manager)
|
network_manager=network_manager)
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@ __all__ = [
|
|||||||
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
|
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
|
||||||
'ddns_manager', 'service_store_manager', 'connectivity_manager',
|
'ddns_manager', 'service_store_manager', 'connectivity_manager',
|
||||||
'service_registry', 'service_composer', 'account_manager',
|
'service_registry', 'service_composer', 'account_manager',
|
||||||
'egress_manager',
|
'egress_manager', 'audit_manager',
|
||||||
'firewall_manager', 'EventType',
|
'firewall_manager', 'EventType',
|
||||||
'DATA_DIR', 'CONFIG_DIR',
|
'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
|
||||||
@@ -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 "<method>.<path>" 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
|
||||||
@@ -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
|
||||||
@@ -58,6 +58,7 @@ class _BackupBase(unittest.TestCase):
|
|||||||
_write(d / 'api' / 'peer_service_credentials.json', '{}')
|
_write(d / 'api' / 'peer_service_credentials.json', '{}')
|
||||||
_write(d / 'api' / 'cell_links.json', '{"link": 1}')
|
_write(d / 'api' / 'cell_links.json', '{"link": 1}')
|
||||||
_write(d / 'api' / 'ddns_token', 'tok123')
|
_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' / 'keys' / 'server_private.key', 'PRIV')
|
||||||
_write(d / 'wireguard' / 'wg_confs' / 'wg0.conf', '[Interface]')
|
_write(d / 'wireguard' / 'wg_confs' / 'wg0.conf', '[Interface]')
|
||||||
_write(d / 'api' / 'wireguard' / 'keys' / 'private.key', 'P2')
|
_write(d / 'api' / 'wireguard' / 'keys' / 'private.key', 'P2')
|
||||||
@@ -92,6 +93,7 @@ class TestBackupInclude(_BackupBase):
|
|||||||
'data/api/peer_service_credentials.json',
|
'data/api/peer_service_credentials.json',
|
||||||
'data/api/cell_links.json',
|
'data/api/cell_links.json',
|
||||||
'data/api/ddns_token',
|
'data/api/ddns_token',
|
||||||
|
'data/api/audit/audit.log',
|
||||||
'data/wireguard/keys/server_private.key',
|
'data/wireguard/keys/server_private.key',
|
||||||
'data/wireguard/wg_confs/wg0.conf',
|
'data/wireguard/wg_confs/wg0.conf',
|
||||||
'data/api/wireguard/keys/private.key',
|
'data/api/wireguard/keys/private.key',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
User,
|
User,
|
||||||
|
History,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { healthAPI, cellAPI, servicesAPI } from './services/api';
|
import { healthAPI, cellAPI, servicesAPI } from './services/api';
|
||||||
import { ConfigProvider } from './contexts/ConfigContext';
|
import { ConfigProvider } from './contexts/ConfigContext';
|
||||||
@@ -45,6 +46,7 @@ import EmailPage from './pages/services/EmailPage';
|
|||||||
import CalendarPage from './pages/services/CalendarPage';
|
import CalendarPage from './pages/services/CalendarPage';
|
||||||
import FilesPage from './pages/services/FilesPage';
|
import FilesPage from './pages/services/FilesPage';
|
||||||
import Connectivity from './pages/Connectivity';
|
import Connectivity from './pages/Connectivity';
|
||||||
|
import ActivityPage from './pages/Activity';
|
||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
import SetupGuard from './components/SetupGuard';
|
import SetupGuard from './components/SetupGuard';
|
||||||
|
|
||||||
@@ -268,6 +270,7 @@ function AppCore() {
|
|||||||
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
|
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
|
||||||
{ name: 'Connectivity', href: '/connectivity', icon: Network },
|
{ name: 'Connectivity', href: '/connectivity', icon: Network },
|
||||||
{ name: 'Logs', href: '/logs', icon: Activity },
|
{ name: 'Logs', href: '/logs', icon: Activity },
|
||||||
|
{ name: 'Activity', href: '/activity', icon: History },
|
||||||
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
||||||
{ name: 'Account', href: '/account', icon: User },
|
{ name: 'Account', href: '/account', icon: User },
|
||||||
];
|
];
|
||||||
@@ -382,6 +385,7 @@ function AppCore() {
|
|||||||
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
|
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
|
||||||
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
|
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
|
||||||
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
||||||
|
<Route path="/activity" element={<PrivateRoute requireRole="admin"><ActivityPage /></PrivateRoute>} />
|
||||||
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
|
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 <span className={`text-xs rounded px-1.5 py-0.5 ${cls}`}>{role || 'system'}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultPill({ result }) {
|
||||||
|
const ok = result === 'success';
|
||||||
|
return (
|
||||||
|
<span className={`text-xs rounded-full px-2 py-0.5 font-medium ${
|
||||||
|
ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>{result}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<History className="h-6 w-6" /> Activity
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-gray-600">Append-only audit trail — who changed what, when.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn btn-secondary text-sm" onClick={runVerify}>
|
||||||
|
{verify == null ? <ShieldCheck className="h-4 w-4 mr-1 inline" />
|
||||||
|
: verify.ok ? <ShieldCheck className="h-4 w-4 mr-1 inline text-green-600" />
|
||||||
|
: <ShieldAlert className="h-4 w-4 mr-1 inline text-red-600" />}
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary text-sm" onClick={exportCsv}>
|
||||||
|
<Download className="h-4 w-4 mr-1 inline" />Export CSV
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary text-sm" onClick={() => load(offset)}>
|
||||||
|
<RefreshCw className={`h-4 w-4 inline ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verify != null && (
|
||||||
|
<div className={`mb-4 rounded p-3 text-sm ${
|
||||||
|
verify.ok ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{verify.ok
|
||||||
|
? 'Chain verified — no tampering detected.'
|
||||||
|
: `Chain broken${verify.broken_at_seq != null ? ` at seq ${verify.broken_at_seq}` : ''}.`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Actor"
|
||||||
|
value={actor} onChange={e => setActor(e.target.value)} />
|
||||||
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Action"
|
||||||
|
value={action} onChange={e => setAction(e.target.value)} />
|
||||||
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Target type"
|
||||||
|
value={targetType} onChange={e => setTargetType(e.target.value)} />
|
||||||
|
<select className="border rounded px-2 py-1 text-sm" value={result}
|
||||||
|
onChange={e => setResult(e.target.value)}>
|
||||||
|
{RESULTS.map(r => <option key={r} value={r}>{r || 'any result'}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="date" className="border rounded px-2 py-1 text-sm" title="Since"
|
||||||
|
value={since.slice(0, 10)} onChange={e => setSince(e.target.value ? `${e.target.value}T00:00:00Z` : '')} />
|
||||||
|
<input type="date" className="border rounded px-2 py-1 text-sm" title="Until"
|
||||||
|
value={until.slice(0, 10)} onChange={e => setUntil(e.target.value ? `${e.target.value}T23:59:59Z` : '')} />
|
||||||
|
<button className="btn btn-primary text-sm" onClick={() => load(0)}>
|
||||||
|
<Search className="h-4 w-4 mr-1 inline" />Filter
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-40">
|
||||||
|
<input className="border rounded px-2 py-1 text-sm w-full" placeholder="Free text (current page)"
|
||||||
|
value={text} onChange={e => setText(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 text-left">
|
||||||
|
{['When', 'Who', 'Action', 'Target', 'Summary', 'Result'].map(h =>
|
||||||
|
<th key={h} className="px-3 py-2">{h}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((e, i) => (
|
||||||
|
<tr key={e.seq ?? i} className="border-t align-top">
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">
|
||||||
|
<div>{relativeTime(e.ts)}</div>
|
||||||
|
<div className="text-xs text-gray-400 font-mono">{String(e.ts || '').replace('T', ' ').replace('Z', '')}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">
|
||||||
|
<div className="font-medium">{e.actor}</div>
|
||||||
|
<div className="mt-0.5"><RoleBadge role={e.role} /></div>
|
||||||
|
{e.ip && <div className="text-xs text-gray-400 font-mono mt-0.5">{e.ip}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{e.action}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div>{e.target_type}</div>
|
||||||
|
{e.target_id && <div className="text-xs text-gray-500 font-mono">{e.target_id}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600 max-w-md break-words">{e.summary}</td>
|
||||||
|
<td className="px-3 py-2"><ResultPill result={e.result} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!filtered.length && (
|
||||||
|
<tr><td colSpan={6} className="px-3 py-6 text-center text-gray-400">
|
||||||
|
{loading ? 'Loading…' : 'No activity recorded.'}
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>{total} entries · showing {offset + 1}–{Math.min(offset + PAGE_SIZE, total)}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn btn-secondary text-sm" disabled={offset === 0}
|
||||||
|
onClick={() => load(Math.max(0, offset - PAGE_SIZE))}>Previous</button>
|
||||||
|
<button className="btn btn-secondary text-sm" disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => load(offset + PAGE_SIZE)}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -341,6 +341,12 @@ export const logsAPI = {
|
|||||||
setVerbosity: (levels) => api.put('/api/logs/verbosity', levels),
|
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
|
// DDNS API
|
||||||
export const ddnsAPI = {
|
export const ddnsAPI = {
|
||||||
checkName: (name) => api.get(`/api/ddns/check/${name}`),
|
checkName: (name) => api.get(`/api/ddns/check/${name}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user