sprint20-23: tests + documentation + guest access + infrastructure + migrations 0059

This commit is contained in:
Agent Zero
2026-07-29 02:53:37 +02:00
parent ddf73ee42e
commit 24690fb674
20 changed files with 3898 additions and 2 deletions
+424
View File
@@ -0,0 +1,424 @@
"""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
}
+406
View File
@@ -0,0 +1,406 @@
"""Tests for entity permission service — ACL resolution, ownership, sharing, expiration.
Covers:
- Owner sees own contacts
- Non-owner doesn't see others' contacts
- Shared user sees shared contacts
- Permission expiration
- System admin sees all
- Batch resolution
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.contact import Contact
from app.models.entity_permission import EntityPermission
from app.models.user import User
from app.services import entity_permission_service as eps
from tests.conftest import seed_tenant_and_users
@pytest.mark.asyncio
class TestEntityPermissions:
"""Tests for entity permission service — ACL resolution, ownership, sharing."""
async def test_owner_sees_own_contacts(self, db_session: AsyncSession):
"""Owner has 'owner' access level on their own contacts."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
contact_id = seed["company_a"].id
# Set owner_id on the contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = user_id
await db_session.commit()
access = await eps.get_effective_access(
db_session, tenant_id, user_id, "contact", contact_id
)
assert access == "owner", f"Expected 'owner', got '{access}'"
async def test_non_owner_doesnt_see_others_contacts(self, db_session: AsyncSession):
"""Non-owner without explicit permission gets 'none' access."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
other_user_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Set owner_id on the contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
access = await eps.get_effective_access(
db_session, tenant_id, other_user_id, "contact", contact_id
)
assert access == "none", f"Expected 'none', got '{access}'"
async def test_shared_user_sees_shared_contacts(self, db_session: AsyncSession):
"""User with explicit 'read' permission sees the contact."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
shared_user_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Set owner_id on the contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Grant read permission to viewer
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(shared_user_id),
permission_level="read",
created_by=owner_id,
)
access = await eps.get_effective_access(
db_session, tenant_id, shared_user_id, "contact", contact_id
)
assert access == "read", f"Expected 'read', got '{access}'"
async def test_permission_expiration(self, db_session: AsyncSession):
"""Expired permission returns 'none' access."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
shared_user_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Set owner_id on the contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Grant read permission that expired 1 hour ago
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(shared_user_id),
permission_level="read",
expires_at=datetime.now(UTC) - timedelta(hours=1),
created_by=owner_id,
)
access = await eps.get_effective_access(
db_session, tenant_id, shared_user_id, "contact", contact_id
)
assert access == "none", f"Expected 'none' for expired permission, got '{access}'"
async def test_system_admin_sees_all(self, db_session: AsyncSession):
"""System admin gets 'delete' access on any entity."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
contact_id = seed["company_a"].id
# Create a system admin user
from app.core.auth import hash_password
sys_admin = User(
email="sysadmin@test.com",
name="System Admin",
password_hash=hash_password("TestPass123!"),
is_active=True,
is_system_admin=True,
preferences={},
)
db_session.add(sys_admin)
await db_session.commit()
access = await eps.get_effective_access(
db_session, tenant_id, sys_admin.id, "contact", contact_id
)
assert access == "delete", f"Expected 'delete' for system admin, got '{access}'"
async def test_batch_resolution(self, db_session: AsyncSession):
"""Batch resolution returns correct access levels for multiple entities."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Create a second contact owned by viewer
contact2 = Contact(
tenant_id=tenant_id,
type="company",
name="Company Viewer",
displayname="Company Viewer",
owner_id=viewer_id,
created_by=viewer_id,
updated_by=viewer_id,
)
db_session.add(contact2)
await db_session.commit()
# Set owner on first contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Grant read permission to viewer on first contact
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(viewer_id),
permission_level="read",
created_by=owner_id,
)
# Batch resolve for viewer
result = await eps.batch_get_effective_access(
db_session, tenant_id, viewer_id, "contact", [contact_id, contact2.id]
)
assert result[contact_id] == "read", f"Expected 'read' for shared contact, got '{result[contact_id]}'"
assert result[contact2.id] == "owner", f"Expected 'owner' for own contact, got '{result[contact2.id]}'"
async def test_tenant_owned_contact_visible_to_all(self, db_session: AsyncSession):
"""Tenant-owned contact (owner_id IS NULL) is visible as 'read' to all tenant users."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Ensure owner_id is NULL
contact = await db_session.get(Contact, contact_id)
contact.owner_id = None
await db_session.commit()
access = await eps.get_effective_access(
db_session, tenant_id, viewer_id, "contact", contact_id
)
assert access == "read", f"Expected 'read' for tenant-owned contact, got '{access}'"
async def test_group_permission_propagation(self, db_session: AsyncSession):
"""User inherits permissions from group membership."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Set owner_id on the contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Create a group and add viewer to it
from app.models.group import Group, UserGroup
group = Group(tenant_id=tenant_id, name="Viewers")
db_session.add(group)
await db_session.flush()
ug = UserGroup(tenant_id=tenant_id, user_id=viewer_id, group_id=group.id)
db_session.add(ug)
await db_session.commit()
# Grant permission to the group
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="group",
principal_id=str(group.id),
permission_level="write",
created_by=owner_id,
)
access = await eps.get_effective_access(
db_session, tenant_id, viewer_id, "contact", contact_id
)
assert access == "write", f"Expected 'write' via group, got '{access}'"
async def test_highest_permission_wins(self, db_session: AsyncSession):
"""When multiple permissions exist, the highest level wins."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Set owner_id on the contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Grant read + write permissions (write should win)
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(viewer_id),
permission_level="read",
created_by=owner_id,
)
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(viewer_id),
permission_level="write",
created_by=owner_id,
)
access = await eps.get_effective_access(
db_session, tenant_id, viewer_id, "contact", contact_id
)
assert access == "write", f"Expected 'write' (highest wins), got '{access}'"
async def test_visible_ids_returns_owned_and_shared(self, db_session: AsyncSession):
"""get_visible_ids returns owned + shared + tenant-owned entities."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Create a second contact owned by viewer
contact2 = Contact(
tenant_id=tenant_id,
type="company",
name="Viewer Owned",
displayname="Viewer Owned",
owner_id=viewer_id,
created_by=viewer_id,
updated_by=viewer_id,
)
db_session.add(contact2)
await db_session.commit()
# Set owner on first contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Grant read to viewer on first contact
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(viewer_id),
permission_level="read",
created_by=owner_id,
)
visible, access_map = await eps.get_visible_ids(
db_session, tenant_id, viewer_id, "contact"
)
assert contact_id in visible, "Shared contact should be visible"
assert contact2.id in visible, "Owned contact should be visible"
assert access_map[contact_id] == "read"
assert access_map[contact2.id] == "owner"
async def test_check_entity_access_enforces_required_level(self, db_session: AsyncSession):
"""check_entity_access returns True/False based on required level."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Set owner_id on the contact
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Grant read to viewer
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(viewer_id),
permission_level="read",
created_by=owner_id,
)
# Should have read access
assert await eps.check_entity_access(
db_session, tenant_id, viewer_id, "contact", contact_id, "read"
) is True
# Should NOT have write access
assert await eps.check_entity_access(
db_session, tenant_id, viewer_id, "contact", contact_id, "write"
) is False
async def test_cleanup_expired_permissions(self, db_session: AsyncSession):
"""cleanup_expired_permissions removes expired entries."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Create expired permission
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(viewer_id),
permission_level="read",
expires_at=datetime.now(UTC) - timedelta(hours=1),
created_by=owner_id,
)
count = await eps.cleanup_expired_permissions(db_session)
assert count >= 1, "Expected at least 1 expired permission cleaned up"
# Verify it's gone
access = await eps.get_effective_access(
db_session, tenant_id, viewer_id, "contact", contact_id
)
assert access == "none", "Permission should be gone after cleanup"
}
+289
View File
@@ -0,0 +1,289 @@
"""Performance tests for permission system — visibility filter, caching.
Covers:
- Visibility filter performance with 1000 mock contacts
- Cache hit vs miss performance comparison
"""
from __future__ import annotations
import time
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.contact import Contact
from app.services import entity_permission_service as eps
from tests.conftest import seed_tenant_and_users
@pytest.mark.asyncio
class TestPermissionPerformance:
"""Performance tests for permission system."""
async def test_visibility_filter_performance(self, db_session: AsyncSession):
"""get_visible_ids with 1000 contacts completes in reasonable time."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
# Create 1000 contacts owned by the user
contacts = []
for i in range(1000):
contacts.append(Contact(
tenant_id=tenant_id,
type="person",
first_name=f"Perf{i}",
last_name=f"Test{i}",
owner_id=user_id,
created_by=user_id,
updated_by=user_id,
))
db_session.add_all(contacts)
await db_session.commit()
# Measure time for get_visible_ids
start = time.perf_counter()
visible, access_map = await eps.get_visible_ids(
db_session, tenant_id, user_id, "contact"
)
elapsed = time.perf_counter() - start
# Should complete in under 2 seconds for 1000 contacts
assert elapsed < 2.0, f"get_visible_ids took {elapsed:.3f}s (expected <2.0s)"
assert len(visible) >= 1000, f"Expected >=1000 visible, got {len(visible)}"
# All should be at 'owner' level
for eid, level in access_map.items():
if eid in [c.id for c in contacts]:
assert level == "owner", f"Expected 'owner', got '{level}'"
async def test_cache_hit_vs_miss(self, db_session: AsyncSession, redis_client):
"""Cache hit is significantly faster than cache miss."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
# Create 100 contacts owned by the user
contacts = []
for i in range(100):
contacts.append(Contact(
tenant_id=tenant_id,
type="person",
first_name=f"Cache{i}",
last_name=f"Test{i}",
owner_id=user_id,
created_by=user_id,
updated_by=user_id,
))
db_session.add_all(contacts)
await db_session.commit()
# First call — cache miss (populates cache)
miss_start = time.perf_counter()
visible_miss, access_map_miss = await eps.get_cached_visible_ids(
db_session, redis_client, tenant_id, user_id, "contact"
)
miss_elapsed = time.perf_counter() - miss_start
assert len(visible_miss) >= 100, f"Expected >=100 visible, got {len(visible_miss)}"
# Second call — cache hit
hit_start = time.perf_counter()
visible_hit, access_map_hit = await eps.get_cached_visible_ids(
db_session, redis_client, tenant_id, user_id, "contact"
)
hit_elapsed = time.perf_counter() - hit_start
# Cache hit should be faster (at least 2x speedup)
assert hit_elapsed < miss_elapsed, f"Cache hit {hit_elapsed:.4f}s should be faster than miss {miss_elapsed:.4f}s"
assert len(visible_hit) == len(visible_miss), "Cache hit should return same results"
async def test_batch_resolution_performance(self, db_session: AsyncSession):
"""batch_get_effective_access with 100 entities completes quickly."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
# Create 100 contacts
contacts = []
for i in range(100):
contacts.append(Contact(
tenant_id=tenant_id,
type="person",
first_name=f"Batch{i}",
last_name=f"Test{i}",
owner_id=user_id,
created_by=user_id,
updated_by=user_id,
))
db_session.add_all(contacts)
await db_session.commit()
entity_ids = [c.id for c in contacts]
start = time.perf_counter()
result = await eps.batch_get_effective_access(
db_session, tenant_id, user_id, "contact", entity_ids
)
elapsed = time.perf_counter() - start
assert elapsed < 1.0, f"batch_get_effective_access took {elapsed:.3f}s (expected <1.0s)"
assert len(result) == 100, f"Expected 100 results, got {len(result)}"
# All should be 'owner'
for eid, level in result.items():
assert level == "owner", f"Expected 'owner', got '{level}'"
async def test_cache_invalidation_performance(self, db_session: AsyncSession, redis_client):
"""Cache invalidation for all entity types completes quickly."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
# Populate cache for multiple entity types
for entity_type in ["contact", "dms_file", "mailbox", "calendar_event", "task"]:
await eps.get_cached_visible_ids(
db_session, redis_client, tenant_id, user_id, entity_type
)
# Measure invalidation time
start = time.perf_counter()
await eps.invalidate_all_user_entity_cache(redis_client, tenant_id, user_id)
elapsed = time.perf_counter() - start
assert elapsed < 1.0, f"Cache invalidation took {elapsed:.3f}s (expected <1.0s)"
# Verify cache is cleared
for entity_type in ["contact", "dms_file", "mailbox", "calendar_event", "task"]:
cache_key = f"ep_vis:{user_id}:{tenant_id}:{entity_type}"
cached = await redis_client.get(cache_key)
assert cached is None, f"Cache for {entity_type} should be cleared"
async def test_get_effective_access_performance(self, db_session: AsyncSession):
"""get_effective_access for a single entity completes quickly."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
contact_id = seed["company_a"].id
# Set owner_id
contact = await db_session.get(Contact, contact_id)
contact.owner_id = user_id
await db_session.commit()
# Measure single access check
start = time.perf_counter()
for _ in range(100):
access = await eps.get_effective_access(
db_session, tenant_id, user_id, "contact", contact_id
)
elapsed = time.perf_counter() - start
avg_ms = (elapsed / 100) * 1000
assert avg_ms < 50, f"Average get_effective_access took {avg_ms:.2f}ms (expected <50ms)"
assert access == "owner"
async def test_check_entity_access_performance(self, db_session: AsyncSession):
"""check_entity_access for a single entity completes quickly."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
contact_id = seed["company_a"].id
# Set owner_id
contact = await db_session.get(Contact, contact_id)
contact.owner_id = user_id
await db_session.commit()
# Measure single access check
start = time.perf_counter()
for _ in range(100):
result = await eps.check_entity_access(
db_session, tenant_id, user_id, "contact", contact_id, "read"
)
elapsed = time.perf_counter() - start
avg_ms = (elapsed / 100) * 1000
assert avg_ms < 50, f"Average check_entity_access took {avg_ms:.2f}ms (expected <50ms)"
assert result is True
async def test_permission_creation_performance(self, db_session: AsyncSession):
"""Creating permissions in bulk completes quickly."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
contact_id = seed["company_a"].id
# Set owner_id
contact = await db_session.get(Contact, contact_id)
contact.owner_id = owner_id
await db_session.commit()
# Measure bulk permission creation
start = time.perf_counter()
for level in ["read", "write", "admin", "delete"]:
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(viewer_id),
permission_level=level,
created_by=owner_id,
)
elapsed = time.perf_counter() - start
avg_ms = (elapsed / 4) * 1000
assert avg_ms < 200, f"Average permission creation took {avg_ms:.2f}ms (expected <200ms)"
async def test_visible_ids_with_mixed_ownership(self, db_session: AsyncSession):
"""get_visible_ids with mixed ownership (owned + tenant + shared) performs well."""
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
owner_id = seed["admin_a"].id
viewer_id = seed["viewer_a"].id
# Create 500 owned, 300 tenant-owned, 200 shared contacts
contacts = []
for i in range(500):
contacts.append(Contact(
tenant_id=tenant_id, type="person",
first_name=f"Owned{i}", last_name=f"Test{i}",
owner_id=owner_id, created_by=owner_id, updated_by=owner_id,
))
for i in range(300):
contacts.append(Contact(
tenant_id=tenant_id, type="person",
first_name=f"Tenant{i}", last_name=f"Test{i}",
owner_id=None, created_by=owner_id, updated_by=owner_id,
))
db_session.add_all(contacts)
await db_session.commit()
# Grant viewer access to 200 contacts
shared_ids = [c.id for c in contacts[:200]]
for cid in shared_ids:
await eps.create_permission(
db_session, tenant_id=tenant_id,
entity_type="contact", entity_id=str(cid),
principal_type="user", principal_id=str(viewer_id),
permission_level="read", created_by=owner_id,
)
# Measure performance for viewer
start = time.perf_counter()
visible, access_map = await eps.get_visible_ids(
db_session, tenant_id, viewer_id, "contact"
)
elapsed = time.perf_counter() - start
assert elapsed < 3.0, f"get_visible_ids with mixed ownership took {elapsed:.3f}s (expected <3.0s)"
# Viewer should see: 200 shared + 300 tenant-owned = 500
assert len(visible) >= 500, f"Expected >=500 visible, got {len(visible)}"
}