feat(P01): auth + sessions + user scoping #17

Merged
coreci-bot merged 9 commits from phase/01-auth-sessions-scoping into milestone/v0.3-hosted 2026-09-29 21:36:31 +00:00

9 Commits

Author SHA1 Message Date
coreci b3eea6dd98 docs(P01): verify PASS — all four layers, 184+119 tests green
---ci---
phase: 1
milestone: v0.3
status: verify
verdict: PASS
layers:
  structural: PASS
  behavioral: PASS
  security: PASS
  quality: PASS
requirements:
  covered: [REQ-69, REQ-70, REQ-71]
  partial: []
flags_for_p3:
  - "P2: live-client core-loop mutations lack credentials include + CSRF header — P3 T-3.8 request wrapper must land it"
  - "P2: auth stores persist 0644 — match key-store 0600 (I-4)"
flags_for_p5:
  - "P3: /docs + /openapi.json unauthenticated — fold into T-4.8 exposure audit"
  - "P3: PLAN T-1.11 wording backend/openapi.json vs frontend/openapi.json convention — reconcile traceability"
  - "P2: login timing enumeration (S-2, recorded with D-116)"
---/ci---

No P0s. All 9 P1 must-haves verified against reality. REQ-69/70/71
each mapped to passing tests. Security fixes (90221fb) confirmed
pinned. Ship-ready.
2026-09-29 21:34:54 +00:00
coreci 62e78b4b2d decision(P01): D-1 login rate limit — accept with G-104 front-side binding
---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.
2026-09-29 21:23:54 +00:00
coreci 90221fbc49 security(P01): close first-admin register race (P0) + fail-closed corrupt user store (P1)
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---
2026-09-29 21:22:28 +00:00
coreci dee34a28d4 chore(P01): openapi snapshot + regenerated client (drift gate)
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---
2026-09-29 21:07:08 +00:00
coreci 51905a2d1f feat(P01): session surface — login, guard, user menu, ApiClient auth extensions
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---
2026-09-29 21:07:02 +00:00
coreci 3769c42757 feat(P01): restart sweep + registry pruning
---ci---
phase: 1
milestone: v0.3
status: execute
persona: backend-engineer
task: T-1.8
requirements:
  covered: [REQ-71]
---/ci---

Startup sweep (D-115) rewrites every non-terminal state.json as
CANCELLED at app construction; G-101: PLANNING→CANCELLED added to
_LEGAL_TRANSITIONS (pinned by test). Registry pruning (P-6) drops
terminal entries with zero SSE subscribers (refcounted in the events
route). conftest resets sse_starlette's loop-bound shutdown Event
per test. Gates: 181 passed, ruff+mypy clean.
2026-09-29 20:36:04 +00:00
coreci 17f4340d0a feat(P01): user-scoped storage partitioning + owner-keyed registry
Wave 3 (T-1.6/T-1.7): LocalStorageAdapter gains REQUIRED owner_id
(validated ^[a-z0-9]{8}$ — D-105/D-106), run_dir ->
base_dir/runs/<owner_id>/<run_id>/, list_run_ids() scanning the owner
partition defensively, read_run_artifact error embeds the owner path;
StorageAdapter protocol gains append_run_artifact (the actor already
called it); run-id generators consolidated into config/ids.py — one
alphabet, one validator (P-14; new_run_id now delegates); RunRegistry
keyed (owner_id, run_id) — cross-owner access 404s exactly like a
missing run (authorization half of P-8); routes construct
owner-scoped storage per request from the guard dependency; owner_id
stamped into every persisted state via make_run_config(owner_id=...)
+ route stamping (RunActor class untouched — engine contracts
unchanged); cost counters owner-keyed; config_status storage display =
the data root string (no more run_dir('00000000').parent private-registry
read for the path); demo.py passes the owner (P-9); mechanical
owner_id constructor args across test_local_storage (layout re-pinned
to runs/<owner>/<run_id>/ — P-10, + partition isolation + owner-path
error tests), test_engine, test_probe_wiring, test_p0_regressions,
test_resume, test_integration_p1; test_auth gains the two-user
isolation 404 suite + persisted owner_id + owner-keyed counters pins.

---ci---
project: researchx
phase: 1
milestone: v0.3
status: execute
persona: backend-engineer
task: T-1.6, T-1.7
requirements:
  covered: [REQ-71]
---/ci---
2026-09-29 17:52:36 +00:00
coreci a1fd45ea4b feat(P01): auth guard + routes + admin surface
Wave 2 (T-1.4/T-1.5): auth/guard.py require_user dependency (session
resolve -> 401; X-ResearchX-Request header on non-GET -> 403; missing/
inactive user -> 401; sliding touch) — router dependencies, NOT
middleware (C3-01: no BaseHTTPMiddleware near SSE, overridable per
test, documented in OpenAPI); auth/routes.py register (open only while
the store is empty, first user admin, race-free via create_first_admin
under the store lock — D-104), login (uniform 401 'invalid email or
password', token rotation per login, httpOnly Set-Cookie
SameSite=Lax Path=/ Secure=cookie_secure, no Domain), logout (revokes
presented token), me; admin surface on the protected router (GET/POST
/api/admin/users + deactivate which revokes all of the user's
sessions); create_app gains auth_enabled=True default (dev override =
parameter-only synthetic dev00000 admin — C3-16), public auth router +
/healthz outside /api (D-103), protected router wrapping all existing
/api routes; CORS allow_credentials=True + explicit localhost origins
added last (outermost — P-4/C3-04); dev_bridge explicit
auth_enabled=False (P-5); dead duplicate return EventSourceResponse
removed (P-11). 22 new auth tests (test_auth.py) + existing suites
pinned to the auth-off harness.

---ci---
project: researchx
phase: 1
milestone: v0.3
status: execute
persona: backend-engineer
task: T-1.4, T-1.5
requirements:
  covered: [REQ-69, REQ-70]
---/ci---
2026-09-29 17:45:09 +00:00
coreci 97b49ad613 feat(P01): auth stores — user, session, data root
Wave 1 (T-1.1/T-1.2/T-1.3): researchx.config.paths.data_root()
(RESEARCHX_DATA_DIR -> backend/var/researchx dev, D-107); UserStore
persisted atomically to <data_root>/users.json (argon2-cffi
PasswordHasher() defaults, all hash/verify via asyncio.to_thread,
password >= 12 enforced, unique email); SessionStore persisted to
<data_root>/sessions.json (token_urlsafe(32), sha256 token_hash only,
14-day sliding expiry with >60s persist throttle, immediate revoke /
revoke_all_for); pydantic auth request/response contracts (no
password_hash ever emitted); RunState.owner_id: str | None = None
(serialization-compatible, old state files deserialize unchanged);
config/ids.py consolidates the 8-char slug generator + ^[a-z0-9]{8}$
validation in one place (P-14 groundwork). 12 new store tests.

---ci---
project: researchx
phase: 1
milestone: v0.3
status: execute
persona: backend-engineer
task: T-1.1..T-1.3
requirements:
  covered: [REQ-69]
---/ci---
2026-09-29 17:37:05 +00:00