sprint2: visibility filter + contact service access checks + contacts route integration

This commit is contained in:
Agent Zero
2026-07-29 01:38:18 +02:00
parent ea1c1d5113
commit 479ee04834
4 changed files with 362 additions and 68 deletions
+64 -9
View File
@@ -145,13 +145,26 @@ async def list_contacts(
sort_by: str = "displayname",
sort_order: str = "asc",
resolved_perms: dict | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> dict:
"""List contacts with pagination, FTS search, type/folder filter, sorting."""
"""List contacts with pagination, FTS search, type/folder filter, sorting.
Applies row-level visibility filter based on ownership and entity_permissions.
"""
from app.core.visibility import apply_visibility_filter
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
# Apply row-level visibility filter
if user_id and not is_system_admin:
base = await apply_visibility_filter(
db, base, "contact", Contact, user_id, tenant_id, is_system_admin
)
if contact_type:
base = base.where(Contact.type == contact_type)
@@ -191,8 +204,9 @@ async def list_contacts(
}
async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> dict:
"""Get a single contact with contact_persons."""
async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str,
user_id: uuid.UUID | None = None, is_system_admin: bool = False) -> dict:
"""Get a single contact with contact_persons. Checks row-level access."""
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
@@ -206,6 +220,16 @@ async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
# Check row-level access
if user_id and not is_system_admin:
from app.core.visibility import check_single_entity_access
has_access = await check_single_entity_access(
db, "contact", contact.id, user_id, tenant_id, "read", is_system_admin
)
if not has_access:
raise PermissionError("No access to this contact")
return _serialize_contact_detail(contact)
@@ -222,6 +246,7 @@ async def create_contact(
tenant_id=tenant_id,
created_by=user_id,
updated_by=user_id,
owner_id=user_id,
**{k: v for k, v in data.items() if hasattr(Contact, k)},
)
db.add(contact)
@@ -277,9 +302,10 @@ async def create_contact(
async def update_contact(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict,
is_system_admin: bool = False,
) -> dict:
"""Update a contact."""
"""Update a contact. Checks row-level write access."""
# Expire all cached objects to ensure fresh data with selectinload
db.expire_all()
q = (
@@ -296,6 +322,15 @@ async def update_contact(
if not contact:
raise ValueError("Contact not found")
# Check row-level write access
if not is_system_admin:
from app.core.visibility import check_single_entity_access
has_access = await check_single_entity_access(
db, "contact", contact.id, user_id, tenant_id, "write", is_system_admin
)
if not has_access:
raise PermissionError("No write access to this contact")
# Hook: contact.before_update
await do_action("contact.before_update", data, db=db, tenant_id=tenant_id, user_id=user_id, contact_id=contact_id)
@@ -356,9 +391,10 @@ async def update_contact(
async def delete_contact(
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, user_id: uuid.UUID | None = None
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> None:
"""Soft-delete a contact."""
"""Soft-delete a contact. Checks row-level admin access."""
q = select(Contact).where(
Contact.id == uuid.UUID(contact_id),
Contact.tenant_id == tenant_id,
@@ -369,6 +405,15 @@ async def delete_contact(
if not contact:
raise ValueError("Contact not found")
# Check row-level admin access
if not is_system_admin:
from app.core.visibility import check_single_entity_access
has_access = await check_single_entity_access(
db, "contact", contact.id, user_id, tenant_id, "admin", is_system_admin
)
if not has_access:
raise PermissionError("No admin access to this contact")
# Hook: contact.before_delete
await do_action("contact.before_delete", db=db, tenant_id=tenant_id, contact_id=contact_id, user_id=user_id)
@@ -491,9 +536,12 @@ async def delete_contact_person(
async def export_contacts_csv(
db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None
db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None,
user_id: uuid.UUID | None = None, is_system_admin: bool = False,
) -> str:
"""Export contacts as CSV string."""
"""Export contacts as CSV string. Only exports visible contacts."""
from app.core.visibility import apply_visibility_filter
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
@@ -502,6 +550,13 @@ async def export_contacts_csv(
base = base.where(Contact.type == contact_type)
if search:
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
# Apply visibility filter
if user_id and not is_system_admin:
base = await apply_visibility_filter(
db, base, "contact", Contact, user_id, tenant_id, is_system_admin
)
base = base.order_by(Contact.displayname)
result = await db.execute(base)