Skip to content

Make harness login the account door: sessions and application auth (ENG-703)#15

Merged
czpython merged 1 commit into
mainfrom
commonzenpython/eng-703-make-harness-login-the-account-door-sessions-and-application
Jul 16, 2026
Merged

Make harness login the account door: sessions and application auth (ENG-703)#15
czpython merged 1 commit into
mainfrom
commonzenpython/eng-703-make-harness-login-the-account-door-sessions-and-application

Conversation

@czpython

Copy link
Copy Markdown
Owner

Multi-tenant accounts arc, PR 2 of 4 (ENG-703). Harness login becomes the account door: internal APIs and SSE require a session, capability routes keep their own auth, and the Caddy gate loosens to any nonempty trusted identity.

What this delivers

Canonical login API (druks/accounts/routes.py, the only login surface):

  • GET /api/auth/sessionAccountResponse or 401; POST /api/auth/harnesses/{name}/login/start{authorizeUrl, loginId}; POST .../login/complete {loginId, code} → attaches the seat, mints the session cookie; POST /api/auth/logout.
  • The old settings login start/complete routes are deleted; Settings keeps only the account-scoped disconnect. Landing and Settings drive the same /api/auth client methods.
  • PKCE pending state (opaque single-use flow id, Redis) now carries verifier, expected state, the session account id at start (reconnect binding), and the trusted proxy identity at start.

Account resolution (ticket order, exactly): valid session keeps its account → proxy identity stored at flow start → provider-verified email; normalized exact-equality lookup; new empty account when none. HarnessLogin.connect is account-first: a provider seat attached to a different account raises SeatClaimedError → 409, never an implicit move. Mismatched authoritative/provider emails surface as isEmailMismatch on the settings card — visible, never blocking.

Sessions (druks/accounts/sessions.py + dependencies.py + middleware.py):

  • 256-bit URL-safe token, Redis druks:session:{token} → account id, 30-day sliding TTL via atomic GETEX.
  • druks_session cookie: HttpOnly, SameSite=Lax, Path=/, 30-day Max-Age, Secure when the browser endpoint is HTTPS (via X-Forwarded-Proto).
  • One pure-ASGI response middleware writes the cookie (touched, rotated, or cleared) — SSE response headers included; identity resolution stays in current_account, with resolve_session_account as the seam a future bearer/PAT resolver slots beside.
  • Login rotates any prior token; logout deletes the key; a proxy identity that no longer matches the session account drops the session; Redis loss signs out but never touches stored credentials.

Auth boundary: every internal platform router and every discovered extension router (generic transcript/board/subject /stream included) mounts behind Depends(current_account). Exempt, each with its own auth or safe by construction: health, /_external/*, login start/complete + logout, static SPA/extension assets, and POST /api/notifications/{token}/respond — split onto its own capability router with behavior unchanged. test_auth_boundary.py enumerates both directions (nothing unguarded slips in; nothing exempt grows the gate; every /stream family guarded).

Frontend: AuthProvider above App — only an authenticated state mounts queries/EventSources; landing screen with Connect Codex / Connect Claude (same paste-back flow via a shared hook); centralized credentials: "same-origin" and typed 401 (UnauthorizedError) that broadcasts expiry and unmounts the app (closing every stream); useSSE rechecks /api/auth/session on error and closes the source when the session died — no blind reconnects; the settings card shows authoritative email, provider email, mismatch warning, expiry, and default-seat state.

Deploy/env/docs: DRUKS_AUTH_HEADER is now a backend Settings field; DRUKS_DASHBOARD_EMAIL deleted from setup-env generation/tests, the Compose pass-through, and Caddy (it remains only in the PR1 migration + its upgrade window, by design); Caddy admits any nonempty trusted identity and keeps the no-header exe.dev redirect + separate webhook listener; configuration/full-local/deployment/troubleshooting docs updated; the deploy README's cutover section now spells out replacing host-copied Compose/Caddy, removing the stale .env value, and recreating web + caddy.

Acceptance criteria → tests

Criterion Test
Internal JSON + SSE routes require a session; enumerated capability routes keep their own auth test_auth_boundary.py (4 tests, both directions + stream families), test_auth.py::test_protected_routes_401_without_a_session
Dashboard identity resolves the PR1 account by exact normalized email; other identities get empty accounts and cannot acquire legacy seats test_auth.py::test_dashboard_identity_resolves_the_migrated_account, ::test_new_identity_cannot_acquire_legacy_seats
Local identity from provider; proxy identity from trusted header; mismatch visible without changing authority test_auth.py::test_proxy_identity_is_authoritative_for_the_account, test_api_settings.py::test_harness_card_reports_mismatch_and_default_state
Session mint/touch/proxy-mismatch/logout/eviction/single-use PKCE cannot authenticate the wrong account test_auth.py: login flow, rotation, logout, eviction, proxy mismatch, single-use, ::test_claimed_seat_conflicts_with_409, ::test_session_keeps_its_account_across_reconnects; flow-binding tests in test_harness_login.py
Landing and Settings use one canonical login API; old settings login routes gone route deletion in user_settings/routes.py + shared useHarnessLogin hook; HarnessConnect.test.tsx drives /api/auth
Frontend Vitest specs pass AuthProvider.test.tsx (landing/session transitions), client.test.ts (typed 401 + same-origin), sse.test.tsx (expiry recheck, no blind reconnects), HarnessConnect.test.tsx (settings connection) — 34/34 locally

Rollout (from the ticket)

Deploy backend and SPA behind the old single-email Caddy gate first (compatible during transition). Then replace the host-copied Compose/Caddy, remove DRUKS_DASHBOARD_EMAIL from .env, and recreate web + caddy (steps now in deploy/README.md). Each browser performs one harness login to mint its session.

Codex review

Adversarial review through the codex companion, read-only (gpt-5.5, reasoning effort high — sol is rejected on this account's ChatGPT plan). Five findings:

  1. High, confirmed → fixed. A bound reconnect completing after its session died (eviction/logout) fell through to email-based resolution and could attach the seat to a different account. Now a flow carrying a bound account_id completes only under that same live session — otherwise 422, re-start. Pinned by test_bound_reconnect_requires_its_session_at_complete.
  2. High, confirmed → fixed. UserPreferencesProvider sat above the auth gate and fired /api/settings on unauthenticated cold loads. The provider tree is now AuthProvider-first; every query mounts below the session.
  3. High, confirmed → fixed. The module-level QueryClient survived logout/account switches, so account A's cached settings/harness/events data could render for account B. The query cache is now created per signed-in account (AuthedApp, keyed on account.id) — a new session always starts empty.
  4. Medium, waived with rationale. "Session-gating /api/mcp-servers changes the MCP OAuth callback's auth despite the scope fence." The ticket's exemption list is explicit and exhaustive, and the MCP callback is not on it — exempting it here would be the actual scope deviation. Behaviorally: the callback is a top-level redirect, SameSite=Lax sends the session cookie, so live sessions complete normally; a session that dies mid-connect means redoing that MCP connect. If that UX matters it's a one-line exemption for the MCP arc to take deliberately.
  5. Low, confirmed → fixed. docs/concepts.md still described the single-email edge gate; rewritten to the session/account-door model.

No refutations beyond these: the boundary enumeration, session mechanics, PKCE single-use shape, seat-conflict 409, and legacy-seat protections all held.

Stack

Supersedes #11 (auto-closed when its base branch merged as #10). Rebased onto the landed main — the replay adopts the review direction that shipped there: the HarnessConnection rename, citext emails stored as-given (Python email comparisons mirror citext's case-insensitivity), connect() upserting purely by (harness, account) with the provider-seat 409 and the cross-harness flow guard dropped per the review rationale. ENG-704 (PR #13) and ENG-705 (PR #14) still point at the pre-rebase branches; they get rebased when their turn comes.

🤖 Generated with Claude Code

czpython added a commit that referenced this pull request Jul 16, 2026
Review pass over PR #15: drop the tests that re-proved another test's
property — the 4-assert 401 spot-check (one canary stays; the boundary
enumeration owns the surface), the boundary file folded 4 tests into the
two directions that matter, single-use loginId, unknown-harness 404, the
settings-card re-asserts (pinned in test_api_settings), the FE
credentials config-echo and inline-error tests. Session mechanics,
legacy-seat protection, and the gate enumeration keep full coverage.

Checklist sweep on the touched files: truthiness for row-absence
asserts, imports to module top, one-caller _jwt inlined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
czpython added a commit that referenced this pull request Jul 16, 2026
- Read the trusted proxy identity at login/complete instead of
  snapshotting it at start: proxy_email leaves CompletedLogin, the flow
  state, and login_start's signature.
- Move the notification respond route to
  /_external/notifications/{token}/respond, beside the webhooks it works
  like. The edge only admits POST /_external/* publicly, so under /api it
  wasn't reachable from outside; capability_router and its exempt-list
  entry die with the move.
- Drop is_email_mismatch: one hint line wasn't worth a schema field and
  the lowered-compare dance; provider_email stays in the payload.
- Replace SessionCookieReissue with native response.set_cookie at login,
  logout, and GET /session (the slide every app load performs); the
  request.state.session_token seam dies.
- Comment/docstring diet across everything the PR added, and the last
  'seat' prose swapped for login/connection.
- ruff format over the touched files CI's ruff check never flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…NG-703)

Completing a harness OAuth resolves an account and mints a 30-day
sliding Redis session (druks_session cookie — rotated on login, cleared
on logout, re-issued on every GET /session). Every /api route sits
behind the session gate via include-level dependencies, extension
routes and SSE streams included; the login surface, the health probe,
and the JSON-404 catch-all are the only exemptions, and a boundary test
pins the split in both directions. The notification respond route moves
to /_external/notifications/{token}/respond beside the webhooks, where
the edge actually admits it.

Identity is one plane: the edge (Caddy) is pure admission and the app
never reads the identity header. Actor-less runs (webhooks, schedules)
execute as the fallback account — user_settings.fallback_account_id,
set by the very first login, resolved through
HarnessConnection.get_for_account(harness, fallback=True) — replacing
the per-harness is_default election (column, partial index, and chip
dropped; the migration backfills from the old default).

The frontend mounts the app behind an AuthProvider: session check,
landing screen sharing the PKCE paste-back flow with Settings
reconnect, and auth expiry broadcast as a native window event that
unmounts the app and its per-mount query cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@czpython
czpython force-pushed the commonzenpython/eng-703-make-harness-login-the-account-door-sessions-and-application branch from d4d45ee to 59d1412 Compare July 16, 2026 22:04
@czpython
czpython merged commit 61f96cb into main Jul 16, 2026
1 of 2 checks passed
@czpython
czpython deleted the commonzenpython/eng-703-make-harness-login-the-account-door-sessions-and-application branch July 16, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant