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,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"]
|
||||
@@ -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 == ""
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user