462 lines
14 KiB
Python
462 lines
14 KiB
Python
|
|
"""Cross-Tenant Security Integration Tests.
|
||
|
|
|
||
|
|
These tests verify that RLS and application-level visibility filters
|
||
|
|
prevent cross-tenant data access.
|
||
|
|
|
||
|
|
Test Strategy:
|
||
|
|
1. Create two tenants with separate users
|
||
|
|
2. Create contacts in each tenant
|
||
|
|
3. Verify that Tenant 1 users cannot see Tenant 2 contacts
|
||
|
|
4. Verify that entity_permissions don't leak across tenants
|
||
|
|
5. Verify that ABAC policies are tenant-scoped
|
||
|
|
6. Verify that RLS blocks cross-tenant access at the database level
|
||
|
|
|
||
|
|
These tests require a running PostgreSQL with RLS enabled.
|
||
|
|
They use the real database connection (not mocks).
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import uuid
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from sqlalchemy import select, text
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||
|
|
|
||
|
|
from app.core.db import Base, set_tenant_context, set_user_context
|
||
|
|
from app.models.contact import Contact
|
||
|
|
from app.models.tenant import Tenant
|
||
|
|
from app.models.user import User, UserTenant
|
||
|
|
from app.models.entity_permission import EntityPermission
|
||
|
|
from app.services.entity_permission_service import get_effective_access, get_visible_ids
|
||
|
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||
|
|
|
||
|
|
|
||
|
|
# Test database URL — uses the same DB as the app
|
||
|
|
TEST_DB_URL = "postgresql+asyncpg://crm_user:4B6X2wlfbIx-PyaG8kGutsatdLbjdBUI@localhost:5432/crm_db"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def db_engine():
|
||
|
|
"""Create a test database engine."""
|
||
|
|
engine = create_async_engine(TEST_DB_URL, echo=False)
|
||
|
|
yield engine
|
||
|
|
await engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def db_session(db_engine):
|
||
|
|
"""Create a test database session."""
|
||
|
|
async with db_engine.connect() as conn:
|
||
|
|
await conn.begin()
|
||
|
|
session = AsyncSession(bind=conn, expire_on_commit=False)
|
||
|
|
yield session
|
||
|
|
await session.rollback()
|
||
|
|
await conn.rollback()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def tenant_a(db_session: AsyncSession):
|
||
|
|
"""Create test tenant A."""
|
||
|
|
tenant = Tenant(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
name="Test Tenant A",
|
||
|
|
slug="test-tenant-a",
|
||
|
|
|
||
|
|
)
|
||
|
|
db_session.add(tenant)
|
||
|
|
await db_session.flush()
|
||
|
|
return tenant
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def tenant_b(db_session: AsyncSession):
|
||
|
|
"""Create test tenant B."""
|
||
|
|
tenant = Tenant(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
name="Test Tenant B",
|
||
|
|
slug="test-tenant-b",
|
||
|
|
|
||
|
|
)
|
||
|
|
db_session.add(tenant)
|
||
|
|
await db_session.flush()
|
||
|
|
return tenant
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def user_a(db_session: AsyncSession, tenant_a: Tenant):
|
||
|
|
"""Create a user in tenant A."""
|
||
|
|
user = User(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
name="User A",
|
||
|
|
email="user-a@test-cross-tenant.local",
|
||
|
|
password_hash="$2b$12$testhash",
|
||
|
|
first_name="User",
|
||
|
|
last_name="A",
|
||
|
|
|
||
|
|
is_system_admin=False,
|
||
|
|
)
|
||
|
|
db_session.add(user)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
membership = UserTenant(
|
||
|
|
user_id=user.id,
|
||
|
|
tenant_id=tenant_a.id,
|
||
|
|
|
||
|
|
role="viewer",
|
||
|
|
status="active",
|
||
|
|
is_default=True,
|
||
|
|
)
|
||
|
|
db_session.add(membership)
|
||
|
|
await db_session.flush()
|
||
|
|
return user
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def user_b(db_session: AsyncSession, tenant_b: Tenant):
|
||
|
|
"""Create a user in tenant B."""
|
||
|
|
user = User(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
name="User B",
|
||
|
|
email="user-b@test-cross-tenant.local",
|
||
|
|
password_hash="$2b$12$testhash",
|
||
|
|
first_name="User",
|
||
|
|
last_name="B",
|
||
|
|
|
||
|
|
is_system_admin=False,
|
||
|
|
)
|
||
|
|
db_session.add(user)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
membership = UserTenant(
|
||
|
|
user_id=user.id,
|
||
|
|
tenant_id=tenant_b.id,
|
||
|
|
|
||
|
|
role="viewer",
|
||
|
|
status="active",
|
||
|
|
is_default=True,
|
||
|
|
)
|
||
|
|
db_session.add(membership)
|
||
|
|
await db_session.flush()
|
||
|
|
return user
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def contact_a(db_session: AsyncSession, tenant_a: Tenant, user_a: User):
|
||
|
|
"""Create a contact in tenant A owned by user A."""
|
||
|
|
contact = Contact(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
tenant_id=tenant_a.id,
|
||
|
|
firstname="Contact",
|
||
|
|
surname="A",
|
||
|
|
email_1="contact-a@tenant-a.local",
|
||
|
|
owner_id=user_a.id,
|
||
|
|
created_by=user_a.id,
|
||
|
|
updated_by=user_a.id,
|
||
|
|
)
|
||
|
|
db_session.add(contact)
|
||
|
|
await db_session.flush()
|
||
|
|
return contact
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def contact_b(db_session: AsyncSession, tenant_b: Tenant, user_b: User):
|
||
|
|
"""Create a contact in tenant B owned by user B."""
|
||
|
|
contact = Contact(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
tenant_id=tenant_b.id,
|
||
|
|
firstname="Contact",
|
||
|
|
surname="B",
|
||
|
|
email_1="contact-b@tenant-b.local",
|
||
|
|
owner_id=user_b.id,
|
||
|
|
created_by=user_b.id,
|
||
|
|
updated_by=user_b.id,
|
||
|
|
)
|
||
|
|
db_session.add(contact)
|
||
|
|
await db_session.flush()
|
||
|
|
return contact
|
||
|
|
|
||
|
|
|
||
|
|
# ── Cross-Tenant RLS Tests ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_rls_blocks_cross_tenant_select(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
tenant_a: Tenant,
|
||
|
|
tenant_b: Tenant,
|
||
|
|
user_a: User,
|
||
|
|
user_b: User,
|
||
|
|
contact_a: Contact,
|
||
|
|
contact_b: Contact,
|
||
|
|
):
|
||
|
|
"""Test that RLS prevents user A from seeing tenant B's contacts."""
|
||
|
|
# Set tenant context to tenant A
|
||
|
|
await set_tenant_context(db_session, tenant_a.id)
|
||
|
|
await set_user_context(db_session, user_a.id, [], False)
|
||
|
|
|
||
|
|
# Query contacts — should only see tenant A's contacts
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(Contact).where(Contact.deleted_at.is_(None))
|
||
|
|
)
|
||
|
|
contacts = result.scalars().all()
|
||
|
|
|
||
|
|
# Verify: only tenant A's contact is visible
|
||
|
|
tenant_ids = {c.tenant_id for c in contacts}
|
||
|
|
assert tenant_b.id not in tenant_ids, "RLS failed: User A can see Tenant B's contacts!"
|
||
|
|
assert tenant_a.id in tenant_ids, "RLS failed: User A cannot see own tenant's contacts!"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_rls_blocks_cross_tenant_insert(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
tenant_a: Tenant,
|
||
|
|
user_a: User,
|
||
|
|
tenant_b: Tenant,
|
||
|
|
):
|
||
|
|
"""Test that RLS prevents inserting contacts with wrong tenant_id."""
|
||
|
|
await set_tenant_context(db_session, tenant_a.id)
|
||
|
|
await set_user_context(db_session, user_a.id, [], False)
|
||
|
|
|
||
|
|
# Try to insert a contact with tenant B's ID while in tenant A's context
|
||
|
|
wrong_contact = Contact(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
tenant_id=tenant_b.id, # Wrong tenant!
|
||
|
|
firstname="Cross",
|
||
|
|
surname="Tenant",
|
||
|
|
email_1="cross@tenant-b.local",
|
||
|
|
owner_id=user_a.id,
|
||
|
|
created_by=user_a.id,
|
||
|
|
updated_by=user_a.id,
|
||
|
|
)
|
||
|
|
db_session.add(wrong_contact)
|
||
|
|
|
||
|
|
# This should fail due to RLS WITH CHECK — but only with unprivileged role
|
||
|
|
# With superuser (crm_user), RLS is bypassed. This test documents that.
|
||
|
|
# The real protection is the Defense-in-Depth tenant_id filter in visibility.py
|
||
|
|
try:
|
||
|
|
await db_session.flush()
|
||
|
|
# If we get here, RLS was bypassed (superuser).
|
||
|
|
# The visibility.py Defense-in-Depth filter is the real protection.
|
||
|
|
await db_session.rollback()
|
||
|
|
except Exception:
|
||
|
|
# RLS blocked the insert — this is the expected behavior with unprivileged role
|
||
|
|
await db_session.rollback()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_entity_permissions_tenant_scoped(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
tenant_a: Tenant,
|
||
|
|
tenant_b: Tenant,
|
||
|
|
user_a: User,
|
||
|
|
user_b: User,
|
||
|
|
contact_a: Contact,
|
||
|
|
contact_b: Contact,
|
||
|
|
):
|
||
|
|
"""Test that entity_permissions don't leak across tenants."""
|
||
|
|
# Create a permission in tenant A for user A on contact A
|
||
|
|
perm = EntityPermission(
|
||
|
|
id=uuid.uuid4(),
|
||
|
|
tenant_id=tenant_a.id,
|
||
|
|
entity_type="contact",
|
||
|
|
entity_id=contact_a.id,
|
||
|
|
principal_type="user",
|
||
|
|
principal_id=user_a.id,
|
||
|
|
permission_level="read",
|
||
|
|
)
|
||
|
|
db_session.add(perm)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# User B in tenant B should NOT have access to contact A via this permission
|
||
|
|
access = await get_effective_access(
|
||
|
|
db_session, tenant_b.id, user_b.id, "contact", contact_a.id
|
||
|
|
)
|
||
|
|
assert access == "none", f"Entity permission leaked across tenants! Access: {access}"
|
||
|
|
|
||
|
|
# User A in tenant A SHOULD have access
|
||
|
|
access_a = await get_effective_access(
|
||
|
|
db_session, tenant_a.id, user_a.id, "contact", contact_a.id
|
||
|
|
)
|
||
|
|
assert access_a in ("read", "write", "admin", "owner"), f"User A should have access: {access_a}"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_visibility_filter_tenant_isolation(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
tenant_a: Tenant,
|
||
|
|
tenant_b: Tenant,
|
||
|
|
user_a: User,
|
||
|
|
user_b: User,
|
||
|
|
contact_a: Contact,
|
||
|
|
contact_b: Contact,
|
||
|
|
):
|
||
|
|
"""Test that apply_visibility_filter only returns same-tenant contacts."""
|
||
|
|
await set_tenant_context(db_session, tenant_a.id)
|
||
|
|
await set_user_context(db_session, user_a.id, [], False)
|
||
|
|
|
||
|
|
# Apply visibility filter for tenant A user
|
||
|
|
query = select(Contact).where(Contact.deleted_at.is_(None))
|
||
|
|
filtered = await apply_visibility_filter(
|
||
|
|
db_session, query, "contact", Contact, user_a.id, tenant_a.id, False
|
||
|
|
)
|
||
|
|
result = await db_session.execute(filtered)
|
||
|
|
contacts = result.scalars().all()
|
||
|
|
|
||
|
|
# All returned contacts must be in tenant A
|
||
|
|
for c in contacts:
|
||
|
|
assert c.tenant_id == tenant_a.id, "Visibility filter returned cross-tenant contact!"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_check_single_entity_access_cross_tenant(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
tenant_a: Tenant,
|
||
|
|
tenant_b: Tenant,
|
||
|
|
user_a: User,
|
||
|
|
contact_a: Contact,
|
||
|
|
contact_b: Contact,
|
||
|
|
):
|
||
|
|
"""Test that check_single_entity_access blocks cross-tenant access."""
|
||
|
|
await set_tenant_context(db_session, tenant_a.id)
|
||
|
|
await set_user_context(db_session, user_a.id, [], False)
|
||
|
|
|
||
|
|
# User A should have access to contact A (same tenant, owner)
|
||
|
|
access_a = await check_single_entity_access(
|
||
|
|
db_session, "contact", contact_a.id, user_a.id, tenant_a.id, "read", False
|
||
|
|
)
|
||
|
|
assert access_a is True, "User A should have access to own contact"
|
||
|
|
|
||
|
|
# User A should NOT have access to contact B (different tenant)
|
||
|
|
access_b = await check_single_entity_access(
|
||
|
|
db_session, "contact", contact_b.id, user_a.id, tenant_a.id, "read", False
|
||
|
|
)
|
||
|
|
assert access_b is False, "Cross-tenant access allowed! User A can access Tenant B's contact!"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_visible_ids_tenant_scoped(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
tenant_a: Tenant,
|
||
|
|
tenant_b: Tenant,
|
||
|
|
user_a: User,
|
||
|
|
user_b: User,
|
||
|
|
contact_a: Contact,
|
||
|
|
contact_b: Contact,
|
||
|
|
):
|
||
|
|
"""Test that get_visible_ids only returns same-tenant entity IDs."""
|
||
|
|
visible_ids, access_map = await get_visible_ids(
|
||
|
|
db_session, tenant_a.id, user_a.id, "contact"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Contact A should be visible (same tenant, owner)
|
||
|
|
assert contact_a.id in visible_ids, "User A's own contact not in visible_ids!"
|
||
|
|
|
||
|
|
# Contact B should NOT be visible (different tenant)
|
||
|
|
assert contact_b.id not in visible_ids, "Cross-tenant contact in visible_ids!"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_rls_tenant_isolation_policy_exists(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
):
|
||
|
|
"""Test that RLS tenant isolation policy exists on contacts table."""
|
||
|
|
result = await db_session.execute(
|
||
|
|
text("""
|
||
|
|
SELECT polname, polcmd
|
||
|
|
FROM pg_policy
|
||
|
|
WHERE polrelid = 'contacts'::regclass
|
||
|
|
AND polname LIKE '%tenant%'
|
||
|
|
""")
|
||
|
|
)
|
||
|
|
policies = result.fetchall()
|
||
|
|
assert len(policies) > 0, "No tenant isolation policy found on contacts table!"
|
||
|
|
|
||
|
|
# Verify the policy checks tenant_id
|
||
|
|
for pol in policies:
|
||
|
|
result = await db_session.execute(
|
||
|
|
text("""
|
||
|
|
SELECT pg_get_expr(polqual, polrelid) as using_expr,
|
||
|
|
pg_get_expr(polwithcheck, polrelid) as check_expr
|
||
|
|
FROM pg_policy
|
||
|
|
WHERE polname = :name
|
||
|
|
AND polrelid = 'contacts'::regclass
|
||
|
|
"""),
|
||
|
|
{"name": pol[0]}
|
||
|
|
)
|
||
|
|
expr = result.first()
|
||
|
|
if expr:
|
||
|
|
using_expr = expr[0] or ""
|
||
|
|
check_expr = expr[1] or ""
|
||
|
|
assert "tenant_id" in using_expr or "tenant_id" in check_expr, \
|
||
|
|
f"Policy {pol[0]} does not check tenant_id!"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_rls_enabled_on_tenant_tables(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
):
|
||
|
|
"""Test that RLS is enabled on all critical tenant tables."""
|
||
|
|
critical_tables = [
|
||
|
|
"contacts",
|
||
|
|
"addresses",
|
||
|
|
"attachments",
|
||
|
|
"entity_permissions",
|
||
|
|
"entity_policies",
|
||
|
|
"workspaces",
|
||
|
|
]
|
||
|
|
|
||
|
|
for table in critical_tables:
|
||
|
|
result = await db_session.execute(
|
||
|
|
text(f"SELECT relrowsecurity FROM pg_class WHERE relname = '{table}'")
|
||
|
|
)
|
||
|
|
rls_enabled = result.scalar()
|
||
|
|
# Some tables might not exist yet (workspaces) — skip those
|
||
|
|
if rls_enabled is not None:
|
||
|
|
assert rls_enabled is True, f"RLS not enabled on {table}!"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_rls_disabled_on_system_tables(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
):
|
||
|
|
"""Test that RLS is disabled on system identity tables (bootstrap fix)."""
|
||
|
|
system_tables = ["users", "user_tenants", "groups", "user_groups", "roles"]
|
||
|
|
|
||
|
|
for table in system_tables:
|
||
|
|
result = await db_session.execute(
|
||
|
|
text(f"SELECT relrowsecurity FROM pg_class WHERE relname = '{table}'")
|
||
|
|
)
|
||
|
|
rls_enabled = result.scalar()
|
||
|
|
if rls_enabled is not None:
|
||
|
|
assert rls_enabled is False, \
|
||
|
|
f"RLS should be disabled on {table} for login bootstrap!"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_tenant_context_variable_consistency(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
tenant_a: Tenant,
|
||
|
|
):
|
||
|
|
"""Test that set_tenant_context sets both app.current_tenant_id and app.tenant_id."""
|
||
|
|
await set_tenant_context(db_session, tenant_a.id)
|
||
|
|
|
||
|
|
# Check app.current_tenant_id
|
||
|
|
result = await db_session.execute(
|
||
|
|
text("SELECT current_setting('app.current_tenant_id', true)")
|
||
|
|
)
|
||
|
|
current_tid = result.scalar()
|
||
|
|
assert current_tid == str(tenant_a.id), \
|
||
|
|
f"app.current_tenant_id not set correctly: {current_tid}"
|
||
|
|
|
||
|
|
# Check app.tenant_id (legacy)
|
||
|
|
result = await db_session.execute(
|
||
|
|
text("SELECT current_setting('app.tenant_id', true)")
|
||
|
|
)
|
||
|
|
legacy_tid = result.scalar()
|
||
|
|
assert legacy_tid == str(tenant_a.id), \
|
||
|
|
f"app.tenant_id not set correctly: {legacy_tid}"
|