Org switch endpoint and stale-tab guard

Task 5070 · Done · Coder · created · updated

## Epic ask (verbatim) > "Allow user to be a member of several orgs, including Operator. At any given moment user acts on behalf of the currently selected org, no permissions mix or leak. User can switch between orgs by clicking on his icon and selecting a switch icon next to the org. Same model as in Stripe and other multitenant systems" ## Requirements (PRD, verbatim) - **R2 — One active org, strict isolation.** Every request a user makes is authorized and scoped as exactly ONE org: the session's active org. ... Operator powers ... are held ONLY while the active org is Operator. - **R4 — Switch scope and persistence.** A switch applies to the whole browser session: every open tab of that session moves to the new org on its next request or refresh. No tab keeps acting as the old org after a switch. The last active org is remembered per user, and the next sign-in opens it. - **R10 — Sign-in policy holds per org.** Switching into an org must not bypass that org's security policy. If the session's sign-in skipped MFA (an `mfa_skip` org) and the target org requires MFA, the switch demands the second factor first. ## Acceptance (PRD, verbatim) - AC8 (API half): "After a switch in tab 1, tab 2's next request acts as the new org. No request is ever served as the old org afterwards." - AC14: "A session signed in through an `mfa_skip` org must pass the second factor before switching into an org that requires MFA." ## Approach (architecture §3.2, §3.3 API half, §3.4, §5.2/5.4/5.8) **Contract (pinned — the web and deep-link children consume it):** ``` POST /api/auth/switch body: { "org_id": "<uuid>", "code"?: "<totp|recovery>" } 200 { "outcome": "session", "org": { "id", "slug", "name" } } # Set-Cookie re-minted 200 { "outcome": "reauth_required", "door": "otp" | "google" | "microsoft" | "github", "start_url"?: "/api/auth/<door>/start?switch_to=<org_id>" } # no cookie change 404 { "detail": "not found" } # no row of this identity in org, unknown org, or disabled membership 409 { "detail": "org_pending" | "org_not_approved" } ``` Implement `login_funnel.switch_membership(session, request, response, claims, target_row)` (login_funnel stays the ONLY module that mints — `tests/test_login_funnel.py`): (1) target row same identity + live else 404; (2) `org_gate.sign_in_refusal` → 409; (3) run the target org's full sign-in policy against the session's proven `amr`: `POLICY_HOOKS` for the door implied by `claims.amr`, superadmin-no-password rule, second-factor rule (a `skip` second factor switching into a non-skip org must prove a factor); unmet → `reauth_required` naming the target org's door; a supplied `code` is verified with `mfa.verify_current_code` against the identity's factor and adds `otp`/`recovery` to amr; (4) re-mint `auth.set_session_cookie(response, target.id, target.session_epoch, iepoch=identity.session_epoch, iat=claims.iat, amr=claims.amr ∪ new)` — same `iat`, amr never laundered; (5) write `identity.last_active_admin_id`; publish `session_switched {identity_id, iat}` on `app/events.py`'s broadcaster. NO audit event. Target = current row → 200 no-op re-mint. IdP re-auth: each door's `start` accepts `switch_to=<org_id>`; its callback resolves identity → the row in that org → `complete_first_factor` for THAT row (fresh `iat`). **Stale-tab guard:** middleware after session resolution — inside `SessionAuthMiddleware`, OUTSIDE `SessionRefreshMiddleware` (`main.py:272-276`) — compares header `X-Jaah-Org` to the cookie row's `org_id`; mismatch → `409 {"detail":"org_switched","active_org":{"id","name"}}` before any handler; never re-minted (refresh middleware skips that response). Absent header → no check. `/api/stream` takes `?org=<id>` with the same rule. **SSE close:** the stream records `(identity_id, iat)` of its handshake cookie and closes on a matching `session_switched`; other devices (different `iat`) untouched. Security surface: auth/authz — under the security-surface gate. Out of scope: No web UI, no `api.ts` header sending, no dialog (web child). No `locate` endpoint (deep-link child). No invitations. Epic out-of-scope (PRD §5, verbatim): "No per-tab simultaneous orgs. Org is per session. Do NOT put the org in the URL path as an addressing scheme." "No audit event for a switch (operator decision Q8). Do not add one." "No operator 'view as / impersonate' of an org the operator is not a member of." "Do not weaken the read/write seams (`tenancy/seam.py`, `tenancy/write_seam.py`): one unit of work = one org stays the rule. The active org REPLACES `user.org_id` as the bound scope; it never widens it." Done means: - Contract tests for every row of the table: non-member/unknown org 404, disabled membership 404, pending org 409 `org_pending`, rejected/disabled org 409 `org_not_approved`, current org 200 no-op, live org 200 `session` with Set-Cookie whose `iat` equals the original. - AC14 — API test: session signed in via an `mfa_skip` org switching into an MFA-required org → `reauth_required` door `otp`, no cookie change; retry with a valid `code` → `session`; invalid code → still refused. - SSO-only / superadmin-no-password target → `reauth_required` naming the IdP door with `start_url`; callback with `switch_to` lands the session in that org — API test. - AC8 (API) — two clients sharing a cookie jar header `X-Jaah-Org=<A>`: after a switch to B, the A-headed request gets 409 `org_switched` with no handler run and NO `Set-Cookie`; a write attempted that way changes nothing in either org. - `/api/stream?org=<A>` after the switch → 409; an open stream closes on `session_switched` for the same `(identity_id, iat)` and stays open for a different `iat` — test. - `last_active_admin_id` updated by switch; next sign-in lands in the switched-to org — test. - No audit row written by a switch — test. - AC17 — `./dc check full` green, including the tenancy bypass/scope censuses and the authz policy suite. Required checks: gate

Outcome

Built POST /api/auth/switch (target-org policy, iat kept) and the X-Jaah-Org/?org= stale-tab 409 guard; SSE closes on session_switched. Full gate green; security fixes in; operator approved; merged #4506. Deferred: MFA enrol on switch.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Awaiting response
  • Awaiting response
  • Awaiting response
  • Runner resumed
  • Runner ended (done)