444 lines
22 KiB
PL/PgSQL
444 lines
22 KiB
PL/PgSQL
-- 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; |