The owner-filter check missed an aliased() model, an import alias, .in_() and filter_by; four live inline filters on aliased VideoState go through owned_by now. Cross-unit writes are seen under any import name, through sa.update and via __table__. Core modules may commit only where CORE_COMMITS names the reason; trim_read and audit.gc flush now and their callers commit. The pin target, the debug-session header and a link's target fetch through owned_row. The docs claim what the fence holds, and say what is beyond it.
119 lines
5.1 KiB
Python
119 lines
5.1 KiB
Python
"""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 `<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"
|
|
# 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
|