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
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuditConfig(AppConfig):
name = "portal.audit"
label = "audit"
+30
View File
@@ -0,0 +1,30 @@
# Generated by Django 5.2.18 on 2026-10-15 13:40
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AuditEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor', models.CharField(db_index=True, max_length=256)),
('action', models.CharField(db_index=True, max_length=128)),
('target', models.CharField(blank=True, default='', max_length=256)),
('ip', models.CharField(blank=True, default='', max_length=64)),
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
('detail', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['-created_at', '-id'],
},
),
]
View File
+16
View File
@@ -0,0 +1,16 @@
from django.db import models
from django.utils import timezone
class AuditEntry(models.Model):
"""Who did what to whom, when and from where. Written only by `audit.services` (AD-4, AD-19)."""
actor = models.CharField(max_length=256, db_index=True) # lower-case key
action = models.CharField(max_length=128, db_index=True)
target = models.CharField(max_length=256, blank=True, default="")
ip = models.CharField(max_length=64, blank=True, default="")
created_at = models.DateTimeField(default=timezone.now, db_index=True) # UTC
detail = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at", "-id"]
+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