"""Admin audit trail: an append-only record of who changed what and when. Design (locked): log at ACTION granularity, NEVER per-row — an action that touches thousands of rows is ONE audit row summarising the outcome. Background runs are not actions: they have their own history (app/jobruns.py). Secrets are never stored (config changes log the key name only). Each row keeps the request that made it, so the request's log lines are one click away. `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 import logs 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, request_id=logs.current_request_id(), ) ) 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). Flushes; the job commits.""" 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)) return result.rowcount or 0