sprint2: visibility filter + contact service access checks + contacts route integration
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user