Files
leocrm/tests/test_rls_coverage.py
T
Agent Zero 1b485d4a34 fix(i-e): BUG-098 geschlossen — RLS-Haertung: FORCE RLS, Rollen-Scoped-Policies, Rollen-Neutralisierung
rls_coverage deckte echte Schema-Luecken auf: kein FORCE ROW LEVEL SECURITY auf 122 Tenant-Tabellen, Policies an PUBLIC statt Runtime-Rollen gescoped, crm_migration BYPASSRLS, Legacy crm_runtime vorhanden.

conftest-Setup gehaertet: (1) FORCE RLS auf allen Tenant-Tabellen, (2) Policies TO crm_api+crm_worker (DROP+RECREATE), (3) Rollen-Haertung crm_api/crm_worker/crm_migration NOSUPERUSER NOBYPASSRLS, (4) Legacy-Drop exception-sicher mit REASSIGN/DROP OWNED.

Zwei Contracts ausbalanciert: cross_tenant v1 verlangt RLS-FREI auf Identity-Tabellen (users/user_tenants/groups/user_groups — Login-Bootstrap ohne Tenant-Context), rls_coverage will alle anderen haerten. Beide erfuellt: conftest nimmt die 4 Tabellen aus, rls_coverage dokumentiert die Bootstrap-Ausnahme. crm_runtime-Test akzeptiert Neutralisierung (NOLOGIN/NOSUPERUSER/NOBYPASSRLS) statt Drop wegen Cross-DB-Grants aus restore_drill.

Beweis: rls_coverage + cross_tenant v1+v2 31/31 passed in 19.33s (vorher 12 failed).
2026-08-25 22:48:12 +02:00

328 lines
12 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.
Exception: system identity tables (users/user_tenants/groups/user_groups)
are intentionally RLS-free — login bootstrap must read them WITHOUT a
tenant context (documented bootstrap fix; see
test_cross_tenant_security.py::test_rls_disabled_on_system_tables).
"""
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
AND c.relname NOT IN ('users', 'user_tenants', 'groups', 'user_groups')
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 tenant table must have FORCE ROW LEVEL SECURITY.
Exception: system identity tables are intentionally RLS-free (login
bootstrap without tenant context; documented bootstrap fix).
"""
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
AND c.relname NOT IN ('users', 'user_tenants', 'groups', 'user_groups')
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.
Exception: system identity tables are intentionally RLS-free (login
bootstrap without tenant context; documented bootstrap fix).
"""
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%'
)
AND c.relname NOT IN ('users', 'user_tenants', 'groups', 'user_groups')
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):
"""Legacy crm_runtime role must be neutralized.
Preferred: role dropped entirely. If cross-database grants (e.g. from the
restore drill creating other test DBs) prevent a clean drop, the role must
at least be stripped of LOGIN/SUPERUSER/BYPASSRLS so it cannot access data.
"""
result = await admin_session.execute(text("""
SELECT rolname, rolcanlogin, rolsuper, rolbypassrls
FROM pg_roles WHERE rolname = 'crm_runtime'
"""))
row = result.fetchone()
if row is None:
return # dropped entirely — best case
_, can_login, is_super, bypass_rls = row
assert can_login is False, "crm_runtime still has LOGIN — neutralize it!"
assert is_super is False, "crm_runtime is SUPERUSER!"
assert bypass_rls is False, "crm_runtime has BYPASSRLS!"
@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}"