fix(security): enable RLS for all tenant tables missing RLS (0108)

This commit is contained in:
Agent Zero
2026-08-06 00:51:28 +02:00
parent 407c373173
commit 0ae8db4932
@@ -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