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
8.8 KiB
title, type, ticket, created, status, baseline_revision, route, route_source, risk, review, review_source, lenses_ran, review_loop_iteration, context
| title | type | ticket | created | status | baseline_revision | route | route_source | risk | review | review_source | lenses_ran | review_loop_iteration | context | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Audit, throttle and session foundations | feature | 3 | 2026-10-15 | built | 003ce6e0c2 |
full | auto | medium | quick | pinned |
|
0 |
|
Intent
Problem: Every later feature must write an audit entry with its change, refuse floods before calling Active Directory, and end a user's sessions on demand. None of these shared services exists yet.
Approach: Add two Django apps. audit owns audit entries and a service that records them only inside the caller's transaction. accounts owns throttle counters and a database session store that keeps the username in its own column, with end_sessions, idle and absolute limits, and the SessionUser object.
Boundaries & Constraints
Always: Spine rules AD-4, AD-6, AD-7 and AD-19 apply: only the owning app writes its tables; other code calls its service. Usernames are stored lower-case as key; sam keeps the exact case. Timestamps are UTC. Idle limit 1 hour and absolute limit 4 hours are named settings with those defaults. A throttle default of 5 failures and a 15-minute pause is a named constant, ready for the settings registry. Entries and counters never hold passwords, tokens or picture content.
Never: No sign-in, OIDC, AD, mail or Admin-page code. No client-IP derivation (the sign-in entry owns it; here the IP is a string passed in). No other way to end sessions than end_sessions. No raw SQL.
I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|---|---|---|---|
| Audit in transaction | record() inside transaction.atomic(), then the block fails |
Entry is rolled back with the change | No error expected |
| Audit outside transaction | record() called with no open transaction |
Raises AuditOutsideTransaction |
Nothing is written |
| Secret in detail | detail key like password, token, secret or pwd |
Raises ValueError |
Nothing is written |
| Failure entry | record_failure() after a rolled-back change |
Entry persists in its own transaction | No error expected |
| Throttle refuses | Limit failures reached for endpoint plus user, or plus IP | check() raises Throttled with seconds to wait |
No AD call is made by the caller |
| Throttle recovers | Pause elapsed, or reset() after success |
check() passes again |
No error expected |
| Sessions by user | Two users, several sessions each | end_sessions(user) deletes all of that user's sessions only; except_session_key keeps one |
Unknown user returns 0 |
| Session limits | Idle over 1 hour, or older than 4 hours | Next request has no session | Cookie cleared |
Code Map
portal/config/settings.py-- add the two apps,SESSION_ENGINE = "portal.accounts.session_store",SESSION_COOKIE_AGE(idle),SESSION_SAVE_EVERY_REQUEST = True,SESSION_ABSOLUTE_SECONDS, and the middleware afterSessionMiddleware. Existing: DB sessions, secure cookies, UTC.portal/audit/-- new app:models.py(AuditEntry: actor key, action, target, ip, created_at, detail JSON),services.py(record,record_failure), migration.portal/accounts/-- new app:models.py(UserSession(AbstractBaseSession)withusername,created_at;ThrottleCounter),session_store.py,sessions.py(SessionUser,start_session,current_user,end_sessions),throttle.py(check,hit,reset,Throttled),middleware.py(absolute limit), migration.portal/tests/--test_audit.py,test_throttle.py,test_sessions.py.- Reuse: Django
AbstractBaseSessionandSessionStore; do not changeportal/config/middleware.py.
Tasks & Acceptance
Execution:
portal/audit/-- model, service with the transaction guard and secret-key guard, migration -- AD-4portal/accounts/models.py,session_store.py,sessions.py,middleware.py-- session store with a username column,SessionUser,start_session(rotates the key),end_sessions, absolute limit -- AD-6portal/accounts/throttle.py-- atomic counters keyed by endpoint plus subject (user or IP), pause, reset, injectable clock -- AD-7portal/config/settings.py-- register apps, store and middleware -- wiringportal/tests/-- unit-test every matrix row
Acceptance Criteria:
- Given a failing change, when it runs in a transaction that records an audit entry, then neither is stored.
- Given 5 recorded failures for one user at one endpoint, when
checkruns, then it raisesThrottled, while another user and another IP at that endpoint still pass. - Given three sessions of user A and two of user B, when
end_sessions("a")runs, then A has none and B keeps two. - Given a session older than the absolute limit, when a request arrives, then the session is gone.
- Given the migrations, when
makemigrations --checkruns, then no changes are reported.
Implementation Notes
- Implemented by a coding subagent; the orchestrator read the code and re-ran
uv run pytest(40 passed, none skipped),ruff check .(clean) andmakemigrations --check --dry-run(no changes). - Judgement calls:
**/migrationsadded to ruff'sextend-exclude;reset()clears only the subjects passed in, so the caller decides about the IP counter; the failure window equals the pause (15 minutes) because the plan did not fix it;django.contrib.sessionsstays installed (its unuseddjango_sessiontable is migrated, harmless); audit tests usetransaction=Trueso the "outside a transaction" case is real. - Not tested: concurrent writers on the throttle counters.
- After review:
hit()andrecord_failure()now refuse to run inside an open transaction,reset()takes only a user, and the throttle functions refuse an empty subject. Callers must therefore count failures after the failed work was rolled back, not inside it.
Plan Change Log
Review Triage Log
Pass 1 (quick lens). Counts: high 0, medium 5, low 5, false 1.
| # | Finding | Verdict | Route | Evidence |
|---|---|---|---|---|
| 1 | record_failure inside an open transaction is lost on rollback |
medium | patch | Nested atomic() is a savepoint; now raises AuditInsideTransaction, tested. |
| 2 | hit() inside a transaction that rolls back erases the count, so the throttle never trips |
medium | patch | Same savepoint effect on a failed AD call (AD-4); now raises when a transaction is open, tested. |
| 3 | Throttle with no user and no IP silently does nothing | medium | patch | _subjects returned []; now raises ValueError in check, hit and reset, tested. |
| 4 | reset(ip=...) lets an attacker with one valid account clear the IP counter |
medium | patch | reset now takes only user; IP counters expire on their own; tested. |
| 5 | end_sessions takes except_session_key, the spine says except_current=bool |
low | defer | The implementation is the workable form (a session needs its key); the fix is to reword AD-6, a spec edit. See deferred-work. |
| 6 | Lower-casing of the key is duplicated | low | rejected | Harmless defence; both lower-case the same value. |
| 7 | Absolute limit only covers sessions made by start_session |
low | rejected | start_session is the only sign-in path. |
| 8 | Unused session table; limits are constants, not settings | false | rejected | They are named Django settings; the table is harmless and the settings registry is a later entry. |
| 9 | **/migrations excluded from lint |
low | rejected | Generated files; disclosed. |
| 10 | Idle limit tested only on a store, not through a request | medium | patch | Two request-level tests added (renewal, expired session with cleared cookie). |
| 11 | No concurrency test for hit() |
low | rejected | Disclosed; SQLite serializes writers (IMMEDIATE), a concurrent test needs threads and a file database. |
Design Notes
Throttle storage is one row per (endpoint, subject) with count, window_started and blocked_until; updates use database expressions inside a transaction so concurrent requests count correctly (the database serializes writes). check never writes. The clock is a parameter so tests need no sleeping. The settings registry that later feeds the limits is a different entry.
Verification
Commands:
uv run pytest-- expected: all tests pass, including the new onesuv run ruff check .-- expected: no findingsuv run python manage.py makemigrations --check --dry-run-- expected: no changes detected (with a valid test environment)