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:
co-authored by
Claude Sonnet 5.5
parent
003ce6e0c2
commit
73505ac717
@@ -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
|
||||
Reference in New Issue
Block a user