diff --git a/alembic/versions/0108_enable_rls_for_all_tenant_tables.py b/alembic/versions/0108_enable_rls_for_all_tenant_tables.py new file mode 100644 index 0000000..e44e580 --- /dev/null +++ b/alembic/versions/0108_enable_rls_for_all_tenant_tables.py @@ -0,0 +1,138 @@ +"""Enable RLS for all tenant tables that were added after migration 0085. + +Migration 0085 activated Row Level Security only for tables that existed at the +time it ran. Plugin tables and other tables created by later migrations were +not covered, leaving ~84 tables with a ``tenant_id`` column but without RLS. + +This migration dynamically discovers every table in the ``public`` schema that +has a ``tenant_id`` column but does **not** yet have RLS enabled, then: + +1. Enables and forces RLS. +2. Drops any stale ``tenant_isolation`` / ``{table}_tenant_isolation`` policies. +3. Creates a fail-closed ``{table}_tenant_isolation`` policy scoped to + ``crm_api`` and ``crm_worker``. +4. Grants CRUD to ``crm_api`` and ``crm_worker``. +5. Grants the appropriate permissions to ``crm_auth`` on login tables + (users, user_tenants, tenants, sessions, password_reset_tokens). + +Global tables without tenant isolation requirements are skipped. + +Revision ID: 0108 +Revises: 0107 +""" + +from __future__ import annotations + +from alembic import op + +revision = "0108" +down_revision = "0107" +branch_labels = None +depends_on = None + + +# Tables that must never get RLS (global / cross-tenant infrastructure) +GLOBAL_TABLES = [ + "alembic_version", + "plugin_migrations", + "marketplace_listings", + "sequences", + "notification_types", +] + +# Login / identity tables that DO have tenant_id and need RLS, but also need +# crm_auth to keep the permissions defined in migration 0085. +AUTH_TABLES = { + "users": ["SELECT"], + "user_tenants": ["SELECT"], + "tenants": ["SELECT"], + "password_reset_tokens": ["SELECT", "INSERT", "UPDATE", "DELETE"], + "sessions": ["SELECT", "INSERT", "UPDATE", "DELETE"], +} + + +def _exec(sql: str) -> None: + op.execute(sql) + + +def upgrade() -> None: + # ------------------------------------------------------------------ # + # Dynamic discovery + RLS activation for every tenant table that # + # was created after migration 0085 and therefore lacks RLS. # + # ------------------------------------------------------------------ # + + _exec(""" +DO $$ +DECLARE + r RECORD; + policy_sql TEXT; + auth_sql TEXT; +BEGIN + FOR r IN + SELECT t.table_name + FROM information_schema.tables t + JOIN information_schema.columns c + ON c.table_schema = t.table_schema + AND c.table_name = t.table_name + AND c.column_name = 'tenant_id' + WHERE t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND t.table_name NOT IN ( + 'alembic_version', + 'plugin_migrations', + 'marketplace_listings', + 'sequences', + 'notification_types' + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_class pc + JOIN pg_namespace pn ON pn.oid = pc.relnamespace + WHERE pn.nspname = 'public' + AND pc.relname = t.table_name + AND pc.relrowsecurity = true + ) + LOOP + -- Enable + force RLS + EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.table_name); + EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', r.table_name); + + -- Drop stale policies (idempotent) + EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON public.%I', r.table_name); + EXECUTE format('DROP POLICY IF EXISTS %s_tenant_isolation ON public.%I', r.table_name, r.table_name); + + -- Create fail-closed policy + policy_sql := format( + 'CREATE POLICY %s_tenant_isolation ' + 'ON public.%I ' + 'FOR ALL ' + 'TO crm_api, crm_worker ' + 'USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) ' + 'WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)', + r.table_name, r.table_name + ); + EXECUTE policy_sql; + + -- Grant CRUD to crm_api and crm_worker + EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_api', r.table_name); + EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_worker', r.table_name); + + -- Grant crm_auth permissions on login tables (matching 0085 AUTH_TABLES) + IF r.table_name = 'users' THEN + GRANT SELECT ON public.users TO crm_auth; + ELSIF r.table_name = 'user_tenants' THEN + GRANT SELECT ON public.user_tenants TO crm_auth; + ELSIF r.table_name = 'tenants' THEN + GRANT SELECT ON public.tenants TO crm_auth; + ELSIF r.table_name = 'password_reset_tokens' THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON public.password_reset_tokens TO crm_auth; + ELSIF r.table_name = 'sessions' THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON public.sessions TO crm_auth; + END IF; + END LOOP; +END $$; + """) + + +def downgrade() -> None: + pass