"""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 `_` audit keys — the single source of truth. User-facing labels are resolved on the client via i18n `auditActions.` (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, target_id=None if target_id is None else str(target_id), 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