"""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" # An admin let a stuck registrant in, or re-sent their confirmation link. Both are # recorded because both are the admin acting ON someone's access, not merely reading a list. USER_ACTIVATE = "user_activate" USER_VERIFY_RESEND = "user_verify_resend" USER_SETPW_LINK = "user_setpw_link" INVITE_APPROVE = "invite_approve" INVITE_DENY = "invite_deny" INVITE_ADD = "invite_add" INVITE_DELETE = "invite_delete" # The admin chose to tell someone their address is whitelisted. Adding the address is # silent, so this is the act that actually reaches a person — and the one worth a record. INVITE_SEND = "invite_send" 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" # Per-user download cookie/auth credential set or removed (the value is NEVER recorded) DOWNLOAD_CREDENTIAL_SET = "download_credential_set" DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete" # The log itself was cleared (a single row survives the clear as tamper-evidence) AUDIT_CLEAR = "audit_clear" # A user sent a debug session's issue to the tracker — the one act that takes their log # off this instance, so it is recorded (target = the session, summary = the issue's URL). ISSUE_CREATE = "issue_create" 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, # 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], 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