Approve an address BEFORE the person registers and their sign-up produces no queue row at all — `_decide` only takes a still-pending invite — so the Access-requests screen sat empty, the tab badge read zero, and the only working move was re-typing the address into the whitelist box. Nobody would guess that, and on 2026-09-03 nobody did. This is roadmap UX-25, where two earlier attempts were reverted after review. Both moved AUTHORITY: auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval. What was actually missing was visibility, so this changes nothing about what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the address; they just had no way to know they had to. The section lists every account that cannot sign in yet and says WHICH gate is shut, because the two dead ends look the same from the admin's chair: waiting for activation, or waiting on a confirmation mail. Naming the unconfirmed ones is not decoration — on a no-SMTP instance that is the difference between the owner and whoever registered the address first (SEC-15), and an admin about to let someone in should see it. A person the queue above already covers is deliberately NOT listed here; two buttons for one person is worse than one. Activate is gated on `is_allowed` — the same function the sign-in paths consult, in core, so the button that appears and the badge that counts cannot disagree. Without that gate it would manufacture an inconsistent account: password sign-in only checks `is_active` and would let them in, while their Google sign-in would be turned away and silently reopened as a fresh access request. Letting in someone with no approved invite stays the whitelist box's job, which says so out loud. The badge counts activations SEPARATELY from pending invites rather than widening an existing field's meaning, and only counts what genuinely waits on the admin: an unconfirmed account waits on its owner, so it is listed with a re-send but does not nag. The admin-side resend says plainly what happened, including refusing when the instance has no working SMTP — mail is fail-soft everywhere else, and that silence is exactly what left the 0.78.5 sign-ups with nothing to fall back on. `send_verification_link` is now one function with three callers (registration, the public resend, this one), so the link's shape and TTL cannot drift by who triggered it. UAT on localdev, both languages: the UX-25 shape reproduced end to end (approve the address, then register), the badge counting it, Activate letting them in and dropping the badge, the row losing its Activate but keeping the re-send, a fresh token issued by the re-send, and both actions in the audit log. Test data removed.
95 lines
3.7 KiB
Python
95 lines
3.7 KiB
Python
"""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 `<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"
|
|
# E9.5 S5: 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"
|
|
INVITE_APPROVE = "invite_approve"
|
|
INVITE_DENY = "invite_deny"
|
|
INVITE_ADD = "invite_add"
|
|
INVITE_DELETE = "invite_delete"
|
|
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"
|
|
# 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"
|
|
|
|
|
|
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
|