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:
@@ -0,0 +1,69 @@
|
||||
"""Audit trail API (admin-only).
|
||||
|
||||
Not added to app._PEER_READABLE_PATHS, so enforce_auth blocks peer-role
|
||||
sessions with 403. Routes are thin — all logic lives in AuditManager.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
bp = Blueprint('audit', __name__)
|
||||
|
||||
|
||||
def _filters_from_args():
|
||||
args = request.args
|
||||
filters = {}
|
||||
for field in ('actor', 'action', 'target_type', 'target_id', 'result', 'since', 'until'):
|
||||
val = args.get(field)
|
||||
if val:
|
||||
filters[field] = val
|
||||
return filters
|
||||
|
||||
|
||||
@bp.route('/api/audit', methods=['GET'])
|
||||
def list_audit():
|
||||
try:
|
||||
from app import audit_manager
|
||||
try:
|
||||
limit = int(request.args.get('limit', 100))
|
||||
except (TypeError, ValueError):
|
||||
limit = 100
|
||||
try:
|
||||
offset = int(request.args.get('offset', 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
result = audit_manager.query(_filters_from_args(), limit=limit, offset=offset)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"list_audit: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/audit/export', methods=['GET'])
|
||||
def export_audit():
|
||||
try:
|
||||
from app import audit_manager
|
||||
fmt = request.args.get('format', 'csv')
|
||||
if fmt != 'csv':
|
||||
return jsonify({'error': 'only csv format is supported'}), 400
|
||||
csv_text = audit_manager.export_csv(_filters_from_args())
|
||||
return Response(
|
||||
csv_text,
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename="audit.csv"'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"export_audit: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/audit/verify', methods=['GET'])
|
||||
def verify_audit():
|
||||
try:
|
||||
from app import audit_manager
|
||||
return jsonify(audit_manager.verify_chain())
|
||||
except Exception as e:
|
||||
logger.error(f"verify_audit: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
Reference in New Issue
Block a user