"""Tests for ABAC policy service — attribute-based access control policies. Covers: - Policy allow (conditions match → access granted) - Policy deny (conditions match → access blocked) - Multi-condition AND (all conditions must match) - Multi-condition OR (any condition must match) - Policy CRUD operations - build_sql_condition translation - apply_policy_filter integration """ from __future__ import annotations import uuid import pytest import pytest_asyncio from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.contact import Contact from app.models.entity_policy import EntityPolicy from app.services import policy_service as ps from tests.conftest import seed_tenant_and_users @pytest.mark.asyncio class TestABACPolicyService: """Tests for ABAC policy service — attribute-based access control.""" async def test_policy_allow(self, db_session: AsyncSession): """Allow policy with matching conditions grants access.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create an allow policy for contacts where type == 'company' policy = await ps.create_policy( db_session, tenant_id=tenant_id, name="Allow company contacts", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", conditions={ "operator": "AND", "rules": [ {"field": "type", "op": "eq", "value": "company"} ] }, ) assert policy["effect"] == "allow" assert policy["name"] == "Allow company contacts" assert policy["entity_type"] == "contact" assert policy["conditions"]["rules"][0]["field"] == "type" # Verify the policy was persisted db_policy = await db_session.get(EntityPolicy, uuid.UUID(policy["id"])) assert db_policy is not None assert db_policy.effect == "allow" async def test_policy_deny(self, db_session: AsyncSession): """Deny policy blocks access when conditions match.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create a deny policy for contacts where name contains 'Confidential' policy = await ps.create_policy( db_session, tenant_id=tenant_id, name="Deny confidential contacts", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="deny", conditions={ "operator": "AND", "rules": [ {"field": "name", "op": "starts_with", "value": "Confidential"} ] }, ) assert policy["effect"] == "deny" assert policy["enabled"] is True async def test_multi_condition_and(self, db_session: AsyncSession): """AND conditions: all rules must match for the policy to apply.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create policy with AND conditions policy = await ps.create_policy( db_session, tenant_id=tenant_id, name="VIP company contacts", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", conditions={ "operator": "AND", "rules": [ {"field": "type", "op": "eq", "value": "company"}, {"field": "name", "op": "contains", "value": "VIP"}, ] }, ) assert policy["conditions"]["operator"] == "AND" assert len(policy["conditions"]["rules"]) == 2 # Test build_sql_condition translation condition = ps.build_sql_condition(policy["conditions"], Contact) assert condition is not None async def test_multi_condition_or(self, db_session: AsyncSession): """OR conditions: any rule matching triggers the policy.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create policy with OR conditions policy = await ps.create_policy( db_session, tenant_id=tenant_id, name="High value or VIP contacts", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", conditions={ "operator": "OR", "rules": [ {"field": "name", "op": "contains", "value": "VIP"}, {"field": "name", "op": "contains", "value": "Premium"}, ] }, ) assert policy["conditions"]["operator"] == "OR" assert len(policy["conditions"]["rules"]) == 2 # Test build_sql_condition translation condition = ps.build_sql_condition(policy["conditions"], Contact) assert condition is not None async def test_build_sql_condition_eq(self, db_session: AsyncSession): """build_sql_condition translates 'eq' operator correctly.""" conditions = { "operator": "AND", "rules": [ {"field": "type", "op": "eq", "value": "person"} ] } condition = ps.build_sql_condition(conditions, Contact) assert condition is not None async def test_build_sql_condition_gt(self, db_session: AsyncSession): """build_sql_condition translates 'gt' operator correctly.""" conditions = { "operator": "AND", "rules": [ {"field": "id", "op": "gt", "value": 100} ] } condition = ps.build_sql_condition(conditions, Contact) assert condition is not None async def test_build_sql_condition_contains(self, db_session: AsyncSession): """build_sql_condition translates 'contains' operator correctly.""" conditions = { "operator": "AND", "rules": [ {"field": "name", "op": "contains", "value": "test"} ] } condition = ps.build_sql_condition(conditions, Contact) assert condition is not None async def test_build_sql_condition_nested(self, db_session: AsyncSession): """build_sql_condition handles nested conditions blocks.""" conditions = { "operator": "AND", "rules": [ { "operator": "OR", "rules": [ {"field": "type", "op": "eq", "value": "company"}, {"field": "type", "op": "eq", "value": "person"}, ] }, {"field": "name", "op": "is_not_null", "value": None} ] } condition = ps.build_sql_condition(conditions, Contact) assert condition is not None async def test_build_sql_condition_empty_rules(self, db_session: AsyncSession): """build_sql_condition returns None for empty rules.""" conditions = { "operator": "AND", "rules": [] } condition = ps.build_sql_condition(conditions, Contact) assert condition is None async def test_build_sql_condition_unsupported_op(self, db_session: AsyncSession): """build_sql_condition skips unsupported operators gracefully.""" conditions = { "operator": "AND", "rules": [ {"field": "name", "op": "unsupported_op", "value": "test"} ] } condition = ps.build_sql_condition(conditions, Contact) assert condition is None async def test_list_policies(self, db_session: AsyncSession): """list_policies returns all policies for a tenant.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create two policies await ps.create_policy( db_session, tenant_id=tenant_id, name="Policy 1", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", ) await ps.create_policy( db_session, tenant_id=tenant_id, name="Policy 2", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="deny", ) policies = await ps.list_policies(db_session, tenant_id) assert len(policies) >= 2 names = [p["name"] for p in policies] assert "Policy 1" in names assert "Policy 2" in names async def test_list_policies_filtered_by_entity_type(self, db_session: AsyncSession): """list_policies filters by entity_type when provided.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id await ps.create_policy( db_session, tenant_id=tenant_id, name="Contact Policy", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", ) await ps.create_policy( db_session, tenant_id=tenant_id, name="File Policy", entity_type="dms_file", principal_type="user", principal_id=str(user_id), effect="allow", ) contact_policies = await ps.list_policies(db_session, tenant_id, entity_type="contact") assert len(contact_policies) >= 1 assert all(p["entity_type"] == "contact" for p in contact_policies) async def test_update_policy(self, db_session: AsyncSession): """update_policy modifies an existing policy.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id policy = await ps.create_policy( db_session, tenant_id=tenant_id, name="Original", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", ) updated = await ps.update_policy( db_session, tenant_id, policy["id"], name="Updated", effect="deny", ) assert updated["name"] == "Updated" assert updated["effect"] == "deny" async def test_delete_policy(self, db_session: AsyncSession): """delete_policy removes a policy.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id policy = await ps.create_policy( db_session, tenant_id=tenant_id, name="To Delete", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", ) await ps.delete_policy(db_session, tenant_id, policy["id"]) # Verify it's gone policies = await ps.list_policies(db_session, tenant_id) ids = [p["id"] for p in policies] assert policy["id"] not in ids async def test_apply_policy_filter_no_policies(self, db_session: AsyncSession): """apply_policy_filter returns query unchanged when no policies match.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id query = select(Contact).where(Contact.tenant_id == tenant_id) result = await ps.apply_policy_filter( db_session, query, "contact", user_id, tenant_id, Contact ) # Query should be unchanged (no ABAC restriction) rows = await db_session.execute(result) contacts = rows.scalars().all() assert len(contacts) >= 1 async def test_apply_policy_filter_with_allow(self, db_session: AsyncSession): """apply_policy_filter with allow policy filters correctly.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create allow policy for company-type contacts await ps.create_policy( db_session, tenant_id=tenant_id, name="Only companies", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", conditions={ "operator": "AND", "rules": [ {"field": "type", "op": "eq", "value": "company"} ] }, ) query = select(Contact).where(Contact.tenant_id == tenant_id) result = await ps.apply_policy_filter( db_session, query, "contact", user_id, tenant_id, Contact ) rows = await db_session.execute(result) contacts = rows.scalars().all() assert len(contacts) >= 1 for c in contacts: assert c.type == "company", f"Expected 'company', got '{c.type}'" async def test_apply_policy_filter_with_deny(self, db_session: AsyncSession): """apply_policy_filter with deny policy blocks matching entities.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id # Create deny policy for contacts named 'Company Alpha' await ps.create_policy( db_session, tenant_id=tenant_id, name="Block Company Alpha", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="deny", conditions={ "operator": "AND", "rules": [ {"field": "name", "op": "eq", "value": "Company Alpha"} ] }, ) query = select(Contact).where(Contact.tenant_id == tenant_id) result = await ps.apply_policy_filter( db_session, query, "contact", user_id, tenant_id, Contact ) rows = await db_session.execute(result) contacts = rows.scalars().all() # Company Alpha should be filtered out names = [c.name for c in contacts] assert "Company Alpha" not in names, "Deny policy should have blocked Company Alpha" async def test_policy_priority_ordering(self, db_session: AsyncSession): """Policies are ordered by priority (higher first).""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id await ps.create_policy( db_session, tenant_id=tenant_id, name="Low Priority", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", priority=1, ) await ps.create_policy( db_session, tenant_id=tenant_id, name="High Priority", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", priority=100, ) policies = await ps.list_policies(db_session, tenant_id, entity_type="contact") # High priority should come first high_idx = next(i for i, p in enumerate(policies) if p["name"] == "High Priority") low_idx = next(i for i, p in enumerate(policies) if p["name"] == "Low Priority") assert high_idx < low_idx, "High priority policy should come first" async def test_policy_enabled_flag(self, db_session: AsyncSession): """Disabled policies are not applied.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id policy = await ps.create_policy( db_session, tenant_id=tenant_id, name="Disabled Policy", entity_type="contact", principal_type="user", principal_id=str(user_id), effect="allow", ) # Disable the policy updated = await ps.update_policy( db_session, tenant_id, policy["id"], enabled=False ) assert updated["enabled"] is False