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
80 lines
2.4 KiB
Python
80 lines
2.4 KiB
Python
"""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
|