300 lines
11 KiB
Python
300 lines
11 KiB
Python
|
|
"""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}"
|