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).
2026-07-12 07:32:41 +02:00
|
|
|
"""Admin/scheduler audit trail: an append-only record of who changed what and when.
|
|
|
|
|
|
|
|
|
|
Design (locked): log at ACTION / job-run granularity, NEVER per-row — a scheduler run that
|
|
|
|
|
touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are
|
|
|
|
|
only logged when they actually changed something (or errored); all admin actions and all manual
|
|
|
|
|
runs are always logged. Secrets are never stored (config changes log the key name only).
|
|
|
|
|
|
|
|
|
|
`record()` only db.add()s the row — the caller commits it in the SAME transaction as the
|
|
|
|
|
mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops
|
|
|
|
|
its audit row too).
|
|
|
|
|
"""
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import delete
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app.models import AuditLog
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AuditAction:
|
|
|
|
|
"""Canonical `<entity>_<action>` audit keys — the single source of truth. User-facing labels
|
|
|
|
|
are resolved on the client via i18n `auditActions.<key>` (mirrors quota.QuotaAction)."""
|
|
|
|
|
|
|
|
|
|
# User / roles / access
|
|
|
|
|
USER_ROLE_CHANGE = "user_role_change"
|
|
|
|
|
USER_SUSPEND = "user_suspend"
|
|
|
|
|
USER_UNSUSPEND = "user_unsuspend"
|
|
|
|
|
USER_DELETE = "user_delete"
|
|
|
|
|
INVITE_APPROVE = "invite_approve"
|
|
|
|
|
INVITE_DENY = "invite_deny"
|
|
|
|
|
INVITE_ADD = "invite_add"
|
|
|
|
|
DEMO_ADD = "demo_add"
|
|
|
|
|
DEMO_REMOVE = "demo_remove"
|
|
|
|
|
DEMO_RESET = "demo_reset"
|
|
|
|
|
DISCOVERY_PURGE = "discovery_purge"
|
|
|
|
|
# Config
|
|
|
|
|
CONFIG_SET = "config_set"
|
|
|
|
|
CONFIG_RESET = "config_reset"
|
|
|
|
|
# Scheduler / sync
|
|
|
|
|
SCHEDULER_INTERVAL = "scheduler_interval"
|
|
|
|
|
MAINTENANCE_TUNE = "maintenance_tune"
|
|
|
|
|
SYNC_PAUSE = "sync_pause"
|
|
|
|
|
SYNC_RESUME = "sync_resume"
|
|
|
|
|
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
|
|
|
|
JOB_RUN = "job_run"
|
|
|
|
|
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
|
|
|
|
AUDIT_CLEAR = "audit_clear"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def record(
|
|
|
|
|
db: Session,
|
|
|
|
|
actor_id: int | None,
|
|
|
|
|
action: str,
|
|
|
|
|
*,
|
|
|
|
|
target_type: str | None = None,
|
|
|
|
|
target_id: str | int | None = None,
|
|
|
|
|
summary: str | None = None,
|
|
|
|
|
before: dict | None = None,
|
|
|
|
|
after: dict | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Append an audit row (NOT committed — the caller commits it with the mutation)."""
|
|
|
|
|
db.add(
|
|
|
|
|
AuditLog(
|
|
|
|
|
actor_id=actor_id,
|
|
|
|
|
action=action,
|
|
|
|
|
target_type=target_type,
|
2026-07-12 07:47:07 +02:00
|
|
|
# Truncate to the column widths so an over-long target/summary can never abort the
|
|
|
|
|
# mutation this row is committed alongside (an audit row must not break what it observes).
|
|
|
|
|
target_id=None if target_id is None else str(target_id)[:128],
|
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).
2026-07-12 07:32:41 +02:00
|
|
|
summary=(summary or None) and summary[:255],
|
|
|
|
|
before=before,
|
|
|
|
|
after=after,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gc(db: Session, retention_days: int) -> int:
|
|
|
|
|
"""Delete audit rows older than `retention_days`. Returns the number removed. A
|
|
|
|
|
retention_days <= 0 disables pruning (keep forever)."""
|
|
|
|
|
if retention_days <= 0:
|
|
|
|
|
return 0
|
|
|
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
|
|
|
|
result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff))
|
|
|
|
|
db.commit()
|
|
|
|
|
return result.rowcount or 0
|