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

@ -6,7 +6,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app.audit import AuditAction
from app.auth import (
admin_user,
count_admins,
@ -74,6 +76,14 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv.status = "approved" if approved else "denied"
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
audit.record(
db,
admin.id,
AuditAction.INVITE_APPROVE if approved else AuditAction.INVITE_DENY,
target_type="invite",
target_id=inv.id,
summary=inv.email,
)
db.commit()
return inv
@ -117,6 +127,7 @@ def add_invite(
inv.status = "approved"
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
audit.record(db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email)
db.commit()
_activate_user_by_email(db, email)
return _serialize(inv)
@ -174,6 +185,12 @@ def set_user_role(
):
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role
if changed:
audit.record(
db, admin.id, AuditAction.USER_ROLE_CHANGE,
target_type="user", target_id=target.id, summary=target.email,
before={"role": target.role}, after={"role": role},
)
target.role = role
db.commit()
if changed:
@ -210,6 +227,12 @@ def set_user_suspended(
):
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
was_suspended = target.is_suspended
if was_suspended != suspended:
audit.record(
db, admin.id,
AuditAction.USER_SUSPEND if suspended else AuditAction.USER_UNSUSPEND,
target_type="user", target_id=target.id, summary=target.email,
)
target.is_suspended = suspended
db.commit()
if was_suspended and not suspended:
@ -240,6 +263,11 @@ def admin_delete_user(
)
if target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1:
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
audit.record(
db, admin.id, AuditAction.USER_DELETE,
target_type="user", target_id=target.id, summary=target.email,
)
purge_user(db, target, background)
return {"deleted": user_id}
@ -289,6 +317,7 @@ def add_demo_whitelist(
added_by=admin.email,
)
db.add(row)
audit.record(db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email)
db.commit()
return _serialize_demo(row)
@ -296,11 +325,12 @@ def add_demo_whitelist(
@router.delete("/demo/whitelist/{entry_id}")
def remove_demo_whitelist(
entry_id: int,
_: User = Depends(admin_user),
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
row = db.get(DemoWhitelist, entry_id)
if row is not None:
audit.record(db, admin.id, AuditAction.DEMO_REMOVE, target_type="demo", target_id=row.email, summary=row.email)
db.delete(row)
db.commit()
return {"deleted": entry_id}
@ -375,21 +405,30 @@ def reset_demo_state(db: Session, demo: User) -> int:
@router.post("/demo/reset")
def reset_demo(
_: User = Depends(admin_user), db: Session = Depends(get_db)
admin: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
same automatically (admin-tunable interval); this is the admin's on-demand button."""
demo = get_or_create_demo_user(db)
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}
seeded = reset_demo_state(db, demo)
audit.record(db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded")
db.commit()
return {"reset": True, "playlists_seeded": seeded}
@router.post("/purge-discovery")
def purge_discovery(
_: User = Depends(admin_user), db: Session = Depends(get_db)
admin: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The
scheduled discovery-cleanup job does the same on its interval; this is the on-demand button."""
from app.sync.explore import purge_unkept_explored, purge_unkept_search
return {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}
result = {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}
audit.record(
db, admin.id, AuditAction.DISCOVERY_PURGE,
summary=", ".join(f"{k}: {v}" for k, v in result.items()) or None, after=result,
)
db.commit()
return result