Files
leocrm/tests/test_rls_coverage.py
T

300 lines
11 KiB
Python
Raw Normal View History

"""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}"