Add compliance audit log
Immutable audit_log table in the scanner DB records every significant admin action (profile save/delete, token create/revoke, PIN changes, source add/update/delete, scheduler job changes, scan start/stop, SMTP save, dispositions, item delete/redact). GET /api/audit_log exposes entries newest-first. New Audit Log tab in the Settings modal renders the table on demand. Settings modal widened 540→640 px and tab labels set to white-space:nowrap so the six-tab row fits on one line. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ef2dfb352
commit
744813f4ac
18 files changed
+236
-11
No files matched your search
@@ -72,6 +72,18 @@ def get_lang_json():
|
||||
return jsonify(state.LANG)
|
||||
|
||||
|
||||
@bp.route("/api/audit_log")
|
||||
def audit_log_list():
|
||||
"""Return recent compliance audit log entries."""
|
||||
try:
|
||||
from gdpr_db import get_db as _get_db
|
||||
limit = min(int(request.args.get("limit", 200)), 1000)
|
||||
action = request.args.get("action") or None
|
||||
return jsonify(_get_db().get_audit_log(limit=limit, action=action))
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route("/manual")
|
||||
def manual():
|
||||
"""Serve the user manual as a styled, printable HTML page.
|
||||
|
||||
+12
-2
@@ -11,11 +11,12 @@ from checkpoint import _clear_checkpoint, _DELTA_PATH
|
||||
from cpr_detector import _extract_exif, _html_esc, _placeholder_svg
|
||||
|
||||
try:
|
||||
from gdpr_db import get_db as _get_db
|
||||
from gdpr_db import get_db as _get_db, log_audit_event as _audit
|
||||
DB_OK = True
|
||||
except ImportError:
|
||||
DB_OK = False
|
||||
def _get_db(*a, **kw): return None # type: ignore[misc]
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
|
||||
try:
|
||||
import document_scanner as _ds # noqa: F401
|
||||
@@ -140,6 +141,9 @@ def db_set_disposition():
|
||||
notes = data.get("notes", ""),
|
||||
reviewed_by = data.get("reviewed_by", ""),
|
||||
)
|
||||
_audit("disposition",
|
||||
f"item_id={item_id!r} status={data.get('status','')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"status": "saved"})
|
||||
|
||||
|
||||
@@ -160,6 +164,9 @@ def db_set_disposition_bulk():
|
||||
legal_basis=data.get("legal_basis", ""),
|
||||
notes=data.get("notes", ""),
|
||||
reviewed_by=data.get("reviewed_by", ""))
|
||||
_audit("disposition_bulk",
|
||||
f"count={len(item_ids)} status={status!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"saved": len(item_ids)})
|
||||
|
||||
|
||||
@@ -281,10 +288,13 @@ def admin_pin_set():
|
||||
new_pin = data.get("new_pin", "").strip()
|
||||
if not new_pin:
|
||||
return jsonify({"error": "new_pin required"}), 400
|
||||
if _admin_pin_is_set():
|
||||
had_pin = _admin_pin_is_set()
|
||||
if had_pin:
|
||||
if not _verify_admin_pin(data.get("current_pin", "")):
|
||||
return jsonify({"error": "incorrect_pin"}), 403
|
||||
_set_admin_pin(new_pin)
|
||||
_audit("admin_pin_change" if had_pin else "admin_pin_set", "",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ from __future__ import annotations
|
||||
from flask import Blueprint, jsonify, request
|
||||
from routes import state
|
||||
from app_config import _load_smtp_config, _save_smtp_config
|
||||
try:
|
||||
from gdpr_db import log_audit_event as _audit
|
||||
except ImportError:
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
from routes.export import _build_excel_bytes
|
||||
|
||||
bp = Blueprint("email", __name__)
|
||||
@@ -119,6 +123,7 @@ def smtp_config_save():
|
||||
if not data.get("password") and existing.get("password"):
|
||||
data["password"] = existing["password"]
|
||||
_save_smtp_config(data)
|
||||
_audit("smtp_save", f"host={data.get('host','')!r}", ip=request.remote_addr or "")
|
||||
return jsonify({"status": "saved"})
|
||||
|
||||
|
||||
|
||||
+8
-1
@@ -9,11 +9,12 @@ from routes import state
|
||||
from app_config import _GUID_RE, _resolve_display_name
|
||||
|
||||
try:
|
||||
from gdpr_db import get_db as _get_db
|
||||
from gdpr_db import get_db as _get_db, log_audit_event as _audit
|
||||
DB_OK = True
|
||||
except ImportError:
|
||||
DB_OK = False
|
||||
def _get_db(*a, **kw): return None # type: ignore[misc]
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
|
||||
try:
|
||||
from m365_connector import M365PermissionError
|
||||
@@ -1191,6 +1192,9 @@ def delete_item():
|
||||
reason="manual")
|
||||
_db.delete_item_record(item_id)
|
||||
except Exception: pass
|
||||
_audit("item_delete",
|
||||
f"id={item_id!r} name={item_meta.get('name','')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
return jsonify({"ok": False, "error": "Delete returned unexpected result"})
|
||||
except M365PermissionError:
|
||||
@@ -1285,6 +1289,9 @@ def redact_item():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_audit("item_redact",
|
||||
f"id={item_id!r} name={item_meta.get('name','')!r} spans={redacted}",
|
||||
ip=request.remote_addr or "")
|
||||
logger.info("[redact] %s — %d CPR span(s) redacted", path.name, redacted)
|
||||
return jsonify({"ok": True, "redacted": redacted})
|
||||
|
||||
|
||||
+8
-2
@@ -4,6 +4,10 @@ Scan profiles
|
||||
from __future__ import annotations
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app_config import _profiles_load, _profile_save, _profile_delete, _profile_get
|
||||
try:
|
||||
from gdpr_db import log_audit_event as _audit
|
||||
except ImportError:
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
|
||||
bp = Blueprint("profiles", __name__)
|
||||
|
||||
@@ -21,6 +25,8 @@ def profiles_save():
|
||||
if not profile.get("name"):
|
||||
return jsonify({"error": "name required"}), 400
|
||||
saved = _profile_save(profile)
|
||||
_audit("profile_save", f"name={profile.get('name')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"status": "saved", "profile": saved})
|
||||
|
||||
|
||||
@@ -32,6 +38,8 @@ def profiles_delete():
|
||||
if not key:
|
||||
return jsonify({"error": "name or id required"}), 400
|
||||
ok = _profile_delete(key)
|
||||
if ok:
|
||||
_audit("profile_delete", f"key={key!r}", ip=request.remote_addr or "")
|
||||
return jsonify({"status": "deleted" if ok else "not_found"})
|
||||
|
||||
|
||||
@@ -43,5 +51,3 @@ def profiles_get():
|
||||
if not p:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify({"profile": p})
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ from checkpoint import (
|
||||
bp = Blueprint("scan", __name__)
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from gdpr_db import log_audit_event as _audit
|
||||
except ImportError:
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
|
||||
|
||||
def _maybe_send_auto_email():
|
||||
"""Send the scan report email after a manual scan if auto_email_manual is enabled."""
|
||||
@@ -108,6 +113,9 @@ def scan_start():
|
||||
finally:
|
||||
state._scan_lock.release()
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
_audit("scan_start",
|
||||
f"sources={options.get('sources',[])} profile_id={profile_id!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@@ -115,6 +123,7 @@ def scan_start():
|
||||
def scan_stop():
|
||||
state._scan_abort.set()
|
||||
state._google_scan_abort.set()
|
||||
_audit("scan_stop", "", ip=request.remote_addr or "")
|
||||
return jsonify({"status": "stopping"})
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ Scheduler API routes — multi-job CRUD, status, history, run-now.
|
||||
from __future__ import annotations
|
||||
from flask import Blueprint, jsonify, request
|
||||
import sys, os, threading
|
||||
try:
|
||||
from gdpr_db import log_audit_event as _audit
|
||||
except ImportError:
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
|
||||
bp = Blueprint("scheduler", __name__)
|
||||
|
||||
@@ -52,6 +56,9 @@ def scheduler_jobs_save():
|
||||
_sched().reload()
|
||||
except Exception:
|
||||
pass
|
||||
_audit("scheduler_job_save",
|
||||
f"id={job_id!r} name={jobs[i].get('name','')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True, "job": jobs[i]})
|
||||
# New job
|
||||
job = sm._new_job(data)
|
||||
@@ -61,6 +68,9 @@ def scheduler_jobs_save():
|
||||
_sched().reload()
|
||||
except Exception:
|
||||
pass
|
||||
_audit("scheduler_job_save",
|
||||
f"id={job.get('id','')!r} name={job.get('name','')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True, "job": job})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -81,6 +91,7 @@ def scheduler_jobs_delete():
|
||||
_sched().reload()
|
||||
except Exception:
|
||||
pass
|
||||
_audit("scheduler_job_delete", f"id={job_id!r}", ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -8,6 +8,10 @@ from pathlib import Path
|
||||
from flask import Blueprint, jsonify, request
|
||||
from routes import state
|
||||
from app_config import _load_file_sources, _save_file_sources, _SFTP_KEYS_DIR
|
||||
try:
|
||||
from gdpr_db import log_audit_event as _audit
|
||||
except ImportError:
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
|
||||
try:
|
||||
from file_scanner import store_smb_password, SMB_OK as _SMB_OK
|
||||
@@ -62,10 +66,16 @@ def file_sources_save():
|
||||
if s.get("id") == uid:
|
||||
sources[i] = {**s, **data}
|
||||
_save_file_sources(sources)
|
||||
_audit("source_update",
|
||||
f"name={data.get('name','')!r} type={data.get('source_type','local')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True, "source": sources[i]})
|
||||
data["id"] = data.get("id") or str(_uuid.uuid4())
|
||||
sources.append(data)
|
||||
_save_file_sources(sources)
|
||||
_audit("source_add",
|
||||
f"name={data.get('name','')!r} type={data.get('source_type','local')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True, "source": data})
|
||||
|
||||
|
||||
@@ -79,6 +89,10 @@ def file_sources_delete():
|
||||
deleted = next((s for s in sources if s.get("id") == uid), None)
|
||||
sources = [s for s in sources if s.get("id") != uid]
|
||||
_save_file_sources(sources)
|
||||
if deleted:
|
||||
_audit("source_delete",
|
||||
f"name={deleted.get('name','')!r} type={deleted.get('source_type','local')!r}",
|
||||
ip=request.remote_addr or "")
|
||||
|
||||
# Clean up key file if this was an SFTP key-auth source
|
||||
if deleted and deleted.get("sftp_key_path"):
|
||||
|
||||
+17
-2
@@ -19,6 +19,10 @@ from app_config import (
|
||||
verify_interface_pin,
|
||||
clear_interface_pin,
|
||||
)
|
||||
try:
|
||||
from gdpr_db import log_audit_event as _audit
|
||||
except ImportError:
|
||||
def _audit(*a, **kw): pass # type: ignore[misc]
|
||||
|
||||
bp = Blueprint("viewer", __name__)
|
||||
|
||||
@@ -119,6 +123,8 @@ def create_token():
|
||||
if valid_to:
|
||||
scope["valid_to"] = valid_to
|
||||
entry = create_viewer_token(label=label, expires_days=expires_days, scope=scope)
|
||||
_audit("token_create", f"label={label!r} scope={scope}",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify(entry), 201
|
||||
|
||||
|
||||
@@ -129,6 +135,7 @@ def delete_token(token: str):
|
||||
removed = revoke_viewer_token(token)
|
||||
if not removed:
|
||||
return jsonify({"error": "token not found"}), 404
|
||||
_audit("token_revoke", f"token={token[:8]}...", ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@@ -162,10 +169,13 @@ def pin_set():
|
||||
return jsonify({"error": "pin required"}), 400
|
||||
if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8):
|
||||
return jsonify({"error": "PIN must be 4–8 digits"}), 400
|
||||
if get_viewer_pin_hash():
|
||||
had_pin = bool(get_viewer_pin_hash())
|
||||
if had_pin:
|
||||
if not verify_viewer_pin(str(body.get("current_pin", "")).strip()):
|
||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||
set_viewer_pin(new_pin)
|
||||
_audit("viewer_pin_change" if had_pin else "viewer_pin_set", "",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@@ -177,6 +187,7 @@ def pin_clear():
|
||||
if not verify_viewer_pin(str(body.get("current_pin", "")).strip()):
|
||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||
clear_viewer_pin()
|
||||
_audit("viewer_pin_clear", "", ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@@ -200,10 +211,13 @@ def interface_pin_set():
|
||||
return jsonify({"error": "pin required"}), 400
|
||||
if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8):
|
||||
return jsonify({"error": "PIN must be 4–8 digits"}), 400
|
||||
if get_interface_pin_hash():
|
||||
had_ipin = bool(get_interface_pin_hash())
|
||||
if had_ipin:
|
||||
if not verify_interface_pin(str(body.get("current_pin", "")).strip()):
|
||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||
set_interface_pin(new_pin)
|
||||
_audit("interface_pin_change" if had_ipin else "interface_pin_set", "",
|
||||
ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@@ -215,6 +229,7 @@ def interface_pin_clear():
|
||||
if not verify_interface_pin(str(body.get("current_pin", "")).strip()):
|
||||
return jsonify({"error": "current PIN is incorrect"}), 403
|
||||
clear_interface_pin()
|
||||
_audit("interface_pin_clear", "", ip=request.remote_addr or "")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user