Files
pic/api/routes/audit.py
T
roof 8b50fb1036
Unit Tests / test (push) Successful in 12m47s
feat: audit/change log — owner-visible record of who changed what
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>
2026-06-10 20:19:38 -04:00

70 lines
2.1 KiB
Python

"""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