Schema per organization: the migration, and why these four tables

Umbratools_2.md 5.0 replaces shared-tables-plus-RLS with one schema per
organization, because RLS was measured to be unenforceable twice: the
application connects as the table owner so the policies did not apply to it
(gate G2), and one app function was PUBLIC-executable until it was fixed by
hand against one database (defect B4). A model whose correctness depends on a
grant somebody remembers to make is not a boundary.

The classification comes before the SQL because the SQL's blast radius depends
on it, and the obvious reading of "move the tenant tables" is wrong for eight of
the tables carrying an organization_id. Four move: projects, project_authors,
project_publishers, invitations. The other twenty-six stay, each for a reason
read out of the actual columns and foreign keys rather than the table names.

Two findings the plan did not price, both recorded in the classification:

  - user_roles cannot move. It is the index that resolves a session to an
    organization, so putting it in a tenant schema means finding the tenant by
    searching every tenant schema, on the login path. It also holds
    platform-scope rows, which have no organization to belong to. So one table
    stays shared, and 5.0's "no table is shared between organizations" is not
    fully achievable while the session-to-tenant resolution is a table.

  - moving projects forces dropping three foreign keys that point at it from
    artifacts, jobs and user_roles, because a foreign key cannot name a table
    whose schema depends on the row. That is the one place the decision costs a
    database-enforced guarantee, and it is written down rather than papered over
    with a trigger that would have to rediscover which tenant is meant.

The migration names each tenant schema from the organization id rather than
storing it, so a name cannot disagree with the rows and the rollback can rebuild
the organization_id column from the schema. It is idempotent - verified by
applying it twice - and the rollback is a file that runs rather than a comment
block, because a rollback for a data migration that only exists as a comment is a
rollback nobody runs. Both were applied to the scratch database, rolled back, and
applied again.
This commit is contained in:
umbra audio
2026-10-03 13:30:38 -07:00
parent 62372a9c55
commit 2cdebf5ac3
3 changed files with 1052 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
# Which tables move into a per-tenant schema, and why
Written 2026-10-03, implementing `Umbratools_2.md` §5.0 (*schema per organization*).
Every table in the `public` schema of `umbratrans_scratch` is classified below. The
classification was made from the columns, foreign keys and check constraints read out
of the database, not from what the names suggest:
```sql
-- the evidence for every claim in this document
SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod), a.attnotnull
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0
WHERE n.nspname = 'public' AND c.relkind = 'r';
SELECT conrelid::regclass, confrelid::regclass, pg_get_constraintdef(oid)
FROM pg_constraint WHERE connamespace = 'public'::regnamespace;
```
30 tables. **Four move. Twenty-six stay in `public`.** The summary at the end says why
four is the honest number and not a smaller one.
---
## 1. The rule used
A table is **tenant-owned** if both of these hold:
1. **Every row belongs to exactly one organization.** No row is shared, and no row
belongs to no organization.
2. **It can be found given only the organization.** Locating a tenant's copy of the
table may not require first reading something that identifies the tenant.
Anything that fails either test stays in `public`. A table in `public` is not a
weakness by itself: platform data is not one organization's data, so a platform table
cannot leak another organization's rows by holding one organization's rows.
The rule matters because rule 2 has teeth. It is what keeps `users` and `user_roles`
in `public`, and moving either of them is not a smaller version of this migration but
a different one — see §4.
---
## 2. Tenant-owned — these move
### 2.1 `projects`
| evidence | value |
|---|---|
| `organization_id uuid NOT NULL REFERENCES organizations(id)` | every row has one organization, and it is required |
| `UNIQUE (organization_id, slug)` | a slug is unique *within* an organization — the constraint is itself tenant-scoped |
| `projects_id_organization_id_key UNIQUE (id, organization_id)` | exists only to let `invitations` and `user_roles` point at a project *within* an organization |
| `id uuid PRIMARY KEY` | a project is globally unique, so a tenant cannot collide with another's identifier |
| policies | `projects_select/insert/update/delete`, all of which are `organization_id` filters or an `app.can_access_project` that reads `organization_id` |
The unit of work — the book. It belongs to the organization that commissioned it and
to nobody else. After the move it has **no `organization_id` column at all**: the
schema it is found in *is* the organization, so the column is redundant with the
directory entry. Two constraints go with it and are rebuilt inside the tenant schema:
`UNIQUE (organization_id, slug)` becomes `UNIQUE (slug)` (identical meaning inside one
organization), and `UNIQUE (id, organization_id)` disappears, because nothing outside
the tenant can reference a project any more.
`Project.organization_id` survives **in the model** and is filled in by the adapter
from the schema that answered the query. It is a fact about where the row was found,
not a column that was read.
### 2.2 `project_authors`
| evidence | value |
|---|---|
| no organization column | it is reached through its project |
| `project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE` | every row hangs off exactly one project, therefore one organization |
| `author_id REFERENCES authors(id)`, `created_by REFERENCES users(id)` | outbound references only, to platform tables |
| policy | `project_authors_tenant`, an `EXISTS` over `projects` filtered on `p.organization_id` |
Bylines for a book. The policy's own definition of the tenant boundary is "the
organization of the project this row belongs to", which is exactly what the schema
now says structurally.
### 2.3 `project_publishers`
Identical shape and identical argument: `(project_id, publisher_id, lang)`, one
outbound reference to `public.publishers` (which stays — see §3.6), one policy whose
tenant test is the project's organization.
### 2.4 `invitations`
| evidence | value |
|---|---|
| `organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE` | required |
| `invitations_project_id_organization_id_fkey (project_id, organization_id) → projects(id, organization_id) ON DELETE CASCADE` | already tenant-scoped |
| `email citext NOT NULL`, `token_hash text NOT NULL UNIQUE` | an outstanding invitation is an organization's record of an address it invited |
| `invited_by REFERENCES users(id)`, `role_id REFERENCES roles(id) ON DELETE RESTRICT` | outbound references only |
| policies | `invitations_manage`, `invitations_select` — both filters on `organization_id` |
The one tenant table whose rows are *about* a person rather than about work. It still
belongs to the organization: it is the organization's outstanding invitation, and
`ON DELETE CASCADE` from `organizations` already says the organization owns it. After
the move the composite foreign key becomes `project_id → projects(id)` within the same
schema, and `organization_id` is dropped.
### 2.5 What moving them costs the rest of the schema
Three foreign keys point **into** `projects` from tables that stay in `public`, and a
foreign key cannot name a table whose schema depends on the row:
| constraint | why it cannot survive |
|---|---|
| `artifacts_project_id_fkey → projects(id) ON DELETE CASCADE` | `artifacts` is platform-level (§3.7); the target schema varies per tenant |
| `jobs_project_id_fkey → projects(id)` | `jobs` is platform-level (§3.7) for the same reason |
| `user_roles_project_id_organization_id_fkey → projects(id, organization_id)` | `user_roles` stays platform-level (§4) |
All three are dropped. That is a real loss of a database-enforced invariant and it is
recorded rather than worked around: after this migration nothing in the database stops
`public.jobs` naming a project in an organization it does not belong to. The
application's own rules still do — a job is submitted for an organization, and the
organization is the schema the write lands in — but the statement that could contradict
them is no longer refused by the server.
A compensating trigger was considered and rejected: to check a `project_id` against
the tenant schemas, a function would have to know *which* tenant, which is the thing
that is not recorded on the row, and would need dynamic SQL over a list of schemas
determined at write time. That is the hand-maintained-grant fragility §5.0 was written
to remove, re-introduced as a trigger.
---
## 3. Platform-level — these stay in `public`
### 3.1 `organizations`
The tenant root, and the record that names the tenant's schema. It cannot live inside
the schema it names. Every other table's tenant reference resolves through it.
### 3.2 `users`
| evidence | value |
|---|---|
| **no organization column at all** | the control plane stores tenancy on `user_roles`, not here |
| `email citext NOT NULL UNIQUE`, `username citext UNIQUE` | the login identifier is unique across the whole platform, not per tenant |
| referenced by `sessions`, `user_credentials`, `mfa_methods`, `bootstrap_tokens`, `user_roles`, `audit_events`, `projects.created_by`, `artifacts.created_by_user_id`, `invitations.invited_by`, `project_authors.created_by`, `project_publishers.created_by` | it is the identity every one of those hangs off |
| `SELECT id, email … FROM users WHERE users.id = app.current_user_id()` | the `invitations_select` policy reads `users` from inside a policy on a tenant table |
The login path is `get_user_by_email` → `_resolve_assignment`. The first statement needs
no tenant and the second returns the tenant. A table that must be read to *find* the
tenant cannot be inside the tenant.
### 3.3 `user_roles` — the one that argues hardest, and stays
This is the table the plan expected to move. It carries `organization_id`, it carries
`project_id`, and it is the assignment table. It stays in `public` for three reasons,
each of which is load-bearing on its own:
1. **It is the index that routes a request to a schema.** `Principal.organization_id`
comes from `_resolve_assignment`, which reads this table filtered by `user_id`
alone. Putting the index inside the thing it indexes makes resolution impossible:
to answer "which tenant is this caller in?" you would have to search every tenant
schema for a row. The lookup would become one query per organization on the login
path, at one hundred organizations.
2. **It holds rows that belong to no organization.** `user_roles_check` requires
`scope = 'platform'` to carry `organization_id IS NULL`. A platform administrator's
assignment has no tenant, so in a per-tenant world it has no schema to live in
except a reserved one — which is a second table with the same name and a rule for
which rows go where.
3. **It is what makes a caller visible to a platform administrator.**
`app.is_platform_admin`, `app.is_organization_admin`, `app.can_view_user` and
`find_users(organization_id=…)` all read it, and all four run with no tenant in
hand.
The consequence is recorded plainly: **after this migration one table is shared
between organizations, and it is `user_roles`.** §5.0's headline — *no table is shared
between organizations* — is not fully achievable while the session-to-tenant
resolution is a table. What is achievable is that no table of *tenant data* is shared,
and §4 says what closing the remaining gap would cost.
### 3.4 `user_credentials`, `sessions`, `mfa_methods`, `bootstrap_tokens`
All four are keyed by `user_id` and none carries an organization. They are what makes
a login work, which happens before a tenant exists. Moving them would mean the login
path could not read the credential for an account whose tenant it does not yet know.
### 3.5 `roles`, `permissions`, `role_permissions`
A closed vocabulary seeded once: `roles_scope_name_key UNIQUE (scope, name)`,
`permissions.key` primary key. `invitations.role_id` and `user_roles.role_id` both
point here `ON DELETE RESTRICT`, and `app.validate_user_role_scope` reads `roles` on
every assignment write. A per-tenant copy would let one organization's idea of what
`translator` means diverge from another's, which would defeat the point of a role
catalogue.
### 3.6 `authors`, `publishers`
| evidence | value |
|---|---|
| `owner_organization_id uuid REFERENCES organizations(id)` — **nullable** | a row may belong to no organization |
| `authors_slug_key UNIQUE (slug)`, `publishers_slug_key UNIQUE (slug)` | unique across the platform, which is what makes the row shared |
| `publishers_read` / `authors_read` policies: `USING (app.current_user_id() IS NOT NULL)` | readable by *every* authenticated principal, deliberately, and not gated on ownership |
| `publishers_write`: `USING (app.is_platform_admin() OR app.is_organization_admin(owner_organization_id))` | shared read, owner-only write |
These are third-party reference data — a publishing house, a byline — not one
organization's content, and the schema says so twice: the owner column is nullable and
the read policy admits any signed-in caller. `test_rls_coverage.py` asserts that read
policy by name, because losing it would hide the imprint a tenant's own project is
published under. Moving these into a tenant schema would make a publisher created by
organization A invisible to organization B, which is the opposite of what the existing
decision requires.
### 3.7 The compute surface — named in the constraint, recorded anyway
`compute_nodes`, `compute_devices`, `node_credentials`, `node_heartbeats`,
`node_models`, `jobs`, `job_events`, `job_artifacts`, `artifacts`, `model_registry`.
These stay because **a compute node is not owned by an organization, it serves
organizations.** `model_registry` is the catalogue every node is measured against;
`jobs` is the queue the scheduler drains across every tenant; `artifacts` is content
addressed against `content_hash` with a unique index scoped to `(project_id, …)` so
that two organizations holding identical bytes do not collide. Put any of them in a
tenant schema and the controller can only see the tenants whose schema it happens to be
pointed at — a node in organization A becomes invisible to the scheduler while a job
for organization B is queued.
`artifacts` and `jobs` both carry `organization_id`, so they are tenant-*tagged*
without being tenant-*owned*, and that is worth being explicit about: the tag says
whose work this is, not who may see it. The controller must see all of them.
### 3.8 `node_pairing_codes`, `node_pairing_attempts`
Platform, for a reason that is about *when* rather than *who*: pairing is how a node
joins the platform, and the request that redeems a pairing code is authenticated by
the code itself. There is no session, no principal and therefore **no organization to
resolve a schema from**. A pairing request that had to choose a tenant schema would be
choosing one from a bearer secret. `node_pairing_codes.organization_id` records which
organization's administrator authorised the machine — an audit fact about the
authorisation, in the same way `audit_events.organization_id` is.
### 3.9 `audit_events`
`organization_id` is nullable, and the rows where it is null are the ones an auditor
most wants: a failed sign-in, a rejected token, a request that named no tenant.
Nothing forces that ordering — authentication is resolved against `users` and
`bootstrap_tokens` first — so an event with no tenant is a normal event rather than an
exception. It is also append-only (`app.prevent_audit_mutation`), which is why the
test suite cannot clean up selectively and refuses to run against a populated database
at all. A tenant schema would have nowhere to put the events that precede tenancy.
### 3.10 `schema_migrations`
The ledger. It records which migrations ran, which is a fact about the database, and a
tenant schema is created and dropped by those migrations.
---
## 4. What it would take to move `user_roles` too
Recorded because §5.0 promised eight `organization_id` columns disappearing and the
honest number is two (`projects`, `invitations`). The gap is entirely `user_roles`,
and it is not closed by moving the table — it is closed by splitting it:
- `public.user_tenant_index(user_id, organization_id, scope)` — the routing index, no
role in it, searchable by `user_id` alone, plus the platform-scope rows.
- `<tenant>.user_roles` — the roles themselves: organization-scoped and project-scoped
assignments, which is what the rest of §3.3's reasons are about.
Every one of the readers in §3.3 then changes: the five `app` functions, the six
`user_roles` policies, `_resolve_assignment`, `find_users`, `count_users_with_role`,
`list_project_member_ids`, `assign_user_to_project`, and `verify_rls_contract`. It also
introduces a table that has to be kept in step with the tenant tables — a membership
index that disagrees with the roles it indexes is a privilege bug with no constraint
anywhere behind it. That is a larger and more dangerous change than the one
§5.0 scoped, so it is not done here, and it is not done implicitly either.
---
## 5. Summary
**Move into `<organization>'s schema` (4):** `projects`, `project_authors`,
`project_publishers`, `invitations`.
**Stay in `public` (26):** `organizations`, `users`, `user_roles`,
`user_credentials`, `sessions`, `mfa_methods`, `bootstrap_tokens`, `roles`,
`permissions`, `role_permissions`, `authors`, `publishers`, `audit_events`,
`compute_nodes`, `compute_devices`, `node_credentials`, `node_heartbeats`,
`node_models`, `jobs`, `job_events`, `job_artifacts`, `artifacts`, `model_registry`,
`node_pairing_codes`, `node_pairing_attempts`, `schema_migrations`.
**What the plan said, and what is actually true:**
| §5.0 said | measured |
|---|---|
| "`organization_id` disappears from eight tables" | two: `projects` and `invitations`. Four of the eight are named platform-level by the same decision (`artifacts`, `jobs`, `compute_nodes`, `node_pairing_codes`); `user_roles` cannot move (§3.3); `authors` and `publishers` are shared reference data by their own read policies (§3.6). |
| "nineteen table references in the Python need a schema-qualified form" | twenty-three references to the two tables in `postgres_repository.py`: ten to `projects` and thirteen to `user_roles`. Only the ten change, and they change in the opposite direction — they stop naming `public.` and become unqualified, reaching the tenant through `search_path`. The thirteen `user_roles` references keep naming `public.`, because that table stays (§3.3). |
| "no table is shared between organizations" | one table is: `user_roles` (§3.3), for the reason that the session-to-tenant index cannot live inside the thing it indexes. |
**Also not in the plan:** moving `projects` forces the dropping of three foreign keys
that point at it from platform tables (§2.5). The plan does not mention this, and it is
the only place where the decision costs a database-enforced guarantee.

View File

@@ -0,0 +1,444 @@
-- 0005: one PostgreSQL schema per organization.
--
-- This is §5.0 of Umbratools_2.md: **an organization gets its own schema, and no
-- table of tenant data is shared between organizations.** It replaces shared-tables-
-- plus-RLS because that model was measured to be unenforceable twice over: the
-- application connects as the table owner, so the policies did not apply to it (gate
-- G2), and one of the thirteen `app` functions was PUBLIC-executable until it was
-- closed by hand against one database (defect B4), which means correctness depended on
-- a grant somebody remembered to make.
--
-- With no shared table there is nothing for a policy to fail to protect:
--
-- SELECT email FROM users; -- (users is platform; but for a tenant table)
-- SELECT name FROM projects; -- ERROR: relation "projects" does not exist
--
-- because in a context with no tenant in hand there is no `projects` relation in the
-- search path at all. The strongest possible mistake now fails.
--
-- ## What moves and what does not
--
-- Four tables move: `projects`, `project_authors`, `project_publishers`,
-- `invitations`. Twenty-six stay in `public`, and the reasoning for each of the
-- thirty is in `docs/tenant-schema-classification.md`. The three that are worth
-- repeating here because they are the ones that look wrong:
--
-- * `user_roles` stays. It is the index that resolves a session to an organization
-- (rule 2 of the classification). Putting it in a tenant schema means finding the
-- tenant requires searching every tenant schema, on the login path.
-- * `authors` and `publishers` stay. Their read policies admit any authenticated
-- principal and their owner column is nullable: they are shared reference data,
-- not one organization's content.
-- * The compute surface stays. A node is not owned by an organization, it serves
-- organizations; `jobs`, `artifacts` and `model_registry` must stay visible to a
-- controller that is not pointed at any one tenant's schema.
--
-- ## What this costs, which the plan did not price
--
-- Three foreign keys point into `projects` from tables that stay in `public`:
-- `artifacts_project_id_fkey`, `jobs_project_id_fkey` and
-- `user_roles_project_id_organization_id_fkey`. A foreign key cannot name a table
-- whose schema depends on the row, so all three are dropped. After this migration
-- nothing in the database refuses a `public.jobs` row that names another tenant's
-- project. The application still refuses it; the server no longer does. That is a real
-- loss and it is recorded rather than papered over.
--
-- ## Shape
--
-- 1. `app.tenant_schema_name(uuid)` and `app.provision_tenant_schema()` - the name
-- is derived from the organization id, so it cannot be out of step with a stored
-- column, and provisioning is `CREATE SCHEMA IF NOT EXISTS`.
-- 2. A trigger on `public.organizations` so an organization created by any writer,
-- including one added after this migration, has a schema before anything can be
-- written into it.
-- 3. The move itself: per organization, create the four tables in its schema and
-- copy that organization's rows out of `public`.
-- 4. Drop the three foreign keys described above, then drop the four `public` tables
-- — which is what removes the shared copy, and what removes the policies with it.
-- 5. Re-express `app.can_access_project` and `app.is_organization_admin_for_project`,
-- which read `projects.organization_id` and cannot after this.
--
-- Additive where it can be, idempotent throughout: applying this file twice is a
-- no-op, and applying it to a database where it has already run is not an error. It
-- runs in one transaction, so a failure part-way leaves nothing moved.
--
-- Rollback: `0005_tenant_schemas_rollback.sql`. That file runs; it is not a comment
-- block, because a rollback for a data migration that only exists as a comment is a
-- rollback nobody runs.
BEGIN;
-- ---------------------------------------------------------------------------
-- 1. Naming and provisioning a tenant schema
-- ---------------------------------------------------------------------------
-- The name is derived, not stored.
--
-- A stored `organizations.schema_name` column would be one more thing that can
-- disagree with reality, and there is a specific failure for it: an organization
-- whose stored name does not match the schema its rows are in has data that is
-- unreachable by any name the code computes. Deriving the name from the primary key
-- makes that state unrepresentable. The cost is that the derivation exists in two
-- languages, which is why `PostgresAccountRepository.verify_tenant_schema_contract()`
-- compares this function with the Python one at startup and refuses to serve if they
-- differ — the same treatment `app.user_id` gets in `verify_rls_contract`.
CREATE OR REPLACE FUNCTION app.tenant_schema_name(target_organization_id uuid)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $body$
SELECT 'tenant_' || replace(target_organization_id::text, '-', '')
$body$;
COMMENT ON FUNCTION app.tenant_schema_name(uuid) IS
'The schema holding one organization''s tables. Derived from the organization id so '
'it cannot disagree with a stored value. The Python side is '
'umbratrans_web.tenancy.tenant_schema_name and the two are compared at startup.';
CREATE OR REPLACE FUNCTION app.provision_tenant_schema(target_organization_id uuid)
RETURNS text
LANGUAGE plpgsql
AS $body$
DECLARE
target_schema text := app.tenant_schema_name(target_organization_id);
BEGIN
EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', target_schema);
-- The four tenant tables, created here rather than by copying a template, so
-- that this is the only place their shape is written down.
--
-- `projects` has no `organization_id`: it is the schema. Everything the shared
-- table used to say with that column is now said by where the table is, which is
-- why `UNIQUE (organization_id, slug)` becomes `UNIQUE (slug)` here — identical
-- inside one organization — and `UNIQUE (id, organization_id)` disappears,
-- because nothing outside this schema references a project any more.
EXECUTE format($ddl$
CREATE TABLE IF NOT EXISTS %I.projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug public.citext NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'on_hold', 'released', 'archived')),
created_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Inside one organization a slug is unique. This was the meaning of
-- `UNIQUE (organization_id, slug)` and it is unchanged.
CONSTRAINT projects_slug_key UNIQUE (slug)
)
$ddl$, target_schema);
EXECUTE format($ddl$
CREATE TABLE IF NOT EXISTS %I.project_authors (
project_id uuid NOT NULL REFERENCES %1$I.projects(id) ON DELETE CASCADE,
author_id uuid NOT NULL REFERENCES public.authors(id),
credit_role text NOT NULL
CHECK (credit_role IN ('author', 'translator', 'editor', 'narrator',
'cover_artist', 'illustrator')),
lang text NOT NULL,
"position" smallint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES public.users(id),
CONSTRAINT project_authors_pkey PRIMARY KEY (project_id, author_id, credit_role, lang)
)
$ddl$, target_schema);
EXECUTE format($ddl$
CREATE TABLE IF NOT EXISTS %I.project_publishers (
project_id uuid NOT NULL REFERENCES %1$I.projects(id) ON DELETE CASCADE,
publisher_id uuid NOT NULL REFERENCES public.publishers(id),
lang text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES public.users(id),
CONSTRAINT project_publishers_pkey PRIMARY KEY (project_id, publisher_id, lang)
)
$ddl$, target_schema);
EXECUTE format($ddl$
CREATE TABLE IF NOT EXISTS %I.invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- The project reference was `(project_id, organization_id)` because the
-- tenant had to be named to be sure. Here the schema names it.
project_id uuid REFERENCES %1$I.projects(id) ON DELETE CASCADE,
email public.citext NOT NULL,
role_id uuid NOT NULL REFERENCES public.roles(id) ON DELETE RESTRICT,
token_hash text NOT NULL UNIQUE,
invited_by uuid NOT NULL REFERENCES public.users(id),
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
)
$ddl$, target_schema);
EXECUTE format($ddl$
CREATE INDEX IF NOT EXISTS invitations_email_idx ON %1$I.invitations (email)
$ddl$, target_schema);
EXECUTE format($ddl$
CREATE UNIQUE INDEX IF NOT EXISTS project_authors_order_idx
ON %1$I.project_authors (project_id, credit_role, COALESCE(lang, ''), "position")
$ddl$, target_schema);
EXECUTE format($ddl$
CREATE UNIQUE INDEX IF NOT EXISTS project_publishers_once_idx
ON %1$I.project_publishers (project_id, publisher_id, COALESCE(lang, ''))
$ddl$, target_schema);
-- The same trigger the shared table had, so `updated_at` keeps meaning what it
-- meant. `app.set_updated_at` is unchanged and still in `public`'s reach.
--
-- Guarded on the trigger rather than `DROP ... IF EXISTS` then `CREATE`, because a
-- `DROP IF EXISTS` that finds nothing is a NOTICE, and one NOTICE per organization
-- buries a real error in a run of a hundred harmless ones.
IF NOT EXISTS (
SELECT 1
FROM pg_trigger
WHERE tgrelid = format('%I.projects', target_schema)::regclass
AND tgname = 'projects_set_updated_at'
) THEN
EXECUTE format(
'CREATE TRIGGER projects_set_updated_at BEFORE UPDATE ON %I.projects '
'FOR EACH ROW EXECUTE FUNCTION app.set_updated_at()',
target_schema
);
END IF;
RETURN target_schema;
END
$body$;
-- ---------------------------------------------------------------------------
-- 2. Every organization has a schema, including ones created after this
-- ---------------------------------------------------------------------------
-- Without this, an organization could be inserted by any writer — the console, a
-- script, the next migration — and have no schema, which is not a state in which its
-- data has somewhere to live. The trigger makes the invariant structural: the schema
-- exists in the same transaction as the organization row.
CREATE OR REPLACE FUNCTION app.provision_tenant_schema_for_new_organization()
RETURNS trigger
LANGUAGE plpgsql
AS $body$
BEGIN
PERFORM app.provision_tenant_schema(NEW.id);
RETURN NEW;
END
$body$;
DROP TRIGGER IF EXISTS organizations_provision_tenant_schema ON public.organizations;
CREATE TRIGGER organizations_provision_tenant_schema
AFTER INSERT ON public.organizations
FOR EACH ROW EXECUTE FUNCTION app.provision_tenant_schema_for_new_organization();
-- ---------------------------------------------------------------------------
-- 3. The move
-- ---------------------------------------------------------------------------
-- Per organization: create the tables, then copy that organization's rows.
--
-- `ON CONFLICT DO NOTHING` on every copy is what makes re-running safe. Without it a
-- second run fails on the primary key, and a migration that cannot be applied twice
-- is one that will be applied once, hopefully.
DO $move$
DECLARE
organization record;
target_schema text;
-- The shared tables are the *source*, and a second run finds them already gone:
-- section 5 dropped them. Every copy is therefore guarded on its source still
-- being there, which is what makes applying this file twice a no-op rather than an
-- error on a migration that has already been applied.
shared_projects constant boolean := to_regclass('public.projects') IS NOT NULL;
shared_authors constant boolean := to_regclass('public.project_authors') IS NOT NULL;
shared_publishers constant boolean := to_regclass('public.project_publishers') IS NOT NULL;
shared_invitations constant boolean := to_regclass('public.invitations') IS NOT NULL;
BEGIN
FOR organization IN SELECT id FROM public.organizations ORDER BY id LOOP
target_schema := app.provision_tenant_schema(organization.id);
IF shared_projects THEN
EXECUTE format(
'INSERT INTO %I.projects (id, name, slug, status, created_by, created_at, updated_at) '
'SELECT id, name, slug, status, created_by, created_at, updated_at '
' FROM public.projects WHERE organization_id = %L '
'ON CONFLICT DO NOTHING',
target_schema, organization.id
);
END IF;
IF shared_projects AND shared_authors THEN
EXECUTE format(
'INSERT INTO %I.project_authors (project_id, author_id, credit_role, lang, "position", created_at, created_by) '
'SELECT pa.project_id, pa.author_id, pa.credit_role, pa.lang, pa."position", pa.created_at, pa.created_by '
' FROM public.project_authors pa '
' JOIN public.projects p ON p.id = pa.project_id '
' WHERE p.organization_id = %L '
'ON CONFLICT DO NOTHING',
target_schema, organization.id
);
END IF;
IF shared_projects AND shared_publishers THEN
EXECUTE format(
'INSERT INTO %I.project_publishers (project_id, publisher_id, lang, created_at, created_by) '
'SELECT pp.project_id, pp.publisher_id, pp.lang, pp.created_at, pp.created_by '
' FROM public.project_publishers pp '
' JOIN public.projects p ON p.id = pp.project_id '
' WHERE p.organization_id = %L '
'ON CONFLICT DO NOTHING',
target_schema, organization.id
);
END IF;
IF shared_invitations THEN
EXECUTE format(
'INSERT INTO %I.invitations (id, project_id, email, role_id, token_hash, invited_by, '
'expires_at, accepted_at, revoked_at, created_at) '
'SELECT i.id, i.project_id, i.email, i.role_id, i.token_hash, i.invited_by, '
' i.expires_at, i.accepted_at, i.revoked_at, i.created_at '
' FROM public.invitations i WHERE i.organization_id = %L '
'ON CONFLICT DO NOTHING',
target_schema, organization.id
);
END IF;
END LOOP;
END
$move$;
-- ---------------------------------------------------------------------------
-- 4. Verification, before anything is dropped
-- ---------------------------------------------------------------------------
-- A count comparison, and it raises rather than warns. The point of doing this
-- *here* is that after section 5 the shared copy is gone and there is nothing left to
-- compare against: a move that silently lost a row would be discovered by an
-- organization noticing a missing book.
DO $verify$
DECLARE
tenant record;
moved bigint;
source bigint;
BEGIN
-- Nothing left to compare against once the shared table is gone, which is why this
-- runs before section 5 and not after it. A re-run returns here.
IF to_regclass('public.projects') IS NULL THEN
RETURN;
END IF;
FOR tenant IN SELECT o.id AS organization_id, app.tenant_schema_name(o.id) AS schema_name
FROM public.organizations o ORDER BY o.id
LOOP
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = tenant.schema_name);
EXECUTE format('SELECT count(*) FROM %I.projects', tenant.schema_name) INTO moved;
EXECUTE format(
'SELECT count(*) FROM public.projects WHERE organization_id = %L',
tenant.organization_id
) INTO source;
-- `<` and not `<>`: a re-run finds the rows already copied, and a schema that
-- holds *more* than the shared table is not evidence of a loss.
IF moved < source THEN
RAISE EXCEPTION
'tenant schema % holds % projects but the shared table holds % for organization %;'
' refusing to drop the shared copy',
tenant.schema_name, moved, source, tenant.organization_id;
END IF;
END LOOP;
END
$verify$;
-- ---------------------------------------------------------------------------
-- 5. Drop the shared copy
-- ---------------------------------------------------------------------------
-- The three foreign keys first, because they are what makes the four tables
-- undroppable. Each is named so that the reader can see this is a decision and not an
-- oversight.
--
-- artifacts_project_id_fkey artifacts is platform-level; see the header
-- jobs_project_id_fkey jobs is platform-level; see the header
-- user_roles_project_id_organization_id_fkey
-- user_roles stays platform-level, so it
-- cannot point into a per-tenant schema
ALTER TABLE public.artifacts DROP CONSTRAINT IF EXISTS artifacts_project_id_fkey;
ALTER TABLE public.jobs DROP CONSTRAINT IF EXISTS jobs_project_id_fkey;
ALTER TABLE public.user_roles DROP CONSTRAINT IF EXISTS user_roles_project_id_organization_id_fkey;
-- Now the shared tables. Their row-level-security policies go with the tables, which
-- is the point rather than a side effect: `projects_select` was `app.can_access_project`,
-- and there is no shared `projects` left for it to filter.
--
-- `DROP TABLE` rather than `DROP TABLE ... CASCADE`, deliberately: cascading would
-- silently drop whatever else had grown a dependency, which is exactly the class of
-- change that has to be written down to be reviewed.
DROP TABLE IF EXISTS public.invitations;
DROP TABLE IF EXISTS public.project_authors;
DROP TABLE IF EXISTS public.project_publishers;
DROP TABLE IF EXISTS public.projects;
-- ---------------------------------------------------------------------------
-- 6. The two functions that read a table which no longer exists
-- ---------------------------------------------------------------------------
-- `app.can_access_project` and `app.is_organization_admin_for_project` both resolved a
-- project to an organization by reading `projects.organization_id`. There is no shared
-- `projects` to read.
--
-- Both are re-expressed over `public.user_roles`, which is where a project's
-- organization is still recorded: `user_roles_check` requires a `scope = 'project'`
-- row to carry `organization_id NOT NULL`, so the project-scoped assignment of any
-- project names the tenant that project belongs to.
--
-- The narrowing this causes, stated rather than hidden: a project with no
-- project-scoped assignment at all now resolves to no organization, so an
-- organization administrator who is not assigned to it is not recognised by
-- `app.can_access_project`. Before this migration they were, because the answer came
-- from the project row rather than from an assignment. In practice the service
-- refuses an unassigned project anyway — `assign_account_to_project` creates the
-- assignment — and `app.can_access_project` is no longer used by any policy, because
-- the table it filtered has no policy left to filter.
CREATE OR REPLACE FUNCTION app.is_organization_admin_for_project(target_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $body$
SELECT app.is_platform_admin()
OR EXISTS (
SELECT 1
FROM public.user_roles theirs
JOIN public.user_roles mine
ON mine.organization_id = theirs.organization_id
AND mine.user_id = app.current_user_id()
AND mine.scope = 'organization'
AND mine.status = 'active'
JOIN public.roles r ON r.id = mine.role_id
WHERE theirs.project_id = target_project_id
AND theirs.scope = 'project'
AND theirs.status = 'active'
AND r.name = 'organization_admin'
)
$body$;
CREATE OR REPLACE FUNCTION app.can_access_project(target_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $body$
SELECT app.is_platform_admin()
OR EXISTS (
SELECT 1
FROM public.user_roles mine
JOIN public.roles r ON r.id = mine.role_id
WHERE mine.user_id = app.current_user_id()
AND mine.scope = 'organization'
AND mine.status = 'active'
AND r.name IN ('organization_admin', 'member')
AND EXISTS (
SELECT 1
FROM public.user_roles theirs
WHERE theirs.project_id = target_project_id
AND theirs.scope = 'project'
AND theirs.status = 'active'
AND theirs.organization_id = mine.organization_id
)
)
OR EXISTS (
SELECT 1
FROM public.user_roles mine
WHERE mine.user_id = app.current_user_id()
AND mine.project_id = target_project_id
AND mine.scope = 'project'
AND mine.status = 'active'
)
$body$;
-- ---------------------------------------------------------------------------
-- 7. Ledger
-- ---------------------------------------------------------------------------
-- Written last, so a migration that fails above leaves no record that it ran.
INSERT INTO public.schema_migrations (version) VALUES ('0005_tenant_schemas')
ON CONFLICT (version) DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,313 @@
-- 0005 rollback: put the four tenant tables back in `public`, one shared table again.
--
-- This file **runs**. It is not the commented-out sketch at the end of a
-- migration, because a rollback for a data migration that exists only as a comment
-- is a rollback nobody runs.
--
-- psql -d <database> -v ON_ERROR_STOP=1 -f 0005_tenant_schemas_rollback.sql
--
-- It undoes 0005 completely:
--
-- 1. recreate `public.projects`, `public.project_authors`,
-- `public.project_publishers` and `public.invitations` with their
-- `organization_id` columns, constraints, indexes, policies and trigger;
-- 2. copy every organization's rows back, recovering `organization_id` from the
-- schema name - the name was derived from the identifier, so this is exact and
-- not a guess;
-- 3. restore the three foreign keys that pointed into `projects`, and the two
-- `app` functions that read it;
-- 4. drop the tenant schemas, the trigger and the two provisioning functions;
-- 5. remove the ledger row.
--
-- ## What it cannot restore
--
-- Three things, stated rather than discovered afterwards:
--
-- * **Rows written into a tenant schema since the migration.** They are copied
-- back, so they survive; but a row whose `created_by` names a user deleted in
-- the meantime will fail the foreign key and stop the copy. That is the
-- constraint doing its job, and the fix is to resolve the user, not to drop the
-- constraint.
-- * **The column order and any constraint added by a later migration** that
-- referenced the tenant tables. This rollback is written against 0001-0005 and
-- nothing after them.
-- * **The gate it reopens.** The shared tables have no `organization_id`
-- filtering unless the policies below are enforced, and they are not enforced
-- for the table owner, which is the application. Rolling back returns the
-- deployment to gate **G2** and to shared-tables-plus-RLS, both of which §5.0
-- records as measured failures. Rolling forward is the direction; this file
-- exists so that the decision is reversible, not because it is better.
BEGIN;
-- ---------------------------------------------------------------------------
-- 1. The shared tables, as they were
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES public.organizations(id),
name text NOT NULL,
slug public.citext NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'on_hold', 'released', 'archived')),
created_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Referenced by `invitations` and `user_roles` as `(project_id,
-- organization_id)`, so the pair has to be unique for those foreign keys to
-- exist again.
CONSTRAINT projects_id_organization_id_key UNIQUE (id, organization_id),
CONSTRAINT projects_organization_id_slug_key UNIQUE (organization_id, slug)
);
CREATE TABLE IF NOT EXISTS public.project_authors (
project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
author_id uuid NOT NULL REFERENCES public.authors(id),
credit_role text NOT NULL
CHECK (credit_role IN ('author', 'translator', 'editor', 'narrator',
'cover_artist', 'illustrator')),
lang text NOT NULL,
"position" smallint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES public.users(id),
CONSTRAINT project_authors_pkey PRIMARY KEY (project_id, author_id, credit_role, lang)
);
CREATE TABLE IF NOT EXISTS public.project_publishers (
project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
publisher_id uuid NOT NULL REFERENCES public.publishers(id),
lang text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES public.users(id),
CONSTRAINT project_publishers_pkey PRIMARY KEY (project_id, publisher_id, lang)
);
CREATE TABLE IF NOT EXISTS public.invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
project_id uuid,
email public.citext NOT NULL,
role_id uuid NOT NULL REFERENCES public.roles(id) ON DELETE RESTRICT,
token_hash text NOT NULL UNIQUE,
invited_by uuid NOT NULL REFERENCES public.users(id),
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT invitations_project_id_organization_id_fkey
FOREIGN KEY (project_id, organization_id)
REFERENCES public.projects(id, organization_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS projects_organization_idx ON public.projects (organization_id);
CREATE INDEX IF NOT EXISTS invitations_email_idx ON public.invitations (email);
CREATE UNIQUE INDEX IF NOT EXISTS project_authors_order_idx
ON public.project_authors (project_id, credit_role, COALESCE(lang, ''), "position");
CREATE UNIQUE INDEX IF NOT EXISTS project_publishers_once_idx
ON public.project_publishers (project_id, publisher_id, COALESCE(lang, ''));
-- ---------------------------------------------------------------------------
-- 2. The rows, back into the shared tables
-- ---------------------------------------------------------------------------
-- `organization_id` is recovered by parsing the schema name, which is the
-- definition `app.tenant_schema_name` used to build it. That is why the name was
-- derived rather than stored: a rollback can rebuild the column from the schema,
-- and a stored name could have disagreed with the rows.
DO $restore$
DECLARE
tenant record;
organization_id uuid;
BEGIN
FOR tenant IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\_%' ORDER BY nspname LOOP
BEGIN
organization_id := substring(tenant.nspname FROM 8)::uuid;
EXCEPTION WHEN others THEN
-- A schema that matches the pattern but is not one of ours. Skipped
-- loudly rather than dropped: something else made it, and guessing at
-- its contents is how a rollback destroys data.
RAISE WARNING 'skipping %: its name is not an organization identifier', tenant.nspname;
CONTINUE;
END;
EXECUTE format(
'INSERT INTO public.projects (id, organization_id, name, slug, status, created_by, created_at, updated_at) '
'SELECT id, %L, name, slug, status, created_by, created_at, updated_at FROM %I.projects '
'ON CONFLICT DO NOTHING',
organization_id, tenant.nspname
);
EXECUTE format(
'INSERT INTO public.project_authors (project_id, author_id, credit_role, lang, "position", created_at, created_by) '
'SELECT project_id, author_id, credit_role, lang, "position", created_at, created_by FROM %I.project_authors '
'ON CONFLICT DO NOTHING',
tenant.nspname
);
EXECUTE format(
'INSERT INTO public.project_publishers (project_id, publisher_id, lang, created_at, created_by) '
'SELECT project_id, publisher_id, lang, created_at, created_by FROM %I.project_publishers '
'ON CONFLICT DO NOTHING',
tenant.nspname
);
EXECUTE format(
'INSERT INTO public.invitations (id, organization_id, project_id, email, role_id, token_hash, invited_by, '
'expires_at, accepted_at, revoked_at, created_at) '
'SELECT id, %L, project_id, email, role_id, token_hash, invited_by, expires_at, accepted_at, revoked_at, created_at '
' FROM %I.invitations ON CONFLICT DO NOTHING',
organization_id, tenant.nspname
);
END LOOP;
END
$restore$;
-- ---------------------------------------------------------------------------
-- 3. The policies, and the trigger
-- ---------------------------------------------------------------------------
-- Restored verbatim from the 0001-0004 schema. They are not decoration: without
-- them the four tables sit in `public` with no tenant filter at all, which is
-- strictly worse than either state - the tables are shared *and* unprotected.
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS projects_select ON public.projects;
CREATE POLICY projects_select ON public.projects FOR SELECT USING (app.can_access_project(id));
DROP POLICY IF EXISTS projects_insert ON public.projects;
CREATE POLICY projects_insert ON public.projects FOR INSERT
WITH CHECK (app.is_platform_admin() OR app.is_organization_admin(organization_id));
DROP POLICY IF EXISTS projects_update ON public.projects;
CREATE POLICY projects_update ON public.projects FOR UPDATE
USING (app.is_platform_admin() OR app.is_organization_admin(organization_id))
WITH CHECK (app.is_platform_admin() OR app.is_organization_admin(organization_id));
DROP POLICY IF EXISTS projects_delete ON public.projects;
CREATE POLICY projects_delete ON public.projects FOR DELETE
USING (app.is_platform_admin() OR app.is_organization_admin(organization_id));
ALTER TABLE public.project_authors ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_authors_tenant ON public.project_authors;
CREATE POLICY project_authors_tenant ON public.project_authors
USING (app.is_platform_admin() OR EXISTS (
SELECT 1 FROM public.projects p
WHERE p.id = project_authors.project_id
AND app.is_organization_member(p.organization_id)));
ALTER TABLE public.project_publishers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_publishers_tenant ON public.project_publishers;
CREATE POLICY project_publishers_tenant ON public.project_publishers
USING (app.is_platform_admin() OR EXISTS (
SELECT 1 FROM public.projects p
WHERE p.id = project_publishers.project_id
AND app.is_organization_member(p.organization_id)));
ALTER TABLE public.invitations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS invitations_manage ON public.invitations;
CREATE POLICY invitations_manage ON public.invitations
USING (app.is_platform_admin() OR app.is_organization_admin(organization_id))
WITH CHECK (app.is_platform_admin() OR app.is_organization_admin(organization_id));
DROP POLICY IF EXISTS invitations_select ON public.invitations;
CREATE POLICY invitations_select ON public.invitations FOR SELECT USING (
app.is_platform_admin()
OR app.is_organization_admin(organization_id)
OR (email = (SELECT u.email FROM public.users u WHERE u.id = app.current_user_id())));
DO $$
BEGIN
EXECUTE 'DROP TRIGGER IF EXISTS projects_set_updated_at ON public.projects';
EXECUTE 'CREATE TRIGGER projects_set_updated_at BEFORE UPDATE ON public.projects '
'FOR EACH ROW EXECUTE FUNCTION app.set_updated_at()';
END
$$;
-- ---------------------------------------------------------------------------
-- 4. The foreign keys and functions that 0005 removed
-- ---------------------------------------------------------------------------
-- Guarded on the catalogue rather than written plainly, because PostgreSQL has no
-- `ADD CONSTRAINT IF NOT EXISTS`. It is not decoration: every other statement in
-- this file is safe to run twice, and a rollback that fails on the second attempt
-- is one that cannot be used to recover from a failure part-way through itself -
-- and a rollback of a per-tenant migration on a database with a few hundred
-- tenants is exactly where that happens.
DO $keys$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'artifacts_project_id_fkey') THEN
EXECUTE 'ALTER TABLE public.artifacts ADD CONSTRAINT artifacts_project_id_fkey '
'FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jobs_project_id_fkey') THEN
EXECUTE 'ALTER TABLE public.jobs ADD CONSTRAINT jobs_project_id_fkey '
'FOREIGN KEY (project_id) REFERENCES public.projects(id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_roles_project_id_organization_id_fkey') THEN
EXECUTE 'ALTER TABLE public.user_roles ADD CONSTRAINT user_roles_project_id_organization_id_fkey '
'FOREIGN KEY (project_id, organization_id) REFERENCES public.projects(id, organization_id) '
'ON DELETE CASCADE';
END IF;
END
$keys$;
-- The original bodies, which read `projects.organization_id` and could not survive
-- the table leaving `public`. Restored verbatim so that a rolled-back database is
-- byte-for-byte the schema 0005 found rather than an approximation of it.
CREATE OR REPLACE FUNCTION app.can_access_project(target_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $body$
SELECT app.is_platform_admin()
OR EXISTS (
SELECT 1
FROM projects p
WHERE p.id = target_project_id
AND app.is_organization_admin(p.organization_id)
)
OR EXISTS (
SELECT 1
FROM user_roles ur
WHERE ur.user_id = app.current_user_id()
AND ur.project_id = target_project_id
AND ur.scope = 'project'
AND ur.status = 'active'
)
$body$;
CREATE OR REPLACE FUNCTION app.is_organization_admin_for_project(target_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $body$
SELECT app.is_platform_admin()
OR EXISTS (
SELECT 1
FROM projects p
WHERE p.id = target_project_id
AND app.is_organization_admin(p.organization_id)
)
$body$;
-- ---------------------------------------------------------------------------
-- 5. The tenant schemas, the trigger, the functions, the ledger row
-- ---------------------------------------------------------------------------
-- Dropped last, one schema per statement, because each of the four tables in a
-- schema holds a foreign key and a few hundred of them in one transaction will
-- exhaust the lock table - which is a failure that looks like a PostgreSQL defect
-- rather than a rollback that got too big.
--
-- When it does happen, the answer is to **run this file again**. Every statement
-- above it is safe to run twice, the constraints are guarded on the catalogue
-- precisely so that a re-run does not stop at the first one, and the schema drops
-- use `IF EXISTS`. So the recovery is another run, not a hand-written fix.
DO $drop$
DECLARE
tenant text;
BEGIN
FOR tenant IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\_%' LOOP
EXECUTE format('DROP SCHEMA IF EXISTS %I CASCADE', tenant);
END LOOP;
END
$drop$;
DROP TRIGGER IF EXISTS organizations_provision_tenant_schema ON public.organizations;
DROP FUNCTION IF EXISTS app.provision_tenant_schema_for_new_organization();
DROP FUNCTION IF EXISTS app.provision_tenant_schema(uuid);
COMMENT ON FUNCTION app.tenant_schema_name(uuid) IS NULL;
DELETE FROM public.schema_migrations WHERE version = '0005_tenant_schemas';
COMMIT;