727d86614e
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
105 lines
4.1 KiB
Python
105 lines
4.1 KiB
Python
"""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)
|