feat(gateway): workspace, invitation and member routes (CHOO-2722) - #447
Conversation
update_member_role is gated by require_tenant_admin, and beyond the last-owner check it places no limit on which role an admin may grant or to whom — including themselves. I added a test: Final state: ADMIN NOW owner, ORIGINAL OWNER NOW REMOVED. The last-owner guard never fires, because at every step there genuinely are two owners — the admin just made sure of it first. This makes owner and admin the same role with extra steps, and it hollows out the invariant #439 states as a guarantee ("a workspace must always have an owner", §5): the workspace still has an owner, just not the one it started with. Granting or removing owner should require the caller to be an owner, not merely an admin. Same argument for create_invitation — an admin can currently mint an owner invitation.
InvitationStore.consume is a Python read-modify-write: Two POST /invitations/{token}/accept in an asyncio.gather, uses_remaining=1: Note the last line, because it changes the fix I suggested on #446: the counter landed on 0, not −1. The ORM writes the absolute value from each session's own snapshot, so both issued SET uses_remaining = 0 — a CHECK (uses_remaining >= 0) would not have caught this either. The database has to arbitrate the decrement itself: with a zero rowcount meaning "already used"; or, if you'd rather keep the checks where they are, with_for_update() on the get_by_token_hash in the accept path so the whole validate-and-consume serialises. The first is better — it puts the invariant in the store, which is where consume's docstring already implies it lives. This is not a theoretical window. A shareable link with uses_remaining=N is precisely the object someone pastes into a channel where several people click it within the same second. For what it's worth I probed the last-owner guard the same way — two concurrent demotions of a two-owner workspace — and could not break it; one got its 409. I'm not claiming it's proven safe, only that it isn't the same easy target.
test_session_requires_authentication.py keys every one of its four assertions on get_system_session, and says so deliberately: "get_system_session exists so that adding a fourth is a visible act, not a signature nobody reads." But GET /tenants, POST /tenants and POST /invitations/{token}/accept don't take get_system_session — they take get_session_factory and open sessions themselves. None of the three appears in _ROUTES_WITH_NO_TENANT_BOUND, and none is visible to the test. accept is the one that matters: it opens a tenant_session bound to a tenant chosen by the submitted token and writes a membership row into it. That is the most consequential unbound-session route in the codebase, and the test written to make such routes conspicuous does not know it exists. Either add get_session_factory to the discovery and declare all three, or flip the predicate to "takes neither get_session nor get_current_user", which catches the next one too.
POST /invitations/{token}/accept. It is a bearer credential that grants workspace membership, and a path segment ends up in load-balancer and reverse-proxy access logs, in browser history, and in the Referer header of whatever the accept page loads next. #446 went to real trouble to keep only a hash so the plaintext exists in exactly one place; putting it in the path puts it back in several. POST /invitations/accept with {"token": …} in the body costs nothing.
I agree with the decision — refusing beats deleting someone's agents, for exactly the reasons in the description. But the message says "reassign or delete them first", and nothing in the codebase reassigns an agent's owner. There is no gateway route that writes agents.owner_id (the only writes are owner_id=user.id at registration), and update_agent_detail on the MCP side is owner-only and doesn't touch it either. So the admin's only actual option is to delete the agents — the irreversible act the refusal exists to prevent — or to leave the member in the workspace indefinitely. That doesn't change the decision, but it does mean the reassign route has to ship alongside it, or the 409 is a dead end. At minimum the message should not offer an action that doesn't exist.
|
c865095 to
0d41506
Compare
POST /tenants creates a workspace through ClientLifecycleService's existing
provisioning seam and makes the caller its owner; a taken slug is a 409.
Invitation routes (mint/list/revoke, owner+admin only) and POST
/invitations/{token}/accept resolve the token's tenant through the exempt
lookup and do their work in a fresh tenant_session, since the request's own
session is already bound to the caller's current workspace. Member routes
add list/patch-role/remove, refusing to demote or remove a workspace's last
owner and revoking a removed member's API keys and owned agents in the same
transaction as the membership row going away.
UserStore.add_membership is now the one place a TenantMember row is
constructed; gateway/auth.py grows get_authenticated_caller and
tenant_of_invitation_token so the tenant-lookup exemption stays reachable
through that one module rather than through gateway/tenants.py directly.
Deleting a member's agents alongside their membership went past revoking access into destroying shared infrastructure: an agent sits in rooms with other members and its name is depended on well outside the membership being removed, and unlike a personal key it cannot be reminted. Member removal now refuses with a 409 naming the agents when the target owns any in this tenant, and removes nothing — no membership, no keys — until an admin reassigns or deletes them deliberately. Personal API key revocation and the last-owner refusal are unchanged.
…722) An `admin` could take a workspace from its owner in three requests — promote self to `owner`, demote the founder, remove them — and the last-owner guard never fired, because there genuinely were two owners at every step. `authz.owns_tenant` is the narrower bit beside `administers_tenant`, and granting `owner`, changing an owner's role, removing an owner and minting an `owner` invitation now all answer to it. Alongside that: - Accepting an invitation goes through `InvitationStore.consume`'s conditional `UPDATE` (#446) instead of the read-then-write copy this branch carried, so two simultaneous acceptances of a single-use link grant exactly one membership. The cherry-pick had textually merged a second `consume` into the store, shadowing the safe one. - A use is spent only when a membership is actually granted, so a double-clicked shared link no longer burns a seat for nothing. - The token moves from the path to the body: it is a bearer credential, and a URL segment is written to proxy logs, browser history and `Referer`. - `expires_in_hours` is bounded above. `1000000000` passed `gt=0` and then raised `OverflowError` inside `timedelta` — a 500 for a bad request. - The "every route that opens a session is authenticated" guard keyed on `get_system_session`, which routes taking `get_session_factory` walk straight past. It now keys on the absence of `get_current_user`, with an explicit allowlist, so the next door is a failing test rather than a route nobody counted. - The 409 on removing a member who owns agents no longer offers reassignment; no route reassigns an agent's owner in this phase. - Workspace creation cannot be one transaction (the tenant row must be committed before its admin client can be provisioned against it), so the window where the owner membership fails to land now logs an error naming the workspace and the person who should have owned it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5f26e84 to
af22ad1
Compare
|
Rebased onto 1 — privilege escalation. Fixed as you'd expect: 2 — the double accept. Took your first option. Accept now calls main's Worth flagging how the unsafe version got here at all: the rebase textually auto-merged a second
3 — the second door. Took your preferred option. The predicate is now the absence of 4 — the token in the URL. 5 — the 409 that promises reassignment. Reworded to "delete them first", and the docstring now says explicitly that there is no reassignment route in this phase, so the next person to read it doesn't re-add the promise. 6 — 7 — a use burned on re-acceptance. 8 — the ownerless-workspace window. This is the one I did not fold, and I'd like a second opinion. One transaction means moving the membership write into So I took your fallback, slightly widened: the membership write is wrapped, and a failure logs an error naming the tenant id, the slug and the user who should have owned it, then re-raises. The orphan is findable and repairable instead of silent. Happy to do the refactor in a follow-up if you'd rather not carry the window. Also: Each new guard was verified to bite by reverting the production code: escalation succeeds with |
Rebased onto
main#444, #445 and #446 are all merged, so this is no longer stacked — base is
mainand the diff is this PR's own work.Summary
The rest of the tenant API: create a workspace, mint / list / revoke / accept invitations, list members, change a role, remove a member.
POST /tenantsgoes through the existingClientLifecycleService.create_tenantseam rather than a raw insert, so a new workspace gets the admin client every tenant needs. A taken slug is a 409, never a silent suffix.POST /invitations/acceptresolves the token's tenant through the lookup and does its work in atenant_sessionof its own. Revocation, expiry, remaining uses and the bound address are all checked.The privilege escalation the review found
An
admincould take a workspace from its owner in three requests: promote self toowner, demote the founder, remove them. The last-owner guard never fired, because there genuinely were two owners at every step — each request was individually safe and the sequence was not.The fix is a second, narrower bit beside
administers_tenant:authz.owns_tenant. An admin runs the workspace; only an owner moves the ownership set. Grantingowner, changing an existing owner's role, removing an owner, and minting anownerinvitation all answer to it, on top of theowner/admingate that lets the request in at all.test_an_admin_cannot_take_the_workspace_from_its_ownerruns the three requests and asserts both memberships are untouched afterwards.The rest of the review, fixed
InvitationStore.consume's conditionalUPDATE(feat(db): invitations table and the lookup that resolves one to a tenant (CHOO-2722) #446) rather than the read-then-write copy this branch was carrying. Worth naming how that got here: the rebase ontomaintextually merged a secondconsumeinto the store, shadowing the safe one — a clean auto-merge that silently replaced the correct implementation. The test run caught it; git did not.consumehad been running unconditionally, so a double-clicked shared link burned a seat for nothing. It now sits inside theexisting_role is Nonebranch.Referer.expires_in_hoursis bounded above.1000000000passedgt=0and then raisedOverflowErrorinsidetimedelta— a 500 for what should be a 422.get_system_session, which the routes takingget_session_factoryand opening their own sessions walk straight past. It now keys on the absence ofget_current_user, with an explicit allowlist of the ten routes that resolve no tenant, so the next door is a failing test rather than a route nobody counted.The one I did not fold, disclosed rather than hidden
POST /tenantscommits the tenant row and then writes the owner membership in a second transaction. It is not one transaction because the tenant has to be committed beforeensure_system_clientcan provision an admin client against it, and that call is inside the seam; folding it means moving membership intoClientLifecycleServiceand threading aUserStorethrough nine construction sites, which is a refactor rather than a fix.What the gap gets instead is a
logger.errornaming the workspace id, the slug and the user who should have owned it, so an orphan is findable and repairable rather than silent. Say if you'd rather have the refactor.The one decision worth arguing with (unchanged)
An earlier version of this deleted every agent the removed member owned, because there is no way to disable an agent and its bearer key cannot be freed while the agent still references it. That was rejected. An agent is not a private possession: it sits in rooms with other members, it may be the only copy of a working configuration, and removing someone from a workspace is a routine administrative act that gets done by mistake. A deleted API key is reminted; a deleted agent is not. So removal refuses, naming the agents, and changes nothing.
ProtocolService.delete_agentis deliberately not used anywhere in this path — it spans several sessions and commits and calls out to the live client and the bridges, none of which folds into one transaction.Known gap, disclosed rather than hidden
Removal does not tear down a live session an agent already holds. Its next call fails authentication because the credential is gone, but one in flight continues. Judged acceptable; say if it should block instead.
Test plan
just check,just typecheck— pass. Full suite: 3188 passed, 6 skipped, 1 xfailed, 0 failed._require_ownerneutered the escalation succeeds (201/200where403is asserted); with thele=bound removed the absurd expiry reachesOverflowError: date value out of rangerather than a 422; withconsumemoved back outside the branch the re-accept test findsuses_remaining == 0instead of1.Two repo invariants caught real mistakes in the first pass
test_there_is_one_way_to_write_a_membershipcaught membership rows being constructed directly in handlers, andtest_tenant_exemption_allowlistcaught the privileged tenant lookup being reached from the wrong module. Both fixed properly rather than by weakening the tests.🤖 Generated with Claude Code