feat(db): invitations table and the lookup that resolves one to a tenant (CHOO-2722) - #446
Conversation
Nothing stops create(uses_remaining=0) or a negative, and nothing at the database level will stop a decrement going below zero later. That matters because the consume path is the next PR's, and the obvious implementation of it — read the row, check uses_remaining > 0, invitation.uses_remaining -= 1, flush — double-spends under concurrency. Two redemptions of a single-use invitation both read 1 and both issue SET uses_remaining = 0; read-committed does not stop that, both commit, and two people get membership from one invite. Two lines here close it for good:
This is a schema PR, and this is a schema-shaped fix. Putting it in the routes PR means reviewing it in a diff that is mostly HTTP.
get_by_token_hash returns revoked, expired and spent invitations alike, with no signal. tenant_of_invitation's docstring says the scoped read is where "its role, its email, whether it is spent or revoked" gets checked — but there is no method that checks any of it, so every caller will hand-roll revoked_at is None and expires_at > now and uses_remaining > 0. Three independent gates, hand-rolled per call site, is how one of them gets left out of the second call site. Either a get_valid_by_token_hash that expresses the predicate once, or an is_valid on the model. Cheap now, and it keeps the invariant in the one module that owns it.
expires_at: Mapped[str], revoked_at: Mapped[str | None] and created_at: Mapped[str] all sit on DateTime(timezone=True) columns, while InvitationStore.create correctly takes expires_at: datetime. The tell is in the same diff: That type: ignore is not working around a SQLAlchemy limitation — it is silencing a correct complaint about a wrong annotation. I changed the three to Mapped[datetime] / Mapped[datetime | None], added the import, and removed the ignore: mypy passes clean. Nothing else needed changing. It is a pre-existing house pattern (TenantMember.created_at does the same), so I'm not asking for a sweep. But this is the first place it forces a suppression in new code, and expires_at is a value callers will compare and do arithmetic on, so it's the worst one to leave as str. |
4c4ce57 to
0fc9869
Compare
b05a1eb to
16e3f92
Compare
|
Rebased onto 1 — 2 — no way to ask whether an invitation is usable. One shared predicate now backs 3 — the datetime annotations. Fixed exactly as described; the One thing worth flagging, because it nearly got past me. The concurrency test was worthless as first written. I ran it against a deliberately naive Both sessions now issue a statement before meeting at an Full suite: 3157 passed, 6 skipped, 11 deselected, 1 xfailed. 🤖 Generated with Claude Code |
…ant (CHOO-2722) The schema half of the tenant API from multi-tenancy-phase2-tenants.md §5: a credential that grants membership in a tenant, minted by an owner or admin and accepted by whoever holds it. `email` is null for a shareable link; `role`, `expires_at`, `uses_remaining` and `revoked_at` are what an acceptance route (a later branch) will check before honouring one. Only a hash of the token is ever stored. `InvitationStore.create` generates the token and hands it back exactly once, in its return value; the `Invitation` row has no column that could hold the plaintext, so nothing built on top of the store can log or persist it by accident. `tenant_of_invitation(token_hash)` joins the seven existing SECURITY DEFINER lookups in db/tenant_lookup.py as the eighth. Accepting an invitation is the same credential-resolution shape as a bearer token: the request carries a token and nothing else, no tenant is bound, and the table is tenant-scoped like everything else — so the ordinary policy refuses the read that has to happen first. The lookup answers only which tenant, never the row. test_tenant_lookup.py gets the same _ADDED_SINCE bookkeeping the closed-list tests already use for a dropped lookup, in the other direction: the frozen copy in 9c41a7b0e5d8 predates this table, so the comparison excludes the new function by name rather than by loosening to a subset check, and a dedicated test pins that the new migration installs the exact DDL db/tenant_lookup.py builds. test_frozen_ddl_matches_create_all.py and test_tenant_schema_catalogue .py needed no changes — both derive their expectations from the live schema rather than a hand-kept list, so a correctly-scoped new table is covered by construction. Verified the isolation and lookup tests actually bite: temporarily exempting invitations from row-level security fails the two isolation tests plus the closed global-table-list test, and removing the eighth lookup breaks import of the module that both defines and exercises it.
…aller's `get_by_token_hash` returned revoked, expired and spent invitations with no signal, so every caller would have hand-rolled the same three conditions — and `tenant_of_invitation`'s docstring already promised a scoped read that checks them. `get_valid_by_token_hash` expresses the predicate once, in the module that owns the invariant. `consume` is the decrement, and it is one conditional UPDATE … WHERE uses_remaining > 0 AND revoked_at IS NULL AND expires_at > now() RETURNING, sharing that predicate. The obvious alternative — read the row, check, subtract in Python, flush — double-spends: two acceptances of a single-use invitation both read 1, both write 0, read-committed commits both, and one invite grants two memberships. `ck_invitations_uses_remaining_not_negative` is the floor under any future decrement that forgets; `>= 0` and not `> 0` because a spent invitation has to stay representable. Refusal raises rather than returning None: revoked, expired and spent are states a caller answers for, not states it retries. `expires_at`, `revoked_at` and `created_at` were annotated `Mapped[str]` on DateTime(timezone=True) columns, which is what forced the `type: ignore` on `revoke`'s assignment — the suppression was silencing a correct complaint, not a SQLAlchemy limitation. Corrected and the ignore removed; mypy passes clean. The wider `Mapped[str]`-on-datetime pattern elsewhere in models.py is left alone. The concurrency test needed a barrier to be worth anything. Without one the second acceptance spends the race opening its connection, lands after the first has committed, and passes against a double-spending implementation — which it did, until both sessions were made to issue a statement before the barrier and collide with connections already in hand. Verified each new assertion bites: the naive read-then-write `consume` fails the race test on three consecutive runs; dropping `revoked_at IS NULL` from the shared predicate fails both parametrised `revoked` cases; removing the check constraint fails the floor test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16e3f92 to
f7829e5
Compare
…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>
Rebased onto
mainWas stacked on #444; that and #445 have both merged, so this is now a single commit on top of
mainplus a review-fix commit. Still schema only — no HTTP routes; those are #447.Summary
invitationstable. Tenant-scoped, so it inherits the policy by default rather than by argument. It stores a hash of the token and never the token — the plaintext is returned once, by the create call, and exists nowhere else.tenant_of_invitation(token_hash)as an eighthSECURITY DEFINERlookup. Resolving an invitation before any tenant is bound is the same shape as resolving a bearer token or an OIDC client id: it answers which tenant, never what row, which is the property the module argues makes its exemption defensible. The list is closed and pinned by tests against the installed functions and the migration's frozen copy, so adding to it is an edit a reviewer sees — as intended.Review fixes (second commit)
uses_remaininghas a floor, and spending a use is one statement.ck_invitations_uses_remaining_not_negativeon the table, andconsumeas a single conditionalUPDATE … WHERE uses_remaining > 0 AND revoked_at IS NULL AND expires_at > now() … RETURNING. Read-then-write double-spends — two acceptances of a single-use invitation both read1, both write0, read-committed commits both, and one invite grants two memberships. The constraint is>= 0and not> 0because a spent invitation has to stay representable.get_valid_by_token_hashandconsume. Refusal raises rather than returningNone.expires_at,revoked_atandcreated_atwereMapped[str]onDateTime(timezone=True), which is what forced the# type: ignoreonrevoke's assignment — the suppression was silencing a correct complaint. The widerMapped[str]-on-datetime pattern elsewhere inmodels.pyis left alone.Test plan
just check,just typecheck— pass. Full suite: 3157 passed, 6 skipped, 11 deselected, 1 xfailed.consume: the second acceptance spent the race opening its connection and landed after the first had committed. Both sessions now issue a statement and meet at a barrier before racing, so they collide with connections already in hand — and the naive implementation then fails it on three consecutive runs.revoked_at IS NULLfrom the shared predicate fails both parametrisedrevokedcases; removing the check constraint fails the floor test.work/multi-tenancy-phase4adds one for its messaging installs. Two branches claiming the same slot in a deliberately closed, test-pinned list is a semantic conflict, and each also freezes a copy of the schema in its own migration — a textual merge will produce something that passes neither test.b1d7c4f0a92e, which is several revisions behindmain's head. It will fork the migration chain the moment it merges, independently of this branch.Whoever merges second has to reconcile the lookup list and the frozen DDL by hand, not by rebase.
🤖 Generated with Claude Code