From 87fac02e9b254f715215c70a07427276daeffdb8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 10 Apr 2026 06:55:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20migration=20=E2=80=94=20enable=20RLS=20?= =?UTF-8?q?on=2011=20Phase=202=20session=20tables=20(tenant-only=20+=20ste?= =?UTF-8?q?p=5Flibrary=20visibility=20policy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../70a5dd746e83_enable_rls_phase2.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 backend/alembic/versions/70a5dd746e83_enable_rls_phase2.py diff --git a/backend/alembic/versions/70a5dd746e83_enable_rls_phase2.py b/backend/alembic/versions/70a5dd746e83_enable_rls_phase2.py new file mode 100644 index 00000000..aa39efaa --- /dev/null +++ b/backend/alembic/versions/70a5dd746e83_enable_rls_phase2.py @@ -0,0 +1,89 @@ +"""Enable RLS on Phase 2 session and supporting tables. + +10 tables use a standard tenant-only policy. +step_library uses a visibility-aware policy — public steps visible to all tenants. + +NOTE: session_messages does not exist in this codebase (removed from plan). +script_generations is the correct table name (not script_template_generations). +sessions and ai_sessions are two separate tables, both in scope. + +Prerequisites: +- Phase 1 migration must have run (resolutionflow_app role exists, Phase 1 tables have RLS) +- NOT NULL write-path bugs fixed (P2-A commits b641ac6) +- shares.py cross-tenant session fix deployed (P2-B commit ac2b193) + +Revision ID: 70a5dd746e83 +Revises: c5f48b9890f9 +Create Date: 2026-04-10 06:54:49.431817 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '70a5dd746e83' +down_revision: Union[str, None] = 'c5f48b9890f9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NULL_UUID = "00000000-0000-0000-0000-000000000000" +_CURRENT_ACCOUNT = ( + f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), " + f"'{_NULL_UUID}')::uuid" +) + +# Standard tenant-only policy — account_id must match the current tenant. +# When no tenant context is set, COALESCE returns the nil UUID so zero rows +# are visible (fail-closed). +_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}" + +# Visibility-aware policy for step_library — public steps (visibility='public') +# must be visible to ALL tenants regardless of account_id, mirroring +# build_step_visibility_filter() in app/core/filters.py. +_STEP_LIBRARY_USING = f"account_id = {_CURRENT_ACCOUNT} OR visibility = 'public'" + +# Standard tables: strict tenant isolation, no cross-tenant visibility. +_STANDARD_TABLES = [ + "sessions", + "ai_sessions", + "session_branches", + "session_supporting_data", + "session_resolution_outputs", + "session_handoffs", + "script_templates", + "script_generations", + "maintenance_schedules", + "psa_post_log", +] + + +def upgrade() -> None: + # ── Standard tenant-isolation tables ──────────────────────────────────── + for table in _STANDARD_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f""" + CREATE POLICY tenant_isolation ON {table} + USING ({_STANDARD_USING}) + """) + + # ── step_library ──────────────────────────────────────────────────────── + # Public steps (visibility='public') must be readable by all tenants so + # the Solutions Library browsing experience works without tenant context. + # Private/team steps remain tenant-scoped. + op.execute("ALTER TABLE step_library ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE step_library FORCE ROW LEVEL SECURITY") + op.execute(f""" + CREATE POLICY tenant_isolation ON step_library + USING ({_STEP_LIBRARY_USING}) + """) + + +def downgrade() -> None: + for table in _STANDARD_TABLES + ["step_library"]: + 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")