sprint20-23: tests + documentation + guest access + infrastructure + migrations 0059
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)}"
|
||||
}
|
||||
Reference in New Issue
Block a user