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()
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
+7 -1
View File
@@ -29,6 +29,8 @@ INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.sessions",
"django.contrib.staticfiles",
"portal.audit",
"portal.accounts",
]
MIDDLEWARE = [
@@ -37,6 +39,7 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"portal.accounts.middleware.AbsoluteSessionLimitMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -91,7 +94,10 @@ STORAGES = {
}
# Sessions in the database; cookies secure, HttpOnly, SameSite=Lax (AD-6).
SESSION_ENGINE = "django.contrib.sessions.backends.db"
SESSION_ENGINE = "portal.accounts.session_store"
SESSION_COOKIE_AGE = 60 * 60 # idle limit: 1 hour, renewed on every request
SESSION_SAVE_EVERY_REQUEST = True
SESSION_ABSOLUTE_SECONDS = 4 * 60 * 60 # absolute limit: 4 hours
SESSION_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
+59
View File
@@ -0,0 +1,59 @@
import pytest
from django.db import transaction
from portal.audit import services
from portal.audit.models import AuditEntry
@pytest.mark.django_db(transaction=True)
def test_record_rolls_back_with_the_change():
with pytest.raises(RuntimeError), transaction.atomic():
services.record("Alice", "profile.update", target="alice", ip="10.0.0.1")
raise RuntimeError("change failed")
assert AuditEntry.objects.count() == 0
@pytest.mark.django_db(transaction=True)
def test_record_persists_on_commit_and_lowercases_actor():
with transaction.atomic():
services.record("Alice", "profile.update", target="alice", ip="10.0.0.1", detail={"a": 1})
entry = AuditEntry.objects.get()
assert (entry.actor, entry.action, entry.ip, entry.detail) == (
"alice",
"profile.update",
"10.0.0.1",
{"a": 1},
)
@pytest.mark.django_db(transaction=True)
def test_record_outside_transaction_raises_and_writes_nothing():
with pytest.raises(services.AuditOutsideTransaction):
services.record("alice", "x")
assert AuditEntry.objects.count() == 0
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("key", ["password", "New_Token", "client_secret", "pwd", "oldPassword"])
def test_secret_keys_are_refused(key):
with pytest.raises(ValueError), transaction.atomic():
services.record("alice", "x", detail={"ok": 1, "nested": {key: "v"}})
with pytest.raises(ValueError):
services.record_failure("alice", "x", detail={key: "v"})
assert AuditEntry.objects.count() == 0
@pytest.mark.django_db(transaction=True)
def test_record_failure_inside_transaction_raises_and_writes_nothing():
with pytest.raises(services.AuditInsideTransaction), transaction.atomic():
services.record_failure("alice", "x.failed")
assert AuditEntry.objects.count() == 0
@pytest.mark.django_db(transaction=True)
def test_record_failure_persists_after_rollback():
with pytest.raises(RuntimeError), transaction.atomic():
services.record("alice", "x")
raise RuntimeError("boom")
services.record_failure("alice", "x.failed", detail={"reason": "ad"})
assert list(AuditEntry.objects.values_list("action", flat=True)) == ["x.failed"]
+123
View File
@@ -0,0 +1,123 @@
import time
from datetime import timedelta
import pytest
from django.conf import settings
from django.test import Client, RequestFactory
from django.utils import timezone
from portal.accounts.middleware import AbsoluteSessionLimitMiddleware
from portal.accounts.models import UserSession
from portal.accounts.session_store import CREATED_AT, SessionStore
from portal.accounts.sessions import SessionUser, current_user, end_sessions, start_session
pytestmark = pytest.mark.django_db
def make_session(sam):
request = RequestFactory().get("/")
request.session = SessionStore()
start_session(request, sam)
request.session.save()
return request
def test_username_column_is_lowercase_key_and_sam_keeps_case():
request = make_session("Alice")
row = UserSession.objects.get(session_key=request.session.session_key)
assert row.username == "alice"
assert current_user(request) == SessionUser(key="alice", sam="Alice")
def test_start_session_rotates_key_and_drops_old_data():
request = RequestFactory().get("/")
request.session = SessionStore()
request.session["stale"] = 1
request.session.save()
old_key = request.session.session_key
start_session(request, "alice")
request.session.save()
assert request.session.session_key != old_key
assert "stale" not in request.session
assert not UserSession.objects.filter(session_key=old_key).exists()
def test_end_sessions_only_target_user():
for _ in range(3):
make_session("a")
for _ in range(2):
make_session("b")
assert end_sessions("a") == 3
assert UserSession.objects.filter(username="a").count() == 0
assert UserSession.objects.filter(username="b").count() == 2
def test_end_sessions_except_session_key_and_unknown_user():
keep = make_session("a").session.session_key
make_session("a")
assert end_sessions(SessionUser("a", "A"), except_session_key=keep) == 1
assert list(UserSession.objects.values_list("session_key", flat=True)) == [keep]
assert end_sessions("nobody") == 0
def test_idle_limit_expired_session_is_gone():
request = make_session("a")
UserSession.objects.update(expire_date=timezone.now() - timedelta(seconds=1))
assert SessionStore(request.session.session_key).get(CREATED_AT) is None
def test_settings_defaults():
assert settings.SESSION_COOKIE_AGE == 3600
assert settings.SESSION_ABSOLUTE_SECONDS == 14400
assert settings.SESSION_SAVE_EVERY_REQUEST is True
def _client_with_session(created_at):
store = SessionStore()
request = RequestFactory().get("/")
request.session = store
start_session(request, "alice")
store[CREATED_AT] = created_at
store.save()
client = Client()
client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key
return client, store.session_key
def test_absolute_limit_removes_session_and_clears_cookie():
assert any(m.endswith("AbsoluteSessionLimitMiddleware") for m in settings.MIDDLEWARE)
client, key = _client_with_session(time.time() - settings.SESSION_ABSOLUTE_SECONDS - 5)
response = client.get("/healthz")
assert not UserSession.objects.filter(session_key=key).exists()
assert response.cookies[settings.SESSION_COOKIE_NAME].value == ""
def test_session_within_limits_survives_a_request():
client, key = _client_with_session(time.time() - 60)
client.get("/healthz")
assert UserSession.objects.filter(session_key=key).exists()
def test_middleware_callable_directly():
request = make_session("alice")
AbsoluteSessionLimitMiddleware(lambda r: None)(request)
assert current_user(request) is not None
def test_request_renews_expire_date_to_one_hour_ahead():
client, key = _client_with_session(time.time() - 60)
UserSession.objects.filter(session_key=key).update(
expire_date=timezone.now() + timedelta(minutes=5)
)
client.get("/healthz")
remaining = UserSession.objects.get(session_key=key).expire_date - timezone.now()
assert timedelta(minutes=59) < remaining <= timedelta(hours=1)
def test_request_with_idle_expired_session_has_no_session_and_clears_cookie():
client, key = _client_with_session(time.time() - 60)
UserSession.objects.filter(session_key=key).update(
expire_date=timezone.now() - timedelta(seconds=1)
)
response = client.get("/healthz")
assert response.cookies[settings.SESSION_COOKIE_NAME].value == ""
+91
View File
@@ -0,0 +1,91 @@
from datetime import UTC, datetime, timedelta
import pytest
from django.db import transaction
from portal.accounts import throttle
from portal.accounts.models import ThrottleCounter
T0 = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
EP = "sign-in"
pytestmark = pytest.mark.django_db(transaction=True)
def fail(n, at=T0, **kw):
for _ in range(n):
throttle.hit(EP, clock=lambda: at, **kw)
def test_below_limit_passes():
fail(4, user="alice")
throttle.check(EP, user="alice", clock=lambda: T0)
def test_user_is_throttled_but_others_pass():
fail(5, user="Alice", ip="1.1.1.1")
with pytest.raises(throttle.Throttled) as exc:
throttle.check(EP, user="alice", clock=lambda: T0)
assert exc.value.retry_after == 900
throttle.check(EP, user="bob", ip="2.2.2.2", clock=lambda: T0)
def test_ip_is_throttled_across_users():
fail(5, user="a", ip="1.1.1.1")
with pytest.raises(throttle.Throttled):
throttle.check(EP, user="other", ip="1.1.1.1", clock=lambda: T0)
throttle.check(EP, user="other", ip="3.3.3.3", clock=lambda: T0)
def test_other_endpoint_is_unaffected():
fail(5, user="alice")
throttle.check("password-change", user="alice", clock=lambda: T0)
def test_pause_elapses():
fail(5, user="alice")
later = T0 + timedelta(seconds=901)
throttle.check(EP, user="alice", clock=lambda: later)
# a new failure after the pause starts a fresh count
throttle.hit(EP, user="alice", clock=lambda: later)
assert ThrottleCounter.objects.get(subject="user:alice").count == 1
def test_stale_window_restarts_count():
fail(4, user="alice")
later = T0 + timedelta(minutes=16)
throttle.hit(EP, user="alice", clock=lambda: later)
assert ThrottleCounter.objects.get().count == 1
def test_reset_clears_user_but_never_the_ip():
fail(5, user="alice", ip="1.1.1.1")
throttle.reset(EP, user="alice")
throttle.check(EP, user="alice", clock=lambda: T0)
with pytest.raises(throttle.Throttled):
throttle.check(EP, ip="1.1.1.1", clock=lambda: T0)
def test_reset_has_no_ip_parameter():
with pytest.raises(TypeError):
throttle.reset(EP, user="alice", ip="1.1.1.1")
def test_hit_inside_transaction_raises_and_counts_nothing():
with pytest.raises(RuntimeError), transaction.atomic():
throttle.hit(EP, user="alice", clock=lambda: T0)
assert ThrottleCounter.objects.count() == 0
def test_no_subject_raises_for_all_functions():
with pytest.raises(ValueError):
throttle.check(EP)
with pytest.raises(ValueError):
throttle.hit(EP)
with pytest.raises(ValueError):
throttle.reset(EP, user="")
def test_check_never_writes():
throttle.check(EP, user="alice", ip="1.1.1.1", clock=lambda: T0)
assert ThrottleCounter.objects.count() == 0