"""Automated RLS coverage check for all tenant tables. This test verifies that every table in the application schema that has a ``tenant_id`` column has: 1. RLS enabled (relrowsecurity = true) 2. FORCE ROW LEVEL SECURITY enabled (relforcerowsecurity = true) 3. A tenant isolation policy using ``app.current_tenant_id`` 4. No fail-open/bootstrap policy (IS NULL, = '', COALESCE patterns) 5. Policy scoped to runtime roles (crm_api, crm_worker) — not PUBLIC 6. Policy has both USING and WITH CHECK clauses It also verifies that runtime roles are not superuser, do not bypass RLS, and are not table owners. This test does NOT mock the RLS boundary — it queries pg_catalog directly. """ from __future__ import annotations import os import pytest import pytest_asyncio from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine os.environ["SESSION_COOKIE_SECURE"] = "false" os.environ["SESSION_COOKIE_SAMESITE"] = "lax" os.environ["ENVIRONMENT"] = "testing" os.environ["SECRET_KEY"] = "test-secret-key-with-at-least-32-characters-for-testing-only!!" # Use admin connection to inspect catalog _ADMIN_DB_URL = os.environ.get( "RLS_TEST_ADMIN_DB_URL", "postgresql+asyncpg://postgres@localhost:5432/leocrm_test", ) def _skip_if_no_db(): """Skip if the test database is not available.""" try: import asyncio eng = create_async_engine(_ADMIN_DB_URL, echo=False) async def _check(): async with eng.connect() as conn: await conn.execute(text("SELECT 1")) asyncio.get_event_loop().run_until_complete(_check()) eng.dispose() return False except Exception: eng.dispose() return True _skip_reason = "Test database not available for RLS coverage check." @pytest_asyncio.fixture async def admin_session(): eng = create_async_engine(_ADMIN_DB_URL, echo=False) async with eng.connect() as conn: session = AsyncSession(bind=conn, expire_on_commit=False) yield session await session.rollback() await conn.rollback() await eng.dispose() @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_all_tenant_tables_have_rls_enabled(admin_session: AsyncSession): """Every table with tenant_id must have RLS enabled.""" result = await admin_session.execute(text(""" SELECT c.relname FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' AND a.attname = 'tenant_id' AND c.relrowsecurity = false ORDER BY c.relname """)) tables_without_rls = [row[0] for row in result.fetchall()] assert len(tables_without_rls) == 0, \ f"Tables with tenant_id but RLS DISABLED: {tables_without_rls}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_all_tenant_tables_have_force_rls(admin_session: AsyncSession): """Every table with tenant_id must have FORCE ROW LEVEL SECURITY.""" result = await admin_session.execute(text(""" SELECT c.relname FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' AND a.attname = 'tenant_id' AND c.relforcerowsecurity = false ORDER BY c.relname """)) tables_without_force = [row[0] for row in result.fetchall()] assert len(tables_without_force) == 0, \ f"Tables with tenant_id but FORCE RLS DISABLED: {tables_without_force}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_all_tenant_tables_have_isolation_policy(admin_session: AsyncSession): """Every tenant table must have a tenant isolation policy.""" result = await admin_session.execute(text(""" SELECT c.relname FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' AND a.attname = 'tenant_id' AND NOT EXISTS ( SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid AND p.polname LIKE '%tenant_isolation%' ) ORDER BY c.relname """)) tables_without_policy = [row[0] for row in result.fetchall()] assert len(tables_without_policy) == 0, \ f"Tables with tenant_id but no isolation policy: {tables_without_policy}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_no_fail_open_policies(admin_session: AsyncSession): """No RLS policy should use fail-open patterns (IS NULL, = '', COALESCE).""" result = await admin_session.execute(text(""" SELECT tablename, policyname, qual, with_check FROM pg_policies WHERE schemaname = 'public' AND ( qual ILIKE '%current_setting%IS NULL%' OR qual ILIKE '%current_setting%= ''%' OR qual ILIKE '%COALESCE%current_setting%' OR with_check ILIKE '%current_setting%IS NULL%' OR with_check ILIKE '%current_setting%= ''%' OR with_check ILIKE '%COALESCE%current_setting%' ) """)) bad_policies = result.fetchall() assert len(bad_policies) == 0, \ f"Fail-open RLS policies found: {bad_policies}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_policies_scoped_to_runtime_roles(admin_session: AsyncSession): """Tenant isolation policies must be scoped to crm_api/crm_worker, not PUBLIC.""" result = await admin_session.execute(text(""" SELECT tablename, policyname, roles FROM pg_policies WHERE schemaname = 'public' AND policyname LIKE '%tenant_isolation%' AND NOT ('{crm_api,crm_worker}' = roles) AND NOT (roles @> '{crm_api}' AND roles @> '{crm_worker}') """)) wrong_scope = result.fetchall() assert len(wrong_scope) == 0, \ f"Policies not scoped to crm_api+crm_worker: {wrong_scope}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_policies_use_app_current_tenant_id(admin_session: AsyncSession): """All tenant policies must use app.current_tenant_id, not app.tenant_id.""" result = await admin_session.execute(text(""" SELECT tablename, policyname, qual, with_check FROM pg_policies WHERE schemaname = 'public' AND policyname LIKE '%tenant_isolation%' AND ( qual ILIKE '%app.tenant_id%' OR with_check ILIKE '%app.tenant_id%' ) """)) old_var = result.fetchall() assert len(old_var) == 0, \ f"Policies using legacy app.tenant_id: {old_var}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_policies_have_with_check(admin_session: AsyncSession): """Tenant policies must have WITH CHECK for write protection.""" result = await admin_session.execute(text(""" SELECT tablename, policyname FROM pg_policies WHERE schemaname = 'public' AND policyname LIKE '%tenant_isolation%' AND with_check IS NULL """)) no_check = result.fetchall() assert len(no_check) == 0, \ f"Policies without WITH CHECK: {no_check}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_runtime_roles_not_superuser(admin_session: AsyncSession): """crm_api and crm_worker must not be superuser or bypass RLS.""" result = await admin_session.execute(text(""" SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname IN ('crm_api', 'crm_worker', 'crm_migration', 'crm_auth') ORDER BY rolname """)) for row in result.fetchall(): rolname, rolsuper, rolbypassrls = row assert rolsuper is False, f"{rolname} is SUPERUSER!" assert rolbypassrls is False, f"{rolname} has BYPASSRLS!" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_runtime_roles_not_table_owner(admin_session: AsyncSession): """crm_api and crm_worker must not own any tables.""" result = await admin_session.execute(text(""" SELECT tablename, tableowner FROM pg_tables WHERE schemaname = 'public' AND tableowner IN ('crm_api', 'crm_worker', 'crm_auth') """)) owned = result.fetchall() assert len(owned) == 0, \ f"Runtime roles own tables: {owned}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_crm_runtime_role_dropped(admin_session: AsyncSession): """crm_runtime legacy role must not exist.""" result = await admin_session.execute(text(""" SELECT 1 FROM pg_roles WHERE rolname = 'crm_runtime' """)) exists = result.fetchone() assert exists is None, "crm_runtime role still exists — should have been dropped" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_no_rls_on_global_tables(admin_session: AsyncSession): """Global tables (no tenant_id) must NOT have RLS enabled.""" # Tables without tenant_id should not have RLS result = await admin_session.execute(text(""" SELECT c.relname FROM pg_class c WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' AND c.relrowsecurity = true AND NOT EXISTS ( SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = 'tenant_id' ) ORDER BY c.relname """)) global_with_rls = [row[0] for row in result.fetchall()] assert len(global_with_rls) == 0, \ f"Global tables (no tenant_id) with RLS enabled: {global_with_rls}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_crm_auth_has_minimal_access(admin_session: AsyncSession): """crm_auth should only have access to identity tables, not business data.""" result = await admin_session.execute(text(""" SELECT table_name FROM information_schema.role_table_grants WHERE table_schema = 'public' AND grantee = 'crm_auth' AND table_name NOT IN ('users', 'user_tenants', 'tenants', 'password_reset_tokens') """)) extra_access = [row[0] for row in result.fetchall()] assert len(extra_access) == 0, \ f"crm_auth has access to non-identity tables: {extra_access}" @pytest.mark.asyncio @pytest.mark.skipif(_skip_if_no_db(), reason=_skip_reason) async def test_alembic_version_not_accessible_to_runtime(admin_session: AsyncSession): """crm_api and crm_worker must not have access to alembic_version.""" result = await admin_session.execute(text(""" SELECT grantee FROM information_schema.role_table_grants WHERE table_schema = 'public' AND table_name = 'alembic_version' AND grantee IN ('crm_api', 'crm_worker', 'crm_auth') """)) accessors = [row[0] for row in result.fetchall()] assert len(accessors) == 0, \ f"Runtime roles have access to alembic_version: {accessors}"