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