The funnel's common ordering made a person open TWO messages: one saying their access was approved, another asking them to confirm their address. The approval mail now branches on what they actually still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse than a plain one: telling someone to sign in while their address is unconfirmed contradicts the screen that will refuse them. That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval — it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in and sets no password. The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here too, and it was never a delivery failure: re-registering an address that already had an unconfirmed account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend already does exactly this for the same input. Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a possibility — usually already agreed with the person — and a row can sit approved for weeks with nobody registering; a mail fired automatically there would be nudging someone who never asked. Telling them is its own act on its own button, `invited_at` records it so a second click is a decision rather than an accident, and the button only appears where nobody is behind the address yet: "the door is open, come in" is meaningless to someone already inside. Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may sign in, and this account still can't", which leaves an undecided request with the queue that owns it. And the invitation button was offered on every approved row, half of which are existing users. Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail always has, while this one left the reader to discover both by meeting a dead link. The card's "didn't get the email?" moves up under the form, and the three waiting messages name the spam folder, which is where a first message to a fresh recipient sometimes lands. Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a preference nobody recorded cannot be backfilled later. UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed nobody; the invitation recorded itself and the row then warned that sending again would write twice. Test data removed.
98 lines
3.9 KiB
Python
98 lines
3.9 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"
|
|
# E9.5 S6: 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"
|
|
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
|