feat: add audit, throttle and session foundations

Audit entries written only inside the caller's transaction, database
throttle counters that refuse to run inside a transaction that could roll
back, and a database session store with the user key in its own column,
end_sessions and idle and absolute limits. Ticket 1.3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
Ebbe Baß
2026-10-15 15:43:43 +02:00
co-authored by Claude Sonnet 5.5
parent 003ce6e0c2
commit 73505ac717
22 changed files with 803 additions and 2 deletions
+79
View File
@@ -0,0 +1,79 @@
"""Audit service: the only writer of audit entries (AD-4, AD-19)."""
from __future__ import annotations
from typing import Any
from django.db import connection, transaction
from .models import AuditEntry
_FORBIDDEN_FRAGMENTS = ("password", "passwd", "pwd", "token", "secret")
class AuditOutsideTransaction(RuntimeError):
"""`record` was called without an open transaction, so it cannot share the change's fate."""
class AuditInsideTransaction(RuntimeError):
"""`record_failure` was called in an open transaction, where a rollback would erase it."""
def _check_detail(value: Any, path: str = "detail") -> None:
if isinstance(value, dict):
for key, inner in value.items():
lowered = str(key).lower()
if any(fragment in lowered for fragment in _FORBIDDEN_FRAGMENTS):
raise ValueError(f"{path}: key {key!r} may hold a secret and is not allowed")
_check_detail(inner, f"{path}.{key}")
elif isinstance(value, list | tuple):
for item in value:
_check_detail(item, path)
def _build(actor: str, action: str, target: str, ip: str, detail: dict | None) -> AuditEntry:
detail = detail or {}
_check_detail(detail)
return AuditEntry(
actor=(actor or "").lower(),
action=action,
target=target or "",
ip=ip or "",
detail=detail,
)
def record(
actor: str,
action: str,
*,
target: str = "",
ip: str = "",
detail: dict | None = None,
) -> AuditEntry:
"""Write an entry inside the caller's open transaction; it rolls back with the change."""
entry = _build(actor, action, target, ip, detail)
if not connection.in_atomic_block:
raise AuditOutsideTransaction("audit.record() must run inside transaction.atomic()")
entry.save()
return entry
def record_failure(
actor: str,
action: str,
*,
target: str = "",
ip: str = "",
detail: dict | None = None,
) -> AuditEntry:
"""Write a failure entry in its own transaction, after the failed change was rolled back."""
entry = _build(actor, action, target, ip, detail)
if connection.in_atomic_block:
raise AuditInsideTransaction(
"audit.record_failure() must run after the failed change was rolled back, "
"outside any open transaction"
)
with transaction.atomic():
entry.save()
return entry