Files
UmbraTrans/umbratrans_web/tenancy.py
2026-10-18 19:01:05 -07:00

248 lines
11 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Tenant scope: which organization's data a caller may see.
Every read in the application resolves its organization through here, and nowhere
else. That is the whole point of the module. Scoping is easy to get right on the
routes that were thought about and wrong on the one that was not, so there is one
place that decides, one rule, and no per-route variation to drift.
The rule:
* a platform administrator sees every organization, because their job is the
platform;
* everyone else is pinned to the organization on their own assignment, and
cannot widen it by naming a different one.
A pinned scope is expressed as a concrete identifier rather than a flag, so a
caller cannot ask for "no filter" and get everything. ``None`` means
"unrestricted" and is only ever returned for a platform administrator, so the
ambiguous case cannot be reached by accident.
**Visibility within an organization is organization-wide, and that is a
deliberate divergence from the database.**
The control plane's own policy helper, ``app.can_access_project``, grants project
access only to the platform administrator, the organization's administrator, or
someone holding a ``project``-scoped role on that specific project. That is
per-assignment visibility.
This module implements per-organization visibility instead: everyone in an
organization sees every project in it, and being assigned to a project says who is
*working* on it rather than who may *know* it exists. Reading the brief that
prompted it, two people work on different books and both books are visible to
both of them, and the work and data of one organization must be separate from
another. Separation is between organizations, not within one.
The two rules agree on the question that matters most - no caller sees across an
organization boundary - and differ on project-level visibility. The divergence is
recorded here, in the module the rule lives in, so that turning on row-level
security does not silently change behaviour:
* row-level security is currently **not enforced**, because the application owns
its tables and the policies are not forced. So this module is the real control
and the stricter policy is dormant.
* if the application is ever run as a non-owner role, ``projects_select`` becomes
load-bearing and ``app.can_access_project`` will restrict projects to assigned
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:
"""Which organization a caller may read, and whether that can be widened.
``organization_id`` of ``None`` means unrestricted, and is only produced for a
platform administrator. There is no way to reach it by asking for a different
organization, because a non-platform caller is pinned rather than filtered.
"""
organization_id: UUID | None
unrestricted: bool
@property
def is_platform_scope(self) -> bool:
return self.unrestricted
def require_organization(self) -> UUID:
"""The single organization this scope covers.
For a pinned scope that is the caller's own organization. For a platform
administrator there is no single organization, so this refuses rather than
guessing one: an operation that needs "the" organization must be given an
explicit identifier, and the caller has to be one who may name it.
"""
if self.organization_id is None:
raise ForbiddenError(
"organization_required",
"This action applies to one organization. Name the organization explicitly.",
)
return self.organization_id
def scope_for(actor: Principal, requested: UUID | None = None) -> TenantScope:
"""Resolve the caller's scope, ignoring anything that would widen it.
``requested`` is honoured only for a platform administrator. For anyone else it
is discarded rather than compared, so a browser that sends another
organization's identifier is not refused with a message that confirms the
identifier exists - it simply has no effect.
"""
if actor.role is RoleName.PLATFORM_ADMIN:
return TenantScope(organization_id=requested, unrestricted=True)
if actor.organization_id is None:
# A member with no organization. They belong to no tenant, so they see no
# tenant. This is reachable only if an assignment is removed underneath
# them, and it must fail closed rather than fall back to unrestricted.
return TenantScope(organization_id=None, unrestricted=False)
return TenantScope(organization_id=actor.organization_id, unrestricted=False)
def require_within(scope: TenantScope, organization_id: UUID | None) -> None:
"""Refuse an operation that names a different organization.
``organization_id`` of ``None`` is allowed for a platform administrator and
refused for anyone else: creating a member with no organization is how a
member would end up belonging to no tenant, which is not a state a
non-platform administrator may create.
"""
if scope.unrestricted:
return
if organization_id is None or organization_id != scope.organization_id:
raise ForbiddenError("cross_organization_forbidden")
def resolve_target(scope: TenantScope, requested: UUID | None) -> UUID:
"""The organization a write lands in, and refusal rather than redirection.
Three cases, and the third is why this is not :func:`require_within`:
* a platform administrator naming one gets it;
* a pinned caller naming **nothing** gets their own organization, which is the
common case and needs no argument in the request;
* a pinned caller naming **someone else's** is refused. Not retargeted.
Retargeting that last case is the failure this exists to prevent. A caller who
asked for organization B and got organization A has put a row somewhere they
will not look for it, and the response reports success. Reading may quietly
ignore a foreign identifier, because refusing it would confirm the identifier
exists; writing may not, because the caller acted on the answer.
"""
if scope.unrestricted:
# No default tenant for a platform administrator to be silently placed in.
# Choosing is an explicit act.
return scope.require_organization() if requested is None else requested
if requested is None:
return scope.require_organization()
if requested != scope.organization_id:
raise ForbiddenError("cross_organization_forbidden")
return requested
__all__ = [
"PLATFORM_SCHEMA",
"TENANT_SCHEMA_PREFIX",
"TenantScope",
"require_within",
"resolve_target",
"scope_for",
"tenant_schema_name",
"tenant_search_path",
]