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 AccountsConfig(AppConfig):
name = "portal.accounts"
label = "accounts"
+20
View File
@@ -0,0 +1,20 @@
"""Absolute session limit; the idle limit is SESSION_COOKIE_AGE with save-every-request."""
import time
from django.conf import settings
from .session_store import CREATED_AT
class AbsoluteSessionLimitMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
session = request.session
created = session.get(CREATED_AT)
if created is not None and time.time() - created > settings.SESSION_ABSOLUTE_SECONDS:
# flush() deletes the row; the empty session makes SessionMiddleware clear the cookie.
session.flush()
return self.get_response(request)
@@ -0,0 +1,44 @@
# 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='UserSession',
fields=[
('session_key', models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name='session key')),
('session_data', models.TextField(verbose_name='session data')),
('expire_date', models.DateTimeField(db_index=True, verbose_name='expire date')),
('username', models.CharField(blank=True, db_index=True, default='', max_length=256)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
],
options={
'verbose_name': 'session',
'verbose_name_plural': 'sessions',
'abstract': False,
},
),
migrations.CreateModel(
name='ThrottleCounter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('endpoint', models.CharField(max_length=64)),
('subject', models.CharField(max_length=256)),
('count', models.PositiveIntegerField(default=0)),
('window_started', models.DateTimeField(default=django.utils.timezone.now)),
('blocked_until', models.DateTimeField(blank=True, null=True)),
],
options={
'constraints': [models.UniqueConstraint(fields=('endpoint', 'subject'), name='uniq_throttle_subject')],
},
),
]
+31
View File
@@ -0,0 +1,31 @@
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models
from django.utils import timezone
class UserSession(AbstractBaseSession):
"""Database session with the user key in its own column (AD-6)."""
username = models.CharField(max_length=256, blank=True, default="", db_index=True)
created_at = models.DateTimeField(default=timezone.now) # UTC
@classmethod
def get_session_store_class(cls):
from .session_store import SessionStore
return SessionStore
class ThrottleCounter(models.Model):
"""One row per (endpoint, subject); subject is `user:<key>` or `ip:<address>` (AD-7)."""
endpoint = models.CharField(max_length=64)
subject = models.CharField(max_length=256)
count = models.PositiveIntegerField(default=0)
window_started = models.DateTimeField(default=timezone.now)
blocked_until = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["endpoint", "subject"], name="uniq_throttle_subject")
]
+29
View File
@@ -0,0 +1,29 @@
"""Database session store that fills the username and created_at columns."""
from __future__ import annotations
from datetime import UTC, datetime
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.utils import timezone
from .models import UserSession
USER_KEY = "session_user_key"
USER_SAM = "session_user_sam"
CREATED_AT = "session_created_at" # epoch seconds, UTC
class SessionStore(DBSessionStore):
@classmethod
def get_model_class(cls):
return UserSession
def create_model_instance(self, data):
obj = super().create_model_instance(data)
obj.username = str(data.get(USER_KEY, "")).lower()
created = data.get(CREATED_AT)
obj.created_at = (
datetime.fromtimestamp(created, tz=UTC) if created is not None else timezone.now()
)
return obj
+52
View File
@@ -0,0 +1,52 @@
"""Session user and the only way to end sessions (AD-3, AD-6)."""
from __future__ import annotations
import time
from dataclasses import dataclass
from .models import UserSession
from .session_store import CREATED_AT, USER_KEY, USER_SAM
@dataclass(frozen=True)
class SessionUser:
key: str # lower-case, used for every database key
sam: str # exact sAMAccountName from AD
@classmethod
def from_sam(cls, sam: str) -> SessionUser:
return cls(key=sam.lower(), sam=sam)
def start_session(request, sam: str) -> SessionUser:
"""Sign a user in on this request; the session key is rotated and old data dropped."""
user = SessionUser.from_sam(sam)
request.session.flush()
request.session[USER_KEY] = user.key
request.session[USER_SAM] = user.sam
request.session[CREATED_AT] = time.time()
return user
def current_user(request) -> SessionUser | None:
session = getattr(request, "session", None)
if session is None:
return None
key = session.get(USER_KEY)
sam = session.get(USER_SAM)
if not key or not sam:
return None
return SessionUser(key=key, sam=sam)
def end_sessions(user: SessionUser | str, except_session_key: str | None = None) -> int:
"""Delete every session of one user, optionally keeping one. Returns how many were ended."""
key = (user.key if isinstance(user, SessionUser) else str(user)).lower()
if not key:
return 0
qs = UserSession.objects.filter(username=key)
if except_session_key:
qs = qs.exclude(session_key=except_session_key)
deleted, _ = qs.delete()
return deleted
+98
View File
@@ -0,0 +1,98 @@
"""Database throttle counters, checked before any outbound call (AD-7)."""
from __future__ import annotations
import math
from collections.abc import Callable
from datetime import datetime, timedelta
from django.db import connection, transaction
from django.db.models import F, Q
from django.utils import timezone
from .models import ThrottleCounter
# Defaults, ready to move into the settings registry (AD-13).
THROTTLE_MAX_FAILURES = 5
THROTTLE_PAUSE_SECONDS = 15 * 60
Clock = Callable[[], datetime]
class Throttled(Exception):
def __init__(self, retry_after: int) -> None:
self.retry_after = retry_after
super().__init__(f"Too many attempts; retry in {retry_after} seconds")
def _subjects(user: str | None, ip: str | None) -> list[str]:
if not user and not ip:
raise ValueError("throttle needs a user or an ip; refusing to do nothing")
subjects = []
if user:
subjects.append(f"user:{user.lower()}")
if ip:
subjects.append(f"ip:{ip}")
return subjects
def check(
endpoint: str,
*,
user: str | None = None,
ip: str | None = None,
clock: Clock = timezone.now,
) -> None:
"""Raise Throttled if the user or the IP is paused at this endpoint. Never writes."""
now = clock()
blocked = ThrottleCounter.objects.filter(
endpoint=endpoint,
subject__in=_subjects(user, ip),
blocked_until__gt=now,
).values_list("blocked_until", flat=True)
latest = max(blocked, default=None)
if latest is not None:
raise Throttled(max(1, math.ceil((latest - now).total_seconds())))
def hit(
endpoint: str,
*,
user: str | None = None,
ip: str | None = None,
clock: Clock = timezone.now,
max_failures: int = THROTTLE_MAX_FAILURES,
pause_seconds: int = THROTTLE_PAUSE_SECONDS,
) -> None:
"""Count one failure for the user and the IP; pause a subject that reaches the limit."""
subjects = _subjects(user, ip)
if connection.in_atomic_block:
raise RuntimeError(
"throttle.hit() must be called after the failed work was rolled back, "
"outside any open transaction"
)
now = clock()
window = timedelta(seconds=pause_seconds)
with transaction.atomic():
for subject in subjects:
counter, _ = ThrottleCounter.objects.get_or_create(
endpoint=endpoint, subject=subject, defaults={"window_started": now}
)
qs = ThrottleCounter.objects.filter(pk=counter.pk)
# Start a fresh window when the old one ran out or the pause is over.
qs.filter(Q(window_started__lte=now - window) | Q(blocked_until__lte=now)).update(
count=0, window_started=now, blocked_until=None
)
qs.update(count=F("count") + 1)
qs.filter(count__gte=max_failures, blocked_until__isnull=True).update(
blocked_until=now + window
)
def reset(endpoint: str, *, user: str) -> None:
"""Clear the user's counter (call after a success).
IP counters expire on their own and are never reset: a valid sign-in must not let an
attacker with one account clear the IP counter.
"""
ThrottleCounter.objects.filter(endpoint=endpoint, subject__in=_subjects(user, None)).delete()