feat(audit): admin audit log (Notification P3)

Append-only who/what/when trail for admin actions + scheduler run summaries,
generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never
per-row (a scheduler run touching thousands of videos is ONE row).

Backend:
- migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action,
  target_type/id, summary, before/after JSON, created_at).
- app/audit.py: AuditAction constants (single source of truth, i18n'd on the
  client) + record() (adds to the caller's txn) + gc(retention_days).
- producers wired: role change / suspend / delete / invite approve-deny-add /
  demo add-remove-reset / discovery purge (admin.py); config set/reset — secret
  VALUES never logged, key only (config.py); scheduler interval + maintenance
  tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job()
  choke point writes ONE run-summary row per run — manual runs + errors always,
  automatic runs only when they changed something (no no-op-poll spam).
- retention: audit_gc scheduler job prunes rows older than audit_retention_days
  (sysconfig, default 180; 0 = keep forever).
- admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails
  resolved, System for background) + clear (leaves one tamper-evidence row).

Frontend:
- new admin-only "Audit log" nav page (ScrollText) using the shared DataTable
  (first admin page to reuse it) — sort/filter/paginate/persist, action select
  filter, actor text filter, before→after detail, Clear-all with confirm.
- AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type.
- trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group +
  scheduler job labels; Audit config group (retention days).
This commit is contained in:
npeter83 2026-07-12 07:32:41 +02:00
parent c1d38eea21
commit 318fdc4812
35 changed files with 720 additions and 29 deletions

View file

@ -9,7 +9,8 @@ from typing import Callable
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from app import progress, quota
from app import audit, progress, quota
from app.audit import AuditAction
from app.config import settings
from app.db import SessionLocal
from app.downloads.gc import run_download_gc
@ -66,6 +67,7 @@ JOB_INTERVALS: dict[str, int] = {
"plex_sync": settings.plex_sync_interval_min,
"plex_watch_sync": settings.plex_watch_sync_interval_min,
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
"audit_gc": settings.audit_gc_minutes,
}
# Sane bounds for an admin-set interval (minutes).
@ -127,6 +129,35 @@ def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | Non
logger.exception("failed to write completion notification for job %s", job_id)
def _run_changed(result) -> bool:
"""Did an automatic job run actually do something worth an audit row? (No-op polls don't.)"""
if result is None:
return False
if isinstance(result, dict):
return any(bool(v) for v in result.values())
if isinstance(result, (int, float)):
return result != 0
return bool(result)
def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
"""Write ONE run-summary audit row per job run (never per-item). Manual runs (an admin
clicked "run now" actor_id set) and errors are always logged; automatic runs only when
they changed something, so routine no-op polls don't spam the trail. Best-effort."""
if actor_id is None and status == "ok" and not _run_changed(result):
return
try:
audit.record(
db, actor_id, AuditAction.JOB_RUN, target_type="job", target_id=job_id,
summary=f"{job_id}: {status}" + (f"{summary}" if summary else ""),
after={"status": status, "summary": summary},
)
db.commit()
except Exception:
db.rollback()
logger.exception("failed to write audit row for job %s", job_id)
def _job(name: str, fn) -> None:
db = SessionLocal()
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
@ -142,6 +173,7 @@ def _job(name: str, fn) -> None:
last_result="paused", progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused")
_audit_run(db, actor_id, name, "skipped", "sync paused", None)
return
with progress.bind(sink):
result = fn(db)
@ -151,6 +183,7 @@ def _job(name: str, fn) -> None:
last_result=summary, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary)
_audit_run(db, actor_id, name, "ok", summary, result)
except Exception as exc:
db.rollback()
logger.exception("job %s failed", name)
@ -159,6 +192,7 @@ def _job(name: str, fn) -> None:
last_error=err, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "error", err)
_audit_run(db, actor_id, name, "error", err, None)
finally:
db.close()
@ -307,6 +341,16 @@ def _plex_watch_reconcile_job() -> None:
_job("plex_watch_reconcile", run_plex_watch_reconcile)
def _audit_gc_job() -> None:
# Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep
# forever). Pure DB, no quota.
def work(db):
from app import sysconfig
return {"reaped": audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))}
_job("audit_gc", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = {
@ -324,6 +368,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"plex_sync": _plex_sync_job,
"plex_watch_sync": _plex_watch_sync_job,
"plex_watch_reconcile": _plex_watch_reconcile_job,
"audit_gc": _audit_gc_job,
}