CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 13s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 32s
Rule 32 "Comments describe the product to a stranger" sweep over src/, alembic/ and tests/. Comments and docstrings now say what the code does and the reason as a present-tense constraint, briefly. Removed: - issue, task, note, rule, milestone and family-idea references - "used to" / before-and-after narration - quotes of decisions and incident stories from the deploying instance - paragraph-length arguments, where a line or two carries the fact Kept every hazard as a present-tense fact, e.g. timing oracles, the proxy hop direction, and migrations that must stay frozen. Also: - Three test names lose "still"/"used to"/"pre_m304" phrasing. - pyproject's description drops "FabledSword family". The history these comments carried stays in Scribe and in git log. A comment-stripping diff (Scribe snippet #6226 "codeonly.py — prove a diff changed only comments") shows no code changed apart from the renamed tests and the description string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
3.7 KiB
Python
108 lines
3.7 KiB
Python
"""The audit log: what happened to accounts, kept where an admin can read it.
|
|
|
|
Sign-ins, failed sign-ins, password changes and resets, devices linked and
|
|
unlinked, invites made and revoked. Each is still written to the app log as it
|
|
happens; this keeps a copy in `audit_events` for `audit_retention_days`, which the
|
|
app log can't promise past the container's log rotation.
|
|
|
|
What is NOT recorded here: a throttled attempt (429). That stays in the app log
|
|
only. A throttle trip is the answer to a flood, and a row per refused request
|
|
would make each one of the flood cost a database write.
|
|
|
|
`record` opens its own session and commits on its own, so an event outlives the
|
|
caller's transaction. A refused registration rolls back, but its refusal is still
|
|
recorded. A failure to record is logged and swallowed: the audit log is never the
|
|
reason a sign-in fails.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import delete, select
|
|
|
|
from .common import expired_before, iso
|
|
from .db import session_scope
|
|
from .models.audit_event import AuditEvent
|
|
from .proxy import client_address
|
|
from .settings import get_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The events, by name. The web Activity list words each one; a new event needs a
|
|
# line there too (`frontend/src/components/ActivityList.vue`).
|
|
SIGN_IN = "sign_in"
|
|
SIGN_IN_FAILED = "sign_in_failed"
|
|
REGISTERED = "registered"
|
|
REGISTRATION_REFUSED = "registration_refused"
|
|
RESET_REQUESTED = "reset_requested"
|
|
RESET_LINK_MADE = "reset_link_made"
|
|
PASSWORD_RESET = "password_reset"
|
|
RESET_REFUSED = "reset_refused"
|
|
PASSWORD_CHANGED = "password_changed"
|
|
PASSWORD_CHANGE_REFUSED = "password_change_refused"
|
|
SIGNED_OUT_ELSEWHERE = "signed_out_elsewhere"
|
|
DEVICE_LINKED = "device_linked"
|
|
DEVICE_UNLINKED = "device_unlinked"
|
|
INVITE_CREATED = "invite_created"
|
|
INVITE_REVOKED = "invite_revoked"
|
|
|
|
# One page of the Activity list. Older events are reached with `before`.
|
|
PAGE = 100
|
|
|
|
|
|
async def record(
|
|
event: str,
|
|
*,
|
|
user_id: uuid.UUID | None = None,
|
|
email: str | None = None,
|
|
detail: str | None = None,
|
|
) -> None:
|
|
"""Keep one event. Call it from inside a request: the address is read from it."""
|
|
try:
|
|
async with session_scope() as db:
|
|
db.add(
|
|
AuditEvent(
|
|
event=event,
|
|
user_id=user_id,
|
|
email=email or None,
|
|
address=client_address(),
|
|
detail=detail,
|
|
)
|
|
)
|
|
await db.commit()
|
|
except Exception:
|
|
logger.exception("audit event %s was not recorded", event)
|
|
|
|
|
|
def _serialize(e: AuditEvent) -> dict:
|
|
return {
|
|
"id": str(e.id),
|
|
"at": iso(e.at),
|
|
"event": e.event,
|
|
"email": e.email,
|
|
"address": e.address,
|
|
"detail": e.detail,
|
|
}
|
|
|
|
|
|
async def recent(db, before: datetime | None = None) -> list[dict]:
|
|
"""The newest page of events, or the page older than `before`."""
|
|
query = select(AuditEvent).order_by(AuditEvent.at.desc(), AuditEvent.id.desc()).limit(PAGE)
|
|
if before is not None:
|
|
query = query.where(AuditEvent.at < before)
|
|
return [_serialize(e) for e in (await db.scalars(query)).all()]
|
|
|
|
|
|
async def sweep_once(*, now: datetime | None = None) -> int:
|
|
"""Delete events older than `audit_retention_days`; 0 keeps them forever."""
|
|
async with session_scope() as db:
|
|
days = int(await get_setting(db, "audit_retention_days"))
|
|
cutoff = expired_before(now or datetime.now(timezone.utc), days)
|
|
if cutoff is None:
|
|
return 0
|
|
deleted = (await db.execute(delete(AuditEvent).where(AuditEvent.at < cutoff))).rowcount
|
|
await db.commit()
|
|
return deleted
|