Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions core/switch_core/db/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import uuid
from datetime import datetime

from sqlalchemy import (
DDL,
Expand Down Expand Up @@ -259,6 +260,76 @@ class ApiKey(TenantScoped, Base):
)


# ── Invitations ────────────────────────────────────────────────────────────────


class Invitation(TenantScoped, Base):
"""A credential that grants membership in a tenant (CHOO-2722).

`email` is null for a shareable link and set for an invitation addressed
to one person; nothing at this layer refuses acceptance by a different
address, which is a decision for whatever accepts the invitation, not for
the row that describes it.

`token_hash` is unique across the whole deployment rather than per tenant,
the same reasoning as `api_keys.key_hash`: accepting an invitation is
exactly the credential-resolution shape that runs before any tenant is
bound, so the hash has to be resolvable on its own. `tenant_of_invitation`
(`db/tenant_lookup.py`) is the lookup that does it. The hash, never the
token: nothing in this schema, this store, or anything built on either
holds the plaintext once `InvitationStore.create` has returned it.

`role` is a checked string rather than an enum type, matching
`tenant_members.role` — the role an acceptance would grant, not one held
by anything yet.

`uses_remaining` and `expires_at` bound how long and how many times the
token works; `revoked_at` is a third, independent way to stop it early.
None of the three are optional here — a table that could not expire or be
revoked would not be a credential, and the design this implements is
explicit that expiry and revocation are not optional.

The floor under `uses_remaining` is a constraint rather than a convention
because the thing it guards against is a lost race, not a typo: two
concurrent acceptances of a single-use invitation can both read `1` and
both write `0`, and read-committed will let both commit. `consume`
(`db/stores/invitation_store.py`) is the decrement that cannot lose that
race; the constraint is what makes any other decrement fail loudly instead
of over-granting membership.
"""

__tablename__ = "invitations"
__table_args__ = (
CheckConstraint(
"role IN ('owner', 'admin', 'member')", name="ck_invitations_role"
),
CheckConstraint(
"uses_remaining >= 0", name="ck_invitations_uses_remaining_not_negative"
),
)

id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid)
role: Mapped[str] = mapped_column(Text, nullable=False)
email: Mapped[str | None] = mapped_column(Text, nullable=True)
# Global on purpose, like api_keys.key_hash: accepting an invitation
# resolves the hash before a tenant is known, so it cannot be scoped by
# one.
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
uses_remaining: Mapped[int] = mapped_column(Integer, nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by: Mapped[str] = mapped_column(
Text, ForeignKey("users.id"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)


# ── Clients ────────────────────────────────────────────────────────────────────


Expand Down
2 changes: 2 additions & 0 deletions core/switch_core/db/stores/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from switch_core.db.stores.collaboration_bridge_store import CollaborationBridgeStore
from switch_core.db.stores.document_store import DocumentStore
from switch_core.db.stores.external_user_store import ExternalUserStore
from switch_core.db.stores.invitation_store import InvitationStore
from switch_core.db.stores.message_store import MessageStore
from switch_core.db.stores.reference_store import ReferenceStore
from switch_core.db.stores.reference_type_store import ReferenceTypeStore
Expand All @@ -25,6 +26,7 @@
"CollaborationBridgeStore",
"DocumentStore",
"ExternalUserStore",
"InvitationStore",
"MessageStore",
"ReferenceStore",
"ReferenceTypeStore",
Expand Down
166 changes: 166 additions & 0 deletions core/switch_core/db/stores/invitation_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
from __future__ import annotations

import hashlib
import secrets
from datetime import UTC, datetime

from sqlalchemy import ColumnElement, and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from switch_core.db.models import Invitation

_TOKEN_BYTES = 32


class InvitationNotUsableError(Exception):
"""An invitation was asked to grant membership and could not.

Raised rather than returned as `None`, because the three ways to get here
— revoked, expired, spent — are all states a caller has to answer for
rather than states it can usefully retry or ignore. Also raised when the
invitation does not exist at all, or belongs to a tenant this session is
not bound to: the row-level-security policy makes those indistinguishable
from the outside, which is the point of it.
"""


def _usable() -> ColumnElement[bool]:
"""The three independent gates on an invitation, as one predicate.

Written once and shared by everything that asks the question, because
three gates hand-rolled per call site is how the second call site ends up
checking two. Postgres evaluates it — expiry against the database's clock,
not the caller's — so `consume` can decide and decrement in one statement.
"""
return and_(
Invitation.revoked_at.is_(None),
Invitation.expires_at > func.now(),
Invitation.uses_remaining > 0,
)


def generate_invitation_token() -> tuple[str, str]:
"""A fresh invitation token and the hash that is all Switch ever stores.

The plaintext lives only in the tuple this returns. Nothing keeps a
second copy of it: `Invitation` has no column for it, so there is nothing
on the row — and therefore nothing a query, a log line, or a repr of that
row could leak — beyond the hash. A caller that loses the plaintext has
to revoke and mint again, the same as a lost API key.
"""
token = secrets.token_urlsafe(_TOKEN_BYTES)
return token, hashlib.sha256(token.encode()).hexdigest()


class InvitationStore:
async def create(
self,
session: AsyncSession,
*,
role: str,
email: str | None,
expires_at: datetime,
uses_remaining: int,
created_by: str,
) -> tuple[Invitation, str]:
"""Mint an invitation, returning the row and its plaintext token.

The token is generated here, inside the one call that can hand it
back — never before, never separately, and never assigned to the row
that persists. A caller that needs to show the token to whoever it
invited must do so from this call's return value; there is no later
read that will give it back.
"""
token, token_hash = generate_invitation_token()
invitation = Invitation(
role=role,
email=email,
token_hash=token_hash,
expires_at=expires_at,
uses_remaining=uses_remaining,
created_by=created_by,
)
session.add(invitation)
await session.flush()
return invitation, token

async def get_by_token_hash(
self, session: AsyncSession, token_hash: str
) -> Invitation | None:
"""The invitation named by a token's hash.

Called on a session already bound to the invitation's tenant —
resolved first through `db/tenant_lookup.py`'s `tenant_of_invitation`,
the same two-step shape a bearer token's own resolution takes. Row-
level security refuses this read on a session with nothing bound.
"""
result = await session.execute(
select(Invitation).where(Invitation.token_hash == token_hash)
)
return result.scalar_one_or_none()

async def get_valid_by_token_hash(
self, session: AsyncSession, token_hash: str
) -> Invitation | None:
"""The invitation named by a token's hash, if it is still usable.

What `get_by_token_hash` finds says nothing about whether the token
still works: a revoked, expired or spent invitation is an ordinary row
and reads back like any other. This is the read for anyone about to
act on one — showing the invitee what they were invited to, say —
and it answers with the same predicate `consume` enforces.

A `None` here is not permission to skip `consume`'s own check. The row
can be spent between the two by whoever else holds the same link;
`consume` is where that race is settled.
"""
result = await session.execute(
select(Invitation).where(Invitation.token_hash == token_hash, _usable())
)
return result.scalar_one_or_none()

async def consume(self, session: AsyncSession, invitation_id: str) -> Invitation:
"""Spend one use of an invitation, or refuse.

One conditional `UPDATE`: the gates are in the `WHERE`, so the row is
checked and decremented in the same statement and Postgres arbitrates
between concurrent acceptances. Read-then-write cannot do that — two
acceptances of a single-use invitation both read `1`, both write `0`,
both commit, and one invite grants two memberships.

No row updated means no usable invitation was there to update, which
is what `InvitationNotUsableError` reports.
"""
result = await session.execute(
update(Invitation)
.where(Invitation.id == invitation_id, _usable())
.values(uses_remaining=Invitation.uses_remaining - 1)
.returning(Invitation)
.execution_options(populate_existing=True)
)
invitation = result.scalar_one_or_none()
if invitation is None:
raise InvitationNotUsableError(
f"Invitation {invitation_id} is revoked, expired, spent, or not "
"visible to this session"
)
return invitation

async def list_for_tenant(self, session: AsyncSession) -> list[Invitation]:
"""Every invitation of the session's bound tenant.

No `WHERE tenant_id = …` of its own: the policy is what narrows this,
the same as every other listing in this package.
"""
result = await session.execute(
select(Invitation).order_by(Invitation.created_at)
)
return list(result.scalars().all())

async def revoke(self, session: AsyncSession, invitation_id: str) -> Invitation:
invitation = await session.get(Invitation, invitation_id)
if invitation is None:
raise ValueError(f"Invitation not found: {invitation_id}")
invitation.revoked_at = datetime.now(UTC)
await session.flush()
return invitation
49 changes: 42 additions & 7 deletions core/switch_core/db/tenant_lookup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""The whole exemption from row-level security, written out as seven functions.
"""The whole exemption from row-level security, written out as eight functions.

Row-level security is enforced by `require_tenant_id()` (`db/rls_ddl.py`),
which raises when no tenant is bound. That is the property everything else
Expand Down Expand Up @@ -32,7 +32,8 @@
- **They are a closed list.** `TENANT_LOOKUPS` below is the list; a test
compares it against the functions actually installed, against the
migration's frozen copy, and against what each one answers when called as
the restricted role. Adding an eighth is an edit a reviewer sees.
the restricted role. Adding one is an edit a reviewer sees, and is meant to
be argued with rather than waved through.

**What the exemption gives, stated exactly.** Every lookup returns `setof
text` — tenant ids, never a row of a scoped table. That much is the part
Expand Down Expand Up @@ -63,8 +64,8 @@

So the property this design actually holds is: **the exemption discloses the
shape of the deployment — which tenants exist, and which tenant a given user,
credential, room, bridge or connector belongs to — and no row of any
tenant-scoped table.** It is a boundary on data, not on metadata. Narrowing
credential, room, bridge, connector or invitation belongs to — and no row of
any tenant-scoped table.** It is a boundary on data, not on metadata. Narrowing
the second is a question about who may hold the runtime role's credentials at
all, since everything above is reachable by anyone who has them.

Expand All @@ -85,7 +86,7 @@
identifier the caller already has, and everything after it is scoped.

**A lookup whose caller already knows the answer does not belong here.** There
was an eighth, `tenant_of_client`, and it was the one called most: every
was one more, `tenant_of_client`, and it was the one called most: every
client's transport asked it, once per transport, and so did every agent
client's `start`. Both were built from a `clients` row that names the tenant
in a column, so the question was asked of the database with the answer already
Expand All @@ -95,6 +96,18 @@
function nobody needs is still a function every role could call, so the shorter
list is the whole point of noticing.

**`tenant_of_invitation` is the eighth, and it is the same shape as
`tenant_of_api_key`.** Accepting an invitation is credential resolution: the
request carries a token and nothing else, no tenant is bound yet, and the
table it would have to read (`invitations`) is tenant-scoped like everything
else, so the policy refuses exactly the read that has to happen first. Resolve
the tenant here, bind it, then read the invitation itself — its role, its
email, whether it is spent, expired or revoked — through the ordinary scoped
store, whose `get_valid_by_token_hash` and `consume` are where those three
gates are actually enforced. Nothing about *that* row crosses the exemption;
only the tenant id does, which is the property every lookup in this module
rests on.

Why not the obvious alternatives is argued in
`docs/old/multi-tenancy-phase1-db.md`, "The bootstrap problem"; the short
version is that returning rows instead of tenant ids would put a second copy
Expand Down Expand Up @@ -257,6 +270,18 @@ def signature(self) -> str:
query="SELECT tenant_id FROM server_connectors WHERE id = p_connector_id",
purpose="Same as the bridge, for a server-side connector.",
),
TenantLookup(
name="tenant_of_invitation",
argument="token_hash",
query="SELECT tenant_id FROM invitations WHERE token_hash = p_token_hash",
purpose=(
"Which tenant an invitation belongs to, resolved from the hash of "
"its token before any tenant is bound — the same credential-"
"resolution shape as a bearer token, and necessary for the same "
"reason: accepting an invitation is exactly the read the policy "
"refuses to a session with nothing bound yet."
),
),
)

TENANT_LOOKUPS_BY_NAME: dict[str, TenantLookup] = {
Expand Down Expand Up @@ -312,9 +337,9 @@ def attach_tenant_lookups(metadata: MetaData) -> None:
# ── Calling them ──────────────────────────────────────────────────────────────

# One `text()` per lookup, written out rather than assembled from `lookup.name`
# at call time: the seven names are fixed and known here, so there is nothing
# at call time: the eight names are fixed and known here, so there is nothing
# for a call site to build. The assertion below is what keeps this dict from
# quietly falling behind `TENANT_LOOKUPS` — an eighth lookup with no entry
# quietly falling behind `TENANT_LOOKUPS` — a ninth lookup with no entry
# here fails at import, not with a `KeyError` on whatever request reaches it
# first.
_LOOKUP_STATEMENTS: dict[str, TextClause] = {
Expand All @@ -337,6 +362,9 @@ def attach_tenant_lookups(metadata: MetaData) -> None:
"tenant_of_server_connector": text(
"SELECT tenant_id FROM tenant_of_server_connector(:argument) AS tenant_id"
),
"tenant_of_invitation": text(
"SELECT tenant_id FROM tenant_of_invitation(:argument) AS tenant_id"
),
}

assert _LOOKUP_STATEMENTS.keys() == TENANT_LOOKUPS_BY_NAME.keys(), (
Expand Down Expand Up @@ -425,3 +453,10 @@ async def tenant_of_server_connector(
) -> str | None:
lookup = TENANT_LOOKUPS_BY_NAME["tenant_of_server_connector"]
return _at_most_one(lookup, await _call(session_factory, lookup, connector_id))


async def tenant_of_invitation(
session_factory: async_sessionmaker[AsyncSession], token_hash: str
) -> str | None:
lookup = TENANT_LOOKUPS_BY_NAME["tenant_of_invitation"]
return _at_most_one(lookup, await _call(session_factory, lookup, token_hash))
Loading
Loading