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:
parent
c1d38eea21
commit
318fdc4812
35 changed files with 720 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue