Skip to content

feat(db): invitations table and the lookup that resolves one to a tenant (CHOO-2722) - #446

Merged
petr-sandbox merged 2 commits into
mainfrom
work/tenant-invitations-schema
Sep 14, 2026
Merged

petr-sandbox merged 2 commits into
mainfrom
work/tenant-invitations-schema

Conversation

@wojtyniakAQ

@wojtyniakAQ wojtyniakAQ commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto main

Was stacked on #444; that and #445 have both merged, so this is now a single commit on top of main plus a review-fix commit. Still schema only — no HTTP routes; those are #447.

Summary

  • An invitations table. 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 eighth SECURITY DEFINER lookup. 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.
  • Store methods: create, read by token hash, read valid by token hash, list a tenant's invitations, consume, revoke.

Review fixes (second commit)

  • uses_remaining has a floor, and spending a use is one statement. ck_invitations_uses_remaining_not_negative on the table, and consume as a single conditional UPDATE … 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 read 1, both write 0, read-committed commits both, and one invite grants two memberships. The constraint is >= 0 and not > 0 because a spent invitation has to stay representable.
  • The three gates are expressed once. Revoked, expired and spent were checkable only by hand, at each call site, which is how the second call site ends up checking two of three. One shared predicate now backs both get_valid_by_token_hash and consume. Refusal raises rather than returning None.
  • The datetime columns are annotated as datetimes. expires_at, revoked_at and created_at were Mapped[str] on DateTime(timezone=True), which is what forced the # type: ignore on revoke's assignment — the suppression was silencing a correct complaint. The wider Mapped[str]-on-datetime pattern elsewhere in models.py is left alone.

Test plan

  • just check, just typecheck — pass. Full suite: 3157 passed, 6 skipped, 11 deselected, 1 xfailed.
  • Isolation proved to bite. Unscoping the table — adding it to the global list, i.e. the mistake this codebase's catalogue test exists to catch — fails four tests: a session bound to one workspace reading another's invitation by token, the same via the listing, an unbound session reading any invitation at all, and the catalogue guard itself. Restored, all pass.
  • The concurrency test was worthless until it was fixed. Written the obvious way it passed against a deliberately double-spending 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.
  • The other two new assertions bite. Dropping revoked_at IS NULL from the shared predicate fails both parametrised revoked cases; removing the check constraint fails the floor test.

⚠️ Still collides with Phase 4 (#435)

  • Both add an eighth lookup. work/multi-tenancy-phase4 adds 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.
  • Phase 4's migration is chained off b1d7c4f0a92e, which is several revisions behind main'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

@petr-sandbox

Copy link
Copy Markdown
Collaborator
  1. uses_remaining has no CHECK, and this is the PR where that gets cheap.

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:

  • CheckConstraint("uses_remaining >= 0", name="ck_invitations_uses_remaining"), so even a naive later implementation fails loudly rather than over-granting.
  • A consume method on this store doing the decrement as a single conditional UPDATE … SET uses_remaining = uses_remaining - 1 WHERE id = :id AND uses_remaining > 0 RETURNING …, so the database arbitrates and the caller cannot get it wrong.

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.

  1. The store offers no way to ask whether an invitation is usable, and three separate gates to forget.

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.

  1. The datetime annotations are wrong, and the diff contains its own proof.

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:

invitation.revoked_at = datetime.now(UTC)  # type: ignore[assignment]

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.

@petr-sandbox petr-sandbox left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some notes.

@petr-sandbox
petr-sandbox force-pushed the work/split-operator-and-workspace-roles branch from 4c4ce57 to 0fc9869 Compare September 14, 2026 12:40
@petr-sandbox
petr-sandbox force-pushed the work/tenant-invitations-schema branch from b05a1eb to 16e3f92 Compare September 14, 2026 15:15
@petr-sandbox
petr-sandbox changed the base branch from work/split-operator-and-workspace-roles to main September 14, 2026 15:15
@petr-sandbox

Copy link
Copy Markdown
Collaborator

Rebased onto main (#444 and #445 both merged; base retargeted) and pushed the three fixes as a second commit.

1 — uses_remaining has no CHECK, and the consume path double-spends. Both closed here. ck_invitations_uses_remaining_not_negative on the table, and a consume that does the decrement as one conditional UPDATE … WHERE uses_remaining > 0 AND revoked_at IS NULL AND expires_at > now() … RETURNING, so Postgres arbitrates and the caller cannot get it wrong. >= 0 rather than > 0 because a spent invitation is a 0 and has to stay representable.

2 — no way to ask whether an invitation is usable. One shared predicate now backs get_valid_by_token_hash and consume, so the three gates are written once in the module that owns the invariant. Refusal from consume raises InvitationNotUsableError rather than returning None — revoked, expired and spent are states the caller answers for, not states it retries. tenant_of_invitation's docstring now names the two methods that actually enforce what it promised.

3 — the datetime annotations. Fixed exactly as described; the type: ignore is gone and mypy is clean. The wider Mapped[str]-on-datetime pattern elsewhere is left alone.

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 consume — read the row, check, subtract in Python, flush — and it passed. Two asyncio.gather'd acceptances do not actually collide when one of them has to open a connection first: the second spends the race on TCP and auth, lands after the first has committed, reads 0, and correctly refuses. Exactly the observation the test was supposed to make impossible, arrived at for the wrong reason.

Both sessions now issue a statement before meeting at an asyncio.Barrier, so they race with connections already in hand. The naive implementation then fails on three consecutive runs, and the real one passes. Worth remembering for any future race test in this suite — a pass is not evidence until you have watched it fail.

Full suite: 3157 passed, 6 skipped, 11 deselected, 1 xfailed. just check and just typecheck clean.

🤖 Generated with Claude Code

wojtyniakAQ and others added 2 commits September 14, 2026 11:32
…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>
@petr-sandbox
petr-sandbox force-pushed the work/tenant-invitations-schema branch from 16e3f92 to f7829e5 Compare September 14, 2026 15:35
@petr-sandbox
petr-sandbox merged commit ec8dced into main Sep 14, 2026
10 checks passed
petr-sandbox added a commit that referenced this pull request Sep 14, 2026
…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>
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.

2 participants