248 lines
11 KiB
Python
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",
|
|
]
|