"""enable_rls_phase1 Revision ID: c5f48b9890f9 Revises: 0b470d9e6cf1 Create Date: 2026-04-10 04:01:13.043321 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = 'c5f48b9890f9' down_revision: Union[str, None] = '0b470d9e6cf1' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None _NULL_UUID = "00000000-0000-0000-0000-000000000000" _PLATFORM_UUID = "00000000-0000-0000-0000-000000000001" _CURRENT_ACCOUNT = ( f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), " f"'{_NULL_UUID}')::uuid" ) def upgrade() -> None: # ── trees ─────────────────────────────────────────────────────────────── # Extended policy mirrors can_access_tree() in app/core/permissions.py. # Tenant sees: own rows, platform rows, any default tree, any public tree, # any gallery-featured tree. # is_gallery_featured = TRUE is included because /public/templates is a # no-auth endpoint — no tenant context is set, so gallery trees must pass # RLS on their own flag rather than relying on account_id or is_public. # Private/team trees from other accounts are hidden. op.execute("ALTER TABLE trees ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE trees FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON trees USING ( account_id = {_CURRENT_ACCOUNT} OR account_id = '{_PLATFORM_UUID}'::uuid OR is_default = TRUE OR is_public = TRUE OR is_gallery_featured = TRUE ) """) # ── tree_tags ──────────────────────────────────────────────────────────── # Own account + platform tags (global tags visible to all tenants). op.execute("ALTER TABLE tree_tags ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE tree_tags FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON tree_tags USING ( account_id = {_CURRENT_ACCOUNT} OR account_id = '{_PLATFORM_UUID}'::uuid ) """) # ── tree_categories ────────────────────────────────────────────────────── op.execute("ALTER TABLE tree_categories ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE tree_categories FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON tree_categories USING ( account_id = {_CURRENT_ACCOUNT} OR account_id = '{_PLATFORM_UUID}'::uuid ) """) # ── step_categories ────────────────────────────────────────────────────── op.execute("ALTER TABLE step_categories ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE step_categories FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON step_categories USING ( account_id = {_CURRENT_ACCOUNT} OR account_id = '{_PLATFORM_UUID}'::uuid ) """) # ── psa_connections ────────────────────────────────────────────────────── # Tenant-only — PSA credentials must never cross tenant boundaries. op.execute("ALTER TABLE psa_connections ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE psa_connections FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON psa_connections USING (account_id = {_CURRENT_ACCOUNT}) """) # ── flow_proposals ──────────────────────────────────────────────────────── # Tenant-only. op.execute("ALTER TABLE flow_proposals ENABLE ROW LEVEL SECURITY") op.execute("ALTER TABLE flow_proposals FORCE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY tenant_isolation ON flow_proposals USING (account_id = {_CURRENT_ACCOUNT}) """) def downgrade() -> None: for table in ["trees", "tree_tags", "tree_categories", "step_categories", "psa_connections", "flow_proposals"]: op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}") op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")