# 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", ]