The session-to-schema step, next to the session-to-organization step

tenant_search_path is the whole isolation model in one function: given the
organization a request resolved to, return the schema its tables are in; given
none, return public alone. That second case is the point. Returning a wildcard
- or leaving the connection's own default in place, which may name a schema
nobody chose - would put every tenant's tables in reach of a request that has not
decided which tenant it is acting for.

The name is derived from the organization id rather than stored, for the reason
the migration derives it too: a recorded name that disagreed with the schema the
rows are in would describe data that no computed name can reach, and that state
would be indistinguishable from "this organization has no data". Deriving makes
it unrepresentable.

This lives in tenancy.py rather than in the adapter because it is the same
decision as scope_for, not a second decision next to it. A module that picked
the organization and another that picked the schema would be two places for the
boundary to be got wrong, and the schema one is the silent one: an unqualified
projects query simply finds nothing, and reads as an empty database.

The value is quoted rather than interpolated because search_path parses its value
as a *list*. A name carrying a comma would not name a schema, it would append
one. The check before that is unreachable from a UUID and is there anyway.
This commit is contained in:
umbra audio
2026-10-03 13:30:55 -07:00
parent 2cdebf5ac3
commit e1bbfdb604

View File

@@ -45,16 +45,97 @@ security does not silently change behaviour:
members. Under this module's rule that is a behaviour change, not a security
improvement, and it would need the policy amended to
``app.is_platform_admin() OR app.is_organization_member(organization_id)``.
Isolation: a schema per organization
------------------------------------
§5.0 replaced shared-tables-plus-RLS with **one PostgreSQL schema per
organization**, so that the strongest possible mistake fails: an unqualified query
in a context that has not resolved an organization finds no ``projects`` relation
at all, rather than finding another organization's rows behind a policy nobody has
to remember.
That gives this module a second job, and it is the same job: it already resolves
*which organization a caller may see*, and it now also resolves *which schema that
organization's data is in*. Both decisions are in one module on purpose. A module
that decided the organization and a different one that decided the schema would be
two places for the boundary to be got wrong, and the second one would be the
silent one.
The database derives the same name in ``app.tenant_schema_name``, because the
migration needs it to name the schemas it creates. Nothing in SQL joins the two
implementations, and the failure mode of a disagreement is silent and total - every
statement would resolve into a schema that is not there - so
:meth:`PostgresAccountRepository.verify_tenant_schema_contract` compares them at
startup and refuses to serve if they differ, exactly as ``verify_rls_contract``
does for ``app.user_id``.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from uuid import UUID
from .errors import ForbiddenError
from .models import Principal, RoleName
#: Prefix of every tenant schema. Also what the migration's provisioning matches
#: and what the test suite's cleanup drops by, so it is named once here rather
#: than written in three places.
TENANT_SCHEMA_PREFIX = "tenant_"
#: The schema a statement with no organization in hand runs against.
#:
#: Not a default, a decision. A platform context that could see a tenant schema
#: would be a context that could read a tenant's tables, and the whole property is
#: that it cannot.
PLATFORM_SCHEMA = "public"
#: What a tenant schema name is allowed to be.
#:
#: The name is derived from a UUID, so this cannot fail for a real organization.
#: It is here because the name reaches ``search_path``, which parses its value as
#: a *list* of identifiers: a name carrying a comma or a quote would not name a
#: schema, it would change the list. So the shape is checked before the name is
#: used, and what survives is quoted anyway.
_TENANT_SCHEMA_NAME = re.compile(rf"^{TENANT_SCHEMA_PREFIX}[0-9a-f]{{32}}$")
def tenant_schema_name(organization_id: UUID) -> str:
"""The schema holding one organization's tables.
Derived from the organization id rather than stored, so it cannot disagree with
a column somewhere. There is a specific failure for the stored alternative: an
organization whose recorded name does not match the schema its rows are in has
data that is unreachable by any name the code can compute. Deriving the name
makes that state unrepresentable, and it is what lets the rollback rebuild the
``organization_id`` column from the schema it finds the rows in.
"""
return f"{TENANT_SCHEMA_PREFIX}{organization_id.hex}"
def tenant_search_path(organization_id: UUID | None) -> str:
"""The ``search_path`` a statement runs with: one organization, or none.
With an organization, that organization's schema first and ``public`` second.
The order matters in both directions: the tenant schema has to come first or a
same-named table elsewhere would answer instead, and ``public`` has to be
reachable or the platform tables would vanish.
Without one, ``public`` alone. That is the isolation property expressed in one
function: a statement that has not resolved an organization can reach the
platform tables and no tenant's, so a query for ``projects`` in that context
raises ``relation does not exist`` rather than returning somebody's rows.
"""
if organization_id is None:
return PLATFORM_SCHEMA
name = tenant_schema_name(organization_id)
if not _TENANT_SCHEMA_NAME.match(name): # pragma: no cover - unreachable from a UUID
raise ForbiddenError("tenant_schema_invalid", "The organization's schema name is not well formed.")
return f'"{name}", {PLATFORM_SCHEMA}'
@dataclass(frozen=True, slots=True)
class TenantScope:
@@ -151,4 +232,13 @@ def resolve_target(scope: TenantScope, requested: UUID | None) -> UUID:
return requested
__all__ = ["TenantScope", "require_within", "resolve_target", "scope_for"]
__all__ = [
"PLATFORM_SCHEMA",
"TENANT_SCHEMA_PREFIX",
"TenantScope",
"require_within",
"resolve_target",
"scope_for",
"tenant_schema_name",
"tenant_search_path",
]