"""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" # 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" # The Files library (`routes.files`): what a user put in, took out, kept or reorganised. Ids # only — a file's or folder's name is the user's own text, not the admin's to read here. FILE_UPLOAD = "file_upload" FILE_DELETE = "file_delete" FILE_KEEP = "file_keep" FILE_UNKEEP = "file_unkeep" FILE_MOVE = "file_move" FILE_FOLDER_CREATE = "file_folder_create" FILE_FOLDER_RENAME = "file_folder_rename" FILE_FOLDER_RETENTION = "file_folder_retention" FILE_FOLDER_DELETE = "file_folder_delete" # A machine's token to the library (`routes.tokens`): made (its folders and rights) or revoked. # What a token then did is the file rows above, their summary naming it. API_TOKEN_CREATE = "api_token_create" API_TOKEN_REVOKE = "api_token_revoke" # A public `/watch` link to a file or a folder (`routes.downloads`): made, its settings changed, # revoked. # Never its token or password — only whether it has one. DOWNLOAD_LINK_CREATE = "download_link_create" DOWNLOAD_LINK_UPDATE = "download_link_update" DOWNLOAD_LINK_REVOKE = "download_link_revoke" 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