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
+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