Publish the caller's schema onto every transaction

Two things now go onto a pooled connection before a statement runs: the caller's
identity, which the surviving platform policies read, and the caller's schema,
which decides which tenant tables the statement can even name.

search_path is set on every transaction rather than only the ones that have an
organization, so a platform context gets public alone instead of whatever the
connection's own default happens to be. That is the difference between a design
and a hope: a default naming a tenant schema is a silent leak with no line of
code mentioning it.

verify_tenant_schema_contract exists for the same reason verify_rls_contract
does. The schema name is derived in two languages - this one and app's - and
nothing joins them. A disagreement is silent and total: every tenant query
resolves into a schema that is not there, every project request for every
organization raises "relation does not exist", and the error names the statement
rather than the cause. That presents as an empty database, which is the one
diagnosis nobody reaches for.
This commit is contained in:
umbra audio
2026-10-03 13:31:41 -07:00
parent e1bbfdb604
commit 2264a6cc42

View File

@@ -26,17 +26,29 @@ thin SQL wrapper. Each is documented at its implementation.
Connection handling
-------------------
Every operation runs inside one transaction on one pooled connection. The
caller's identity is published with ``SET LOCAL app.current_user_id`` so that
row-level-security policies, once the reviewed policies are applied, see a
server-derived value rather than anything a client controls. ``SET LOCAL`` is
scoped to the transaction, so a pooled connection cannot leak one caller's
identity into the next request.
Every operation runs inside one transaction on one pooled connection, and two
things are published onto it before the statement runs.
Until those policies exist the ``SET LOCAL`` is inert and authorization is
enforced by the explicit ``WHERE`` clauses below. That is defence in depth
being staged, not a substitute: the ``WHERE`` clauses are what actually
enforce scope today.
``SET LOCAL app.user_id`` carries the caller's identity, so the row-level
security policies that still guard the platform tables see a server-derived value
rather than anything a client controls. ``SET LOCAL`` is scoped to the
transaction, so a pooled connection cannot leak one caller's identity into the
next request.
``SET LOCAL search_path`` carries the caller's **organization**, resolved into
the schema that holds its tables. That is the isolation model: one schema per
organization, no table of tenant data shared, so an organization's ``projects``
table is found by being in the search path rather than by being filtered by a
predicate. A statement with no organization in hand gets ``public`` alone, and
therefore finds no tenant table at all.
Both are set with ``set_config``, which takes bind parameters. ``SET`` itself
takes none - psycopg renders them as ``$1`` and the grammar rejects that.
Until the policies are applied to a non-owner role the ``app.user_id`` setting is
inert and authorization is enforced by the explicit ``WHERE`` clauses below.
That is defence in depth being staged, not a substitute: the ``WHERE`` clauses
enforce scope today, and the ``search_path`` enforces the schema boundary.
"""
from __future__ import annotations
@@ -72,6 +84,7 @@ from .models import (
)
from .repository import AccountRepository
from .security import utc_now
from .tenancy import tenant_schema_name, tenant_search_path
LOGGER = logging.getLogger("umbratrans_web.postgres")
@@ -95,6 +108,19 @@ RLS_USER_SETTING = "app.user_id"
#: The function the policies call to resolve the caller.
RLS_USER_FUNCTION = "app.current_user_id"
#: The GUC that selects which tenant schema a statement sees.
#:
#: Named here rather than interpolated because this is the one setting a
#: statement's *target* depends on: `search_path` is the only way a single
#: statement template can reach a table whose address is not known until the
#: caller's organization has been resolved. Everything else in this adapter
#: names `public.` explicitly, because the platform tables have one address.
SEARCH_PATH_SETTING = "search_path"
#: The function the migration uses to name a tenant schema, and the one this
#: adapter is checked against at startup.
TENANT_SCHEMA_FUNCTION = "app.tenant_schema_name"
#: Statement timeout applied to every transaction, so one slow query cannot pin
#: a pooled connection indefinitely.
STATEMENT_TIMEOUT_MS = 5_000
@@ -164,6 +190,38 @@ class PostgresAccountRepository(AccountRepository):
LOGGER.warning("database health check failed", exc_info=True)
return False
def verify_tenant_schema_contract(self) -> None:
"""Prove the adapter and the database agree on a tenant schema's name.
The name is derived from the organization id in two languages:
``umbratrans_web.tenancy.tenant_schema_name`` for the connection, and
``app.tenant_schema_name`` for the migration that created the schemas.
Nothing in SQL joins the two together, and the failure mode of a
disagreement is silent and total - every statement would resolve into a
schema that does not exist, so every project request for every
organization would raise "relation does not exist", and the error would
name the statement rather than the cause. That reads as an empty
database, which is the one diagnosis nobody reaches for.
So this asks the database what it calls one organization's schema and
refuses to serve unless it is what this adapter would have chosen. Run at
startup, for the same reason :meth:`verify_rls_contract` is.
"""
probe = uuid4()
expected = tenant_schema_name(probe)
with self._connection() as connection:
row = connection.execute(f"SELECT {TENANT_SCHEMA_FUNCTION}(%s) AS resolved", (probe,)).fetchone()
resolved = row["resolved"] if row is not None else None
if resolved != expected:
raise RepositoryError(
"tenant_schema_mismatch",
"The database derives a different tenant schema name from this adapter, so every "
"tenant table would be looked for in a schema that does not exist. Refusing to "
"start.",
)
LOGGER.info("tenant schema naming contract verified")
def verify_rls_contract(self) -> None:
"""Prove the adapter and the policy helpers agree on the caller's identity.
@@ -195,12 +253,26 @@ class PostgresAccountRepository(AccountRepository):
# -- transaction plumbing ----------------------------------------------
@contextmanager
def _connection(self, *, user_id: UUID | None = None) -> Iterator[psycopg.Connection[Any]]:
"""Yield a connection inside one transaction, publishing the caller.
def _connection(
self, *, user_id: UUID | None = None, organization_id: UUID | None = None
) -> Iterator[psycopg.Connection[Any]]:
"""Yield a connection inside one transaction, publishing caller and tenant.
``SET LOCAL`` is transaction-scoped, which is what makes connection
pooling safe here: a returned connection cannot carry the previous
request's identity.
``organization_id`` is the isolation model. It is resolved into a schema
name by :func:`umbratrans_web.tenancy.tenant_search_path` and set as
``SET LOCAL search_path``, so an unqualified ``projects`` in this
transaction means *this organization's* projects, and there is no
unqualified ``projects`` at all when no organization was given.
Both settings are ``SET LOCAL`` and therefore transaction-scoped, which is
what makes connection pooling safe: a returned connection cannot carry the
previous request's identity or the previous request's tenant.
The search path is set on **every** transaction, including the ones with
no organization, where it becomes ``public`` alone. Leaving it alone
instead would let the connection's own default apply, and a default that
happened to name a tenant schema would be a silent leak with no code
anywhere mentioning it.
"""
# ``SET`` is a utility statement and takes no bind parameters at all -
@@ -209,6 +281,7 @@ class PostgresAccountRepository(AccountRepository):
# the interpolation cannot carry SQL, and the value is a module constant
# anyway. Verified against a real server, not assumed.
timeout_ms = int(STATEMENT_TIMEOUT_MS)
search_path = tenant_search_path(organization_id)
with self._pool.connection() as connection:
with connection.transaction():
connection.execute(f"SET LOCAL statement_timeout = {timeout_ms}")
@@ -224,6 +297,7 @@ class PostgresAccountRepository(AccountRepository):
"SELECT set_config(%s, %s, true)",
(RLS_USER_SETTING, str(user_id)),
)
connection.execute("SELECT set_config(%s, %s, true)", (SEARCH_PATH_SETTING, search_path))
yield connection
@contextmanager