"""Re-enable RLS fail-closed on all tenant tables. This migration reverses the RLS disabling from migrations 0078-0081. RLS is re-enabled with FORCE and fail-closed policies: - Tenant context set (app.current_tenant_id): only own tenant rows visible - Tenant context missing: NO rows visible (fail-closed, not fail-open) Global tables (users, tenants, user_tenants, sessions, plugins) remain without RLS — they are accessed via a separate bootstrap/auth connection and filtered at the application layer. Bootstrap and startup must use: 1. A separate connection (crm_auth/crm_bootstrap) for global tables 2. Per-tenant initialization with explicit tenant context: SELECT set_config('app.current_tenant_id', :tenant_id, true); Revision ID: 0084 Revises: 0083 """ from alembic import op from sqlalchemy import text revision = "0084" down_revision = "0083" branch_labels = None depends_on = None # Tables WITH tenant_id column — get fail-closed RLS TENANT_TABLES = [ "groups", "roles", "system_settings", "currencies", "tax_rates", "sequences", "saved_filters", "saved_views", "webhooks", "workspaces", "workspace_modules", "workspace_users", "workspace_widgets", "user_preferences", "custom_field_definitions", "backups", "share_links", "entity_links", "entity_history", "contact_folder_permissions", "contact_folders", "guest_users", "guest_invitations", "permission_delegations", "permission_templates", "entity_permissions", "entity_policies", "entity_attachments", "files", "folders", "tags", "tag_assignments", "tasks", "subtasks", "notification_preferences", "audit_log", "automation_cron_jobs", "automation_definitions", "automation_runs", "automation_versions", "automation_agent_definitions", "automation_agent_runs", "automation_agent_versions", "report_templates", "report_instances", "consumer_inbox", "event_outbox", "outbox_deliveries", ] def upgrade() -> None: conn = op.get_bind() for table in TENANT_TABLES: # Check if table exists exists = conn.execute( text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'") ).fetchone() is not None if not exists: continue # Check if table has tenant_id column has_tenant_id = conn.execute( text(f"SELECT 1 FROM information_schema.columns WHERE table_name = '{table}' AND column_name = 'tenant_id'") ).fetchone() is not None if not has_tenant_id: continue # Drop any existing policies policies = conn.execute(text( f"SELECT policyname FROM pg_policies WHERE tablename = '{table}'" )).fetchall() for (policyname,) in policies: conn.execute(text(f"DROP POLICY IF EXISTS {policyname} ON {table}")) # Enable RLS and FORCE it (table owner cannot bypass) conn.execute(text(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")) conn.execute(text(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")) # Fail-closed tenant isolation policy # NULLIF converts empty string to NULL -> comparison yields NULL -> no rows returned # This is fail-closed: missing tenant context = no access policy_sql = ( "CREATE POLICY " + table + "_tenant_isolation " "ON " + table + " " "AS PERMISSIVE " "FOR ALL " "TO crm_api " "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" ")" ) conn.execute(text(policy_sql)) def downgrade() -> None: conn = op.get_bind() for table in TENANT_TABLES: exists = conn.execute( text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'") ).fetchone() is not None if not exists: continue conn.execute(text(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")) conn.execute(text(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")) conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"))