feat(gateway): let a session select its tenant among several memberships (CHOO-2723) - #445
Conversation
|
Nothing blocking.
▎ tenants_of_user is read once and used for every case below it, rather than once per case, so this is one round trip to the exemption regardless of which case answers. The 409 branch then calls list_tenant_memberships, which opens with for tenant_id in await tenants_of_user(session_factory, user_id). So the choose-one path reads the exemption twice. Either pass the ids down or correct the sentence — but this is the function that gates every authenticated request, and its comment is the sort this repo treats as load-bearing.
▎ This account is not a member of the selected tenant; ask an administrator to check its membership. The caller can fix this themselves: GET /tenants and POST /tenants/{id}/switch both still work, because neither goes through get_current_user. Signing in again also clears it, since login mints a null claim. So the message sends someone to an administrator for a problem they can resolve in one click. That matters more than it sounds, because removing a member — which ships in this phase — puts everyone it touches into exactly this state on their next request. The wording is a leftover from case 5, where "ask an administrator" genuinely is the answer; the two cases now need different sentences.
detail is a bare list of tenants. FastAPI's own validation failures are also {"detail": [...]}, so a client distinguishing them has only the status code to go on, and 409 is a plausible status for a future conflict elsewhere in the API. The description makes the case itself — this is "a breaking change for every client at once" — so shaping the body now ({"error": "tenant_selection_required", "tenants": [...]}) costs nothing and avoids doing it twice. Same for giving case 2's 403 a machine-readable code, which is what the SPA in point 2 would branch on.
Its two siblings both do: get_system_session has test_the_routes_with_no_tenant_bound_are_exactly_these_three, and the lookup exemption has test_tenant_exemption_allowlist, whose docstring in this very diff argues that reachability is what matters, not current use. get_authenticated_user_id is precisely a reachable way to skip the tenant check. Today it is used by exactly the two routes in tenants.py, which is correct; nothing pins that. It wants the same three-line exact-match test as get_system_session — and by the allowlist docstring's own reasoning, more than either of them.
test_refresh_carries_the_selected_tenant_forward covers the mint that carries a tenant. The two that pass a literal None — password login and the OIDC callback — have no test on the claim. That None is the recovery path from a stale selection (point 2), so if someone later made login "helpfully" carry the previous tenant forward, the suite would stay green and a removed member would be 403'd until their cookie expired. Two asserts.
|
4c4ce57 to
0fc9869
Compare
…ips (CHOO-2723)
The gateway JWT gains a tenant claim, minted (or carried forward) on every
path that sets the switch_auth cookie -- login, the OIDC callback, refresh
and the new switch endpoint. `_resolve_tenant_id` replaces the old
sole-membership guard with the ordered cases from
docs/old/multi-tenancy-phase2-tenants.md, SS4: a claim naming a live
membership binds it, a claim naming none is refused, no claim with exactly
one membership binds it as before, no claim with several is a 409 listing
them behind GATEWAY_TENANT_CHOICE_ENABLED (default off, so nobody's
single-membership session changes behaviour), and no memberships is still a
403.
GET /tenants and POST /tenants/{id}/switch (gateway/tenants.py) let a caller
list their own memberships and pick one without ever holding two database
connections at once -- the listing authenticates independently of
get_current_user and reads each tenant on its own short session,
sequentially, which is also what backs the 409's body.
sole_tenant_id and TenantMembershipError are gone; the tests that pinned two
memberships as an error now pin the opposite.
Two docstrings still pointed at sole_tenant_id after it was replaced by _resolve_tenant_id and list_tenant_memberships in gateway/auth.py.
Addresses review on #445. The three ways `_resolve_tenant_id` can refuse now carry a machine-readable `error`, so a client can tell them apart from each other and from FastAPI's own `{"detail": [...]}` validation failures, which the 409 was previously indistinguishable from: - `tenant_selection_invalid` (403) — the claim names no membership - `tenant_selection_required` (409) — several memberships, none selected; the choices move under a `tenants` key - `tenant_membership_unresolved` (403) — no memberships, or several with the choice flag off Case 2's message changed with it. A caller whose selection has gone stale can recover unaided: `GET /tenants` and `POST /tenants/{id}/switch` authenticate without binding a tenant, and signing in again mints a null claim. Telling them to ask an administrator was wrong, and member removal — which ships in this phase — puts everyone it touches into exactly that state on their next request. `_resolve_tenant_id`'s docstring claimed one round trip to the `SECURITY DEFINER` exemption while the 409 path made two. Split `_describe_memberships` out of `list_tenant_memberships` so the case that builds a body reuses the ids it has already read; the claim is now pinned by a test that counts the calls. Also: - `get_authenticated_user_id` gains the exact-match guard its two siblings have. It is a reachable way past every case above, and only the two routes in `tenants.py` have a reason to use it. A second test refuses any route taking it *and* `get_current_user`, which would resolve a tenant and ignore the answer. - Pin that password login and the OIDC callback mint a null claim. That null is the recovery path out of a stale selection, and only `/auth/refresh`'s claim was asserted anywhere. - Drop a comment citing `get_sole_tenant_id`, a symbol this branch removes and which was never its name. Every new assertion was verified to fail against the unfixed code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c865095 to
0d41506
Compare
|
Rebased onto 1. The docstring's "one round trip" is now true. Split 2 & 3. All three refusals are discriminated, and case 2 stops misdirecting the caller.
Exported as constants from Case 2's message is now "The workspace selected on this session is not one you belong to. Choose another, or sign in again." — you were right that this is the common 403 once member removal ships, and that the caller can resolve it in one click. The test asserts the word "administrator" is absent, so the old wording cannot drift back. Nothing in 4. 5. Login and the OIDC callback are pinned to a null claim. 6. Verification. One note for the stack: #446 and #447 are still based on the pre-rebase 🤖 Generated with Claude Code |
Stacked on #444
Base is
work/split-operator-and-workspace-roles, notmain. Review #444 first; this only makes sense on top of it.Summary
Sign-in resolves the caller's single
tenant_membersrow and returns 403 for anyone holding two — a deliberate Phase 1 guard, and the thing that makes "pick a workspace" impossible. This replaces it./auth/refreshis the one that matters: it re-minted from the user alone, so without this a refresh would silently drop the selection and drop a multi-workspace person into the choose-one path mid-session._resolve_tenant_idbecomes the five ordered cases from the design: claim with a membership binds it; claim without one is 403; no claim and one membership binds it, exactly as today; no claim and several is the choose-one response; no memberships is 403. The 403 deliberately does not clear the cookie — it islax, so a cross-site navigation reaches that path, and clearing there would let any page reset someone's selection.GET /tenantsandPOST /tenants/{id}/switch. Switching verifies membership before re-minting; nobody can select a workspace they do not belong to.sole_tenant_idandTenantMembershipErrorare gone. The tests asserting "two memberships is an error" were inverted rather than deleted — that is precisely the behaviour this removes, so it is what the suite should now prove is gone.Nothing changes for anyone until we say so
The choose-one response is a breaking change for every client at once, and Console's workspace switching is not in this phase. So it is behind
GATEWAY_TENANT_CHOICE_ENABLED, default false — with the flag off, several memberships behave exactly as they do today. Documented in.env.example.The listing constraint, which is the interesting part
GET /tenantsneeds each workspace's name and the caller's role, from two tables a bound session can read for exactly one tenant. The obvious implementation loops bound sessions inside the handler — and the request session already holds its connection, so that needs a second one concurrently.TestARequestHoldsOneConnectionpins one per request with a single-connection pool precisely to stop that creeping in.So the listing authenticates without binding a tenant (a new dependency that verifies the JWT and the user and stops there), reads the tenant ids once, then reads each workspace in its own short bound session, sequentially. One connection at any moment. The same routine produces the choose-one body, because it is the same question asked at a different moment.
Test plan
just check,just typecheck— pass. Full suite: 3121 passed, 6 skipped, 1 xfailed, 0 failed.None:test_a_claim_binds_even_with_several_membershipsgot 403 instead of 200, andtest_a_claim_naming_no_membership_is_refusedgot 200 instead of 403.None.sqlalchemy.exc.TimeoutError: QueuePool limit of size 1 overflow 0 reached, connection timed out.