tenant_search_path is the whole isolation model in one function: given the
organization a request resolved to, return the schema its tables are in; given
none, return public alone. That second case is the point. Returning a wildcard
- or leaving the connection's own default in place, which may name a schema
nobody chose - would put every tenant's tables in reach of a request that has not
decided which tenant it is acting for.
The name is derived from the organization id rather than stored, for the reason
the migration derives it too: a recorded name that disagreed with the schema the
rows are in would describe data that no computed name can reach, and that state
would be indistinguishable from "this organization has no data". Deriving makes
it unrepresentable.
This lives in tenancy.py rather than in the adapter because it is the same
decision as scope_for, not a second decision next to it. A module that picked
the organization and another that picked the schema would be two places for the
boundary to be got wrong, and the schema one is the silent one: an unqualified
projects query simply finds nothing, and reads as an empty database.
The value is quoted rather than interpolated because search_path parses its value
as a *list*. A name carrying a comma would not name a schema, it would append
one. The check before that is unreachable from a UUID and is there anyway.
An organization's work is separate from every other organization's, and the
separation is now one module rather than a property each route remembers.
`tenancy.py` resolves the caller's scope in a single place. A member is pinned
to the organization on their own assignment, expressed as a concrete identifier
rather than a flag, so "no filter" is unreachable except for a platform
administrator. Every read and write resolves through it, so there is no per-route
variation to drift.
Within an organization, every account sees every project. The brief is explicit
that two people working on different books both see both, and assignment says
who is *working* on a project rather than who may *know* it exists. This
diverges from `app.can_access_project`, which restricts visibility to assigned
members; the divergence, and what it means if row-level security is ever
switched on, are recorded in migrations/README.md because that would otherwise
be a silent tenant-internal regression no test here would catch.
Two rules, both learned by getting them wrong first:
- A read naming another organization is ignored, because refusing it would
confirm the identifier exists. A write is refused rather than retargeted,
because a project landing in an organization the caller did not ask for is
somewhere they will not look.
- A missing project and a foreign one get the same error, so an identifier cannot
be probed across a boundary.
Projects: the `projects` table, its statuses, and the project-scoped arm of
`user_roles` were already in the schema and unused. No migration.
The dashboard is one server-scoped call, `/api/workspace`, rather than several
assembled in the browser — pieces from different tenants that nobody notices are
not a failure mode worth shipping. Filters narrow the list already in the page,
so switching cannot mix two fetches, and an empty list is distinguishable from a
failed one.
**A sign-in lockout, found by the integration suite.** An assignment is a second
active `user_roles` row, and `_resolve_assignment` refused an account with more
than one. So assigning anybody to anything made them unable to authenticate —
locked out of the page the assignment was meant to show them. `_sync_assignment`
had the same defect. Six queries were scope-blind; three were harmless only
because the assignment role is named `viewer` and never matched a tenant role
name, which is luck rather than a property, so a structural test now pins the
predicate on every one. The guard against two *tenant* rows survives, narrowed to
the ambiguity that actually matters.
Also fixed: `get_project_by_slug` cast `slug::text`, making citext's
case-insensitivity a case-*sensitive* comparison that appeared to work everywhere
else. An assignment grants the `viewer` project role, since no `member` role
exists at project scope; it is a named constant, not a query literal, because the
project workflow is not defined yet.
410 tests pass with the integration suite against a real database; 386 without.