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:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user