---ci---
phase: 1
milestone: v0.3
status: execute
decision:
id: D-116
decision: "Backend-side login rate limiting is deferred to the P4 HAProxy stick-table per G-104 (per-IP throttle on /api/auth/*, asserted by the T-4.8 exposure audit). v0.3 accepts argon2 CPU cost on the single worker bounded by admin-managed registration + the P4 front throttle; a backend-side limiter is v0.4+ if the user set grows."
rationale: "G-104 already binds the front-side throttle; double-throttling in v0.3 adds session-store complexity without a second line of defense value at admin-managed scale"
confidence: 0.75
alternatives: ["in-process token bucket per IP now", "fail2ban-style host integration"]
requirements:
covered: []
partial: []
---/ci---
Security review verdict: ship-ready after 90221fb. P2 notes
(S-2 timing, I-3 probe lookup, I-4 file perms, R-1 audit trail,
E-4 cookie_secure handoff) recorded for P5 review.
STRIDE adversarial review of the P1 auth surface found:
P0 — Elevation of Privilege / Spoofing (register race, D-104
violation): routes.register called create_user, which re-checks only
EMAIL uniqueness under the store lock — not STORE EMPTINESS. The
atomic create_first_admin existed but was dead code. Two concurrent
register POSTs with distinct emails on a fresh deployment BOTH
succeeded as admin (verified by live probe: two 201s + two admin
roles). Any attacker racing a fresh deployment gains admin. Fix:
register now routes through create_first_admin, whose emptiness
check + insert are atomic under the store lock (the only await —
the argon2 to_thread hash — precedes the check, so it remains
single-worker race-free per the plan constraint).
P1 — Tampering/DoS (corrupt users.json silently reopens
registration): a corrupt users.json made UserStore._load swallow
the error and report empty(); register then succeeded on top of
existing accounts and the next persist overwrote every account
(auth-reset-by-corruption, verified by live probe). Fix: fail
closed — the corrupt file is moved aside (*.corrupt.<hex>) and
construction raises RuntimeError; an operator recovers instead of
an attacker registering. Session stores keep the fail-safe
direction (corrupt sessions.json = everyone logged out = no
access, not all access), now pinned by test.
New adversarial tests (181 -> 184 green):
- test_concurrent_register_yields_exactly_one_admin: 8-way
concurrent register storm on a fresh store — exactly one 201,
losers have no account (regression guard for this exact bug).
- test_corrupt_user_store_fails_closed + move-aside recovery.
- test_corrupt_session_store_fails_toward_no_access.
Other STRIDE findings (verified GREEN, no action): token_urlsafe(32)
+ sha256-at-rest, httpOnly SameSite=Lax no-Domain cookie, CSRF on
all non-GET including auth POSTs, uniform login 401s, argon2 fully
in to_thread, cross-owner 404 on every run endpoint, no
password_hash in any response, deactivation kills sessions, token
rotation at login, dev_bridge loopback-only, sweep never fails
startup. Login timing enumeration noted for P5 review.
---ci---
phase: 1
milestone: v0.3
status: execute
persona: security-engineer
task: adversarial-review
escalations: none
resolution: P0 fixed in-commit (create_first_admin race), P1 fixed
---/ci---
Refreshes the committed frontend/openapi.json snapshot from the
FastAPI app (exported via create_app().openapi(), never a live
server): auth routes (/api/auth/register|login|logout|me), admin
surface (/api/admin/users, POST /api/admin/users,
/api/admin/users/{user_id}/deactivate), and /healthz join the v0.2
route set; UserPublic/LoginBody/RegisterBody/AdminCreateUserBody
schemas included. Operation-ids stay stable.
frontend/src/lib/api/generated/schema.d.ts regenerated from the
snapshot via openapi-typescript (pnpm generate:api). New gate script
pnpm check:api-drift regenerates from the committed snapshot and
diffs (--check — exit 1 on drift, G-005); verified failing on an
intentional schema edit, green on the committed pair.
---ci---
phase: 1, milestone: v0.3, status: execute, persona: frontend-engineer, task: T-1.11
---/ci---
ApiClient gains the C3-18 exhaustive session list: login(email, password)
→ POST /api/auth/login, logout() → POST /api/auth/logout, getSession()
→ GET /api/auth/me (401 → null), plus admin hooks listUsers/createUser/
deactivateUser → /api/admin/users. UserProfile is the wire shape
(user_id/email/display_name/role/active/created_at — UserPublic parity);
the menu fixture shape is renamed DisplayProfile.
Mock binding: standalone dev fixture (dev00000 Dev User, admin) — login
accepts any non-empty credentials, admin hooks return small fixture
arrays; the mock stays zero-backend (REQ-31/REQ-54). Live binding:
credentials:"include" on every call, X-ResearchX-Request: "1" on every
mutation (C3-05), 401 → typed AuthError with the uniform copy.
Login screen at /login (email + password, disabled submit until both
fields set, uniform invalid email or password error state, success →
/ask). SessionGate wraps the app shell: live mode bootstraps the
session on load and redirects unauthenticated users to /login before
any protected content renders; the login route is never gated; mock
mode skips the check entirely. UserMenu shows the real session profile
(display name + email) in live mode, the fixture in mock mode, and
gains Logout (revoke + /login).
Repairs the pre-existing e2e failure: registry pruning (T-1.8, P-6)
404s the post-stream getRun — the DONE signal now comes from the SSE
stream itself and the post-prune 404 is pinned as documented behavior.
19 new tests across session-boundary, login-view, session-gate,
user-menu-session, app-shell (logout). Mock-mode component tests stay
green unchanged.
---ci---
phase: 1, milestone: v0.3, status: execute, persona: frontend-engineer, task: T-1.10, requirements: covered [REQ-69, REQ-70]
---/ci---