Files

105 lines
4.1 KiB
Python
Raw Permalink Normal View History

2026-07-25 21:03:46 +02:00
"""FORCE Row Level Security + WITH CHECK on all tenant-scoped tables.
Revision ID: 0028_rls_force
Revises: 0027_unify_company_to_contact
Create Date: 2026-07-25
This migration:
1. Discovers all tables in the public schema that have a tenant_id column.
2. ALTER TABLE ... FORCE ROW LEVEL SECURITY on each (ensures RLS applies to table owners too).
3. Drops existing tenant_isolation policies and recreates them with both
USING and WITH CHECK clauses so writes are also filtered by tenant.
4. Covers core tables AND plugin tables (anything with tenant_id).
Idempotent: safe to run multiple times.
"""
from __future__ import annotations
import logging
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0028_rls_force"
down_revision: Union[str, None] = "0027_unify_company_to_contact"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
logger = logging.getLogger("alembic.migration.0028_rls_force")
def _discover_tenant_tables(conn) -> list[str]:
"""Return all table names in the public schema that have a tenant_id column."""
result = conn.execute(
sa.text(
"SELECT table_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND column_name = 'tenant_id' "
"ORDER BY table_name"
)
)
return [row[0] for row in result.fetchall()]
def _discover_existing_policies(conn, table_name: str) -> list[str]:
"""Return all policy names on *table_name* that contain 'tenant' or 'isolation'."""
result = conn.execute(
sa.text(
"SELECT policyname FROM pg_policies "
"WHERE schemaname = 'public' AND tablename = :t"
),
{"t": table_name},
)
return [row[0] for row in result.fetchall()]
def upgrade() -> None:
conn = op.get_bind()
tenant_tables = _discover_tenant_tables(conn)
logger.info("Discovered %d tenant-scoped tables: %s", len(tenant_tables), tenant_tables)
for table_name in tenant_tables:
# 1. Enable RLS (idempotent — ENABLE is safe to repeat)
op.execute(f'ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY')
# 2. FORCE RLS — ensures policies apply even to table owners/superusers
# who would otherwise bypass RLS
op.execute(f'ALTER TABLE "{table_name}" FORCE ROW LEVEL SECURITY')
# 3. Drop ALL existing policies on this table that relate to tenant isolation
existing_policies = _discover_existing_policies(conn, table_name)
for policy_name in existing_policies:
op.execute(f'DROP POLICY IF EXISTS "{policy_name}" ON "{table_name}"')
logger.info("Dropped policy %s on %s", policy_name, table_name)
# 4. Create new policy with both USING and WITH CHECK
# USING: filters rows visible in SELECT/UPDATE/DELETE
# WITH CHECK: enforces tenant_id on INSERT/UPDATE
op.execute(
f'CREATE POLICY tenant_isolation ON "{table_name}" '
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid) "
f"WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid)"
)
logger.info("Created policy tenant_isolation on %s (USING + WITH CHECK)", table_name)
def downgrade() -> None:
"""Revert FORCE RLS and restore USING-only policies (matching 0015 behavior)."""
conn = op.get_bind()
tenant_tables = _discover_tenant_tables(conn)
for table_name in tenant_tables:
# Drop the USING+WITH CHECK policy
op.execute(f'DROP POLICY IF EXISTS tenant_isolation ON "{table_name}"')
# Remove FORCE but keep ENABLE (matching pre-0028 state)
op.execute(f'ALTER TABLE "{table_name}" NO FORCE ROW LEVEL SECURITY')
# Recreate USING-only policy (matching original 0015 behavior)
op.execute(
f'CREATE POLICY tenant_isolation ON "{table_name}" '
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid)"
)
logger.info("Reverted %s to USING-only policy (removed FORCE, removed WITH CHECK)", table_name)