diff --git a/alembic/versions/0079_disable_rls_system_tables.py b/alembic/versions/0079_disable_rls_system_tables.py new file mode 100644 index 0000000..92e4930 --- /dev/null +++ b/alembic/versions/0079_disable_rls_system_tables.py @@ -0,0 +1,69 @@ +"""Disable RLS on all system/auth/config tables needed at startup and login. + +These tables are read before a tenant context is set (startup, login, +plugin activation). RLS must be disabled on them to allow unprivileged +(crm_api) access without tenant context. + +Revision ID: 0079 +Revises: 0078 +""" +from alembic import op +from sqlalchemy import text + +revision = "0079" +down_revision = "0078" +branch_labels = None +depends_on = None + +# All tables that need to be read WITHOUT tenant context +SYSTEM_TABLES = [ + # Auth tables + "user_tenants", + "sessions", + "password_reset_tokens", + "api_tokens", + "user_groups", + "user_preferences", + # RBAC tables + "roles", + "groups", + "permissions", + # Config tables + "system_settings", + "currencies", + "tax_rates", + "sequences", + "saved_filters", + "saved_views", + "webhooks", + "notification_preferences", + # Plugin tables + "tenant_plugin_activation", + # Workspace tables (needed for workspace context before tenant filter) + "workspaces", + "workspace_modules", + "workspace_users", + "workspace_widgets", +] + +def upgrade() -> None: + conn = op.get_bind() + for table in SYSTEM_TABLES: + exists = conn.execute( + text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'") + ).fetchone() is not None + if not exists: + continue + # Drop all RLS policies on this table + 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}")) + conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")) + print(f" Disabled RLS on {table}") + +def downgrade() -> None: + # Re-enabling RLS on system tables would break startup with crm_api + # This is intentionally a no-op + pass