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

View file

@ -0,0 +1,71 @@
"""Admin audit log API: read the append-only who/what/when trail, and clear it.
Admin-only. The client renders these rows in a DataTable (client-side sort/filter/paginate), so
this returns the most recent window of rows (capped) plus the true total, rather than paging
server-side the log stays small in practice (state-changing runs + admin actions only)."""
from fastapi import APIRouter, Depends
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import audit
from app.audit import AuditAction
from app.db import get_db
from app.models import AuditLog, User
from app.routes.admin import admin_user
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
# Cap the client-side window. The audit log is small by design; if it ever outgrows this we'd
# switch the DataTable to server-side paging.
MAX_ROWS = 2000
def _serialize(row: AuditLog, actors: dict[int, str]) -> dict:
return {
"id": row.id,
"created_at": row.created_at.isoformat() if row.created_at else None,
# actor_id NULL = the scheduler/background did it (client shows "System"); a set id that
# can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present.
"actor": actors.get(row.actor_id) if row.actor_id is not None else None,
"action": row.action,
"target_type": row.target_type,
"target_id": row.target_id,
"summary": row.summary,
"before": row.before,
"after": row.after,
}
@router.get("")
def list_audit(
limit: int = 500,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
limit = max(1, min(limit, MAX_ROWS))
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
rows = (
db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit))
.scalars()
.all()
)
actor_ids = {r.actor_id for r in rows if r.actor_id is not None}
actors: dict[int, str] = {}
if actor_ids:
actors = dict(
db.execute(select(User.id, User.email).where(User.id.in_(actor_ids))).all()
)
return {
"items": [_serialize(r, actors) for r in rows],
"total": total,
"returned": len(rows),
}
@router.delete("")
def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence)."""
n = db.execute(delete(AuditLog)).rowcount or 0
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
db.commit()
return {"cleared": n}

View file

@ -3,8 +3,10 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app import sysconfig
from app.audit import AuditAction
from app.db import get_db
from app.models import User
from app.routes.admin import admin_user
@ -21,7 +23,7 @@ def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) ->
def set_config(
key: str,
payload: dict,
_: User = Depends(admin_user),
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
s = sysconfig.spec(key)
@ -34,22 +36,40 @@ def set_config(
status_code=400,
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
)
old = None if s.secret else sysconfig.get(db, key)
try:
sysconfig.set_value(db, key, payload["value"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
# Never log a secret's value — only that it was changed, by whom.
new = None if s.secret else sysconfig.get(db, key)
audit.record(
db, admin.id, AuditAction.CONFIG_SET, target_type="config", target_id=key,
summary=f"{key} = (secret updated)" if s.secret else f"{key} = {new}",
before=None if s.secret else {"value": old},
after=None if s.secret else {"value": new},
)
db.commit()
return sysconfig.describe(db)
@router.delete("/{key}")
def reset_config(
key: str,
_: User = Depends(admin_user),
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
if sysconfig.spec(key) is None:
s = sysconfig.spec(key)
if s is None:
raise HTTPException(status_code=404, detail="Unknown config key")
old = None if s.secret else sysconfig.get(db, key)
sysconfig.reset(db, key)
audit.record(
db, admin.id, AuditAction.CONFIG_RESET, target_type="config", target_id=key,
summary=f"{key} → default",
before=None if s.secret else {"value": old},
)
db.commit()
return sysconfig.describe(db)

View file

@ -5,7 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app import quota, state, sysconfig
from app import audit, quota, state, sysconfig
from app.audit import AuditAction
from app.config import settings
from app.db import get_db
from app.models import Channel, Subscription, User, Video
@ -28,7 +29,7 @@ router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
def set_job_interval(
job_id: str,
payload: dict,
_: User = Depends(admin_user),
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
@ -45,6 +46,11 @@ def set_job_interval(
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
)
applied = apply_interval(db, job_id, minutes)
audit.record(
db, admin.id, AuditAction.SCHEDULER_INTERVAL, target_type="job", target_id=job_id,
summary=f"{job_id}{applied} min/run", after={"interval_minutes": applied},
)
db.commit()
return {"id": job_id, "interval_minutes": applied}
@ -74,7 +80,7 @@ def run_all_now(user: User = Depends(admin_user)) -> dict:
@router.patch("/maintenance")
def set_maintenance_settings(
payload: dict,
_: User = Depends(admin_user),
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
@ -91,7 +97,10 @@ def set_maintenance_settings(
f"{state.MAINTENANCE_BATCH_MAX}"
),
)
return {"revalidate_batch": state.set_maintenance_batch(db, value)}
new = state.set_maintenance_batch(db, value)
audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new})
db.commit()
return {"revalidate_batch": new}
@router.get("")

View file

@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, case, func, select, update
from sqlalchemy.orm import Session
from app import quota, state
from app import audit, quota, state
from app.audit import AuditAction
from app.auth import admin_user, current_user, has_read_scope, require_human
from app.db import get_db
from app.models import Channel, Subscription, User, Video
@ -188,12 +189,16 @@ def deep_all(
@router.post("/pause")
def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
state.set_sync_paused(db, True)
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
db.commit()
return {"paused": True}
@router.post("/resume")
def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
state.set_sync_paused(db, False)
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
db.commit()
return {"paused": False}