From 9fc84b7905c5998e88267b04484f5aa966f57d6c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 29 Jul 2026 01:52:47 +0200 Subject: [PATCH] sprint2: 8 services + 8 routes visibility filter + BaseSearchProvider + owned_mixin on models --- app/models/address.py | 2 +- app/models/attachment.py | 2 +- app/models/bank_account.py | 2 +- app/models/saved_filter.py | 2 +- app/models/saved_view.py | 2 +- app/models/sequence.py | 2 +- app/models/webhook.py | 2 +- app/models/workflow.py | 2 +- .../builtins/unified_search/base_provider.py | 98 +++++++++++ app/routes/addresses.py | 28 ++- app/routes/attachments.py | 35 +++- app/routes/bank_accounts.py | 25 ++- app/routes/saved_filters.py | 110 ++++++------ app/routes/saved_views.py | 152 ++++++++-------- app/routes/sequences.py | 29 +++- app/routes/webhooks.py | 63 +++++-- app/routes/workflows.py | 140 ++++++++++----- app/services/address_service.py | 24 +++ app/services/attachment_service.py | 24 +++ app/services/bank_account_service.py | 24 +++ app/services/saved_filter_service.py | 162 ++++++++++++++++++ app/services/saved_view_service.py | 162 ++++++++++++++++++ app/services/sequence_service.py | 24 +++ app/services/webhook_service.py | 39 ++++- app/services/workflow_service.py | 33 ++++ 25 files changed, 977 insertions(+), 211 deletions(-) create mode 100644 app/plugins/builtins/unified_search/base_provider.py create mode 100644 app/services/saved_filter_service.py create mode 100644 app/services/saved_view_service.py diff --git a/app/models/address.py b/app/models/address.py index c9ca1fe..4b90f73 100644 --- a/app/models/address.py +++ b/app/models/address.py @@ -12,7 +12,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class Address(Base, TenantMixin): +class Address(Base, TenantMixin, OwnedMixin): """Polymorphic address entity — multiple addresses per contact. entity_type: 'contact' diff --git a/app/models/attachment.py b/app/models/attachment.py index 783510b..0b302aa 100644 --- a/app/models/attachment.py +++ b/app/models/attachment.py @@ -13,7 +13,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class Attachment(Base, TenantMixin): +class Attachment(Base, TenantMixin, OwnedMixin): """Attachment entity — links files to companies, contacts, invoices, etc.""" __tablename__ = "attachments" diff --git a/app/models/bank_account.py b/app/models/bank_account.py index 3acd468..4415b5e 100644 --- a/app/models/bank_account.py +++ b/app/models/bank_account.py @@ -12,7 +12,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class BankAccount(Base, TenantMixin): +class BankAccount(Base, TenantMixin, OwnedMixin): """Bank account entity — multiple accounts per tenant. is_default: one default bank account per tenant. diff --git a/app/models/saved_filter.py b/app/models/saved_filter.py index 7fb4783..76416f3 100644 --- a/app/models/saved_filter.py +++ b/app/models/saved_filter.py @@ -14,7 +14,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class SavedFilter(Base, TenantMixin): +class SavedFilter(Base, TenantMixin, OwnedMixin): """Saved filter — reusable filter criteria for list views (contacts, mail, calendar, DMS).""" __tablename__ = "saved_filters" diff --git a/app/models/saved_view.py b/app/models/saved_view.py index 8b22fa6..2ee81ca 100644 --- a/app/models/saved_view.py +++ b/app/models/saved_view.py @@ -14,7 +14,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class SavedView(Base, TenantMixin): +class SavedView(Base, TenantMixin, OwnedMixin): """Saved view — reusable view configuration (filter+sort+group+viewMode+folder) for list views.""" __tablename__ = "saved_views" diff --git a/app/models/sequence.py b/app/models/sequence.py index 38f033e..080dc64 100644 --- a/app/models/sequence.py +++ b/app/models/sequence.py @@ -13,7 +13,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class Sequence(Base, TenantMixin): +class Sequence(Base, TenantMixin, OwnedMixin): """Sequence entity for document numbering (e.g. invoice RE-2026-0001).""" __tablename__ = "sequences" diff --git a/app/models/webhook.py b/app/models/webhook.py index 728053f..9670d88 100644 --- a/app/models/webhook.py +++ b/app/models/webhook.py @@ -12,7 +12,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class Webhook(Base, TenantMixin): +class Webhook(Base, TenantMixin, OwnedMixin): """Outgoing webhook subscription. Each webhook defines a target URL, a list of events to subscribe to, diff --git a/app/models/workflow.py b/app/models/workflow.py index cb30b67..3750b02 100644 --- a/app/models/workflow.py +++ b/app/models/workflow.py @@ -15,7 +15,7 @@ from app.core.db import Base, TenantMixin from app.models.owned_mixin import OwnedMixin -class Workflow(Base, TenantMixin): +class Workflow(Base, TenantMixin, OwnedMixin): """Workflow definition — a template of steps stored as JSONB, tenant-scoped.""" __tablename__ = "workflows" diff --git a/app/plugins/builtins/unified_search/base_provider.py b/app/plugins/builtins/unified_search/base_provider.py new file mode 100644 index 0000000..ff530c1 --- /dev/null +++ b/app/plugins/builtins/unified_search/base_provider.py @@ -0,0 +1,98 @@ +"""Base search provider with visibility filter — all search providers should inherit from this. + +This ensures that search results respect row-level security automatically. +Plugins that provide search functionality should use this base class. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +class BaseSearchProvider: + """Base class for search providers with built-in visibility filtering. + + Subclasses must implement _search_fts_filtered() and _search_vector_filtered(). + The base class handles loading visible IDs and passing them to the subclass. + """ + + entity_type: str = "" # Override in subclass + + async def search_fts( + self, + db: AsyncSession, + tsquery: str, + tenant_id: uuid.UUID, + limit: int, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, + ) -> list[dict[str, Any]]: + """Full-text search with visibility filter.""" + if is_system_admin or not user_id: + return await self._search_fts_filtered(db, tsquery, tenant_id, limit, None) + + visible_ids = await self._get_visible_ids(db, tenant_id, user_id) + if not visible_ids: + return [] + return await self._search_fts_filtered(db, tsquery, tenant_id, limit, visible_ids) + + async def search_vector( + self, + db: AsyncSession, + embedding: list[float], + tenant_id: uuid.UUID, + limit: int, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, + ) -> list[dict[str, Any]]: + """Semantic vector search with visibility filter.""" + if is_system_admin or not user_id: + return await self._search_vector_filtered(db, embedding, tenant_id, limit, None) + + visible_ids = await self._get_visible_ids(db, tenant_id, user_id) + if not visible_ids: + return [] + return await self._search_vector_filtered(db, embedding, tenant_id, limit, visible_ids) + + async def _get_visible_ids( + self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID + ) -> set[uuid.UUID] | None: + """Get visible entity IDs for the user.""" + from app.services.entity_permission_service import get_visible_ids + visible, _ = await get_visible_ids(db, tenant_id, user_id, self.entity_type) + return visible if visible else None + + async def _search_fts_filtered( + self, + db: AsyncSession, + tsquery: str, + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Override: FTS search filtered by visible_ids. If visible_ids is None, no filter.""" + raise NotImplementedError + + async def _search_vector_filtered( + self, + db: AsyncSession, + embedding: list[float], + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Override: Vector search filtered by visible_ids. If visible_ids is None, no filter.""" + raise NotImplementedError + + async def get_embedding_text( + self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID + ) -> str: + """Override: Get text for embedding generation.""" + raise NotImplementedError diff --git a/app/routes/addresses.py b/app/routes/addresses.py index ffd7884..f30ad85 100644 --- a/app/routes/addresses.py +++ b/app/routes/addresses.py @@ -24,12 +24,18 @@ async def list_addresses( ): """List all addresses for a given entity (company or contact).""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + try: eid = uuid.UUID(entity_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid entity_id", "code": "invalid_id"}) from None - return await address_service.list_addresses(db, tenant_id, entity_type, eid) + try: + return await address_service.list_addresses(db, tenant_id, entity_type, eid, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.post("", status_code=status.HTTP_201_CREATED) @@ -41,11 +47,13 @@ async def create_address( """Create a new address for a company or contact.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) data = body.model_dump() try: - return await address_service.create_address(db, tenant_id, user_id, data) + return await address_service.create_address(db, tenant_id, user_id, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except ValueError as exc: raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_value"}) from exc @@ -60,7 +68,7 @@ async def update_address( """Update an address.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) try: aid = uuid.UUID(address_id) @@ -68,7 +76,10 @@ async def update_address( raise HTTPException(400, detail={"detail": "Invalid address_id", "code": "invalid_id"}) from None data = body.model_dump(exclude_unset=True) - result = await address_service.update_address(db, tenant_id, user_id, aid, data) + try: + result = await address_service.update_address(db, tenant_id, user_id, aid, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException(404, detail={"detail": "Address not found", "code": "not_found"}) return result @@ -83,13 +94,16 @@ async def delete_address( """Soft-delete an address.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) try: aid = uuid.UUID(address_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid address_id", "code": "invalid_id"}) from None - deleted = await address_service.delete_address(db, tenant_id, user_id, aid) + try: + deleted = await address_service.delete_address(db, tenant_id, user_id, aid, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if not deleted: raise HTTPException(404, detail={"detail": "Address not found", "code": "not_found"}) diff --git a/app/routes/attachments.py b/app/routes/attachments.py index c805126..e0a3107 100644 --- a/app/routes/attachments.py +++ b/app/routes/attachments.py @@ -27,7 +27,7 @@ async def upload_attachment( """Upload a file attachment. Multipart form data.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) try: eid = uuid.UUID(entity_id) @@ -37,10 +37,14 @@ async def upload_attachment( file_content = await file.read() mime_type = file.content_type or "application/octet-stream" - return await attachment_service.save_attachment( - db, tenant_id, user_id, entity_type, eid, - file.filename or "unknown", file_content, mime_type, - ) + try: + return await attachment_service.save_attachment( + db, tenant_id, user_id, entity_type, eid, + file.filename or "unknown", file_content, mime_type, + is_system_admin=is_admin, + ) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.get("") @@ -52,13 +56,18 @@ async def list_attachments( ): """List attachments for a specific entity.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) try: eid = uuid.UUID(entity_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid entity_id", "code": "invalid_id"}) from None - return await attachment_service.list_attachments(db, tenant_id, entity_type, eid) + try: + return await attachment_service.list_attachments(db, tenant_id, entity_type, eid, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.get("/{attachment_id}") @@ -69,13 +78,18 @@ async def download_attachment( ): """Download an attachment file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) try: aid = uuid.UUID(attachment_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid attachment_id", "code": "invalid_id"}) from None - data = await attachment_service.get_attachment(db, tenant_id, aid) + try: + data = await attachment_service.get_attachment(db, tenant_id, aid, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if data is None: raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"}) @@ -99,13 +113,16 @@ async def delete_attachment( """Delete an attachment.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) try: aid = uuid.UUID(attachment_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid attachment_id", "code": "invalid_id"}) from None - deleted = await attachment_service.delete_attachment(db, tenant_id, user_id, aid) + try: + deleted = await attachment_service.delete_attachment(db, tenant_id, user_id, aid, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if not deleted: raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"}) diff --git a/app/routes/bank_accounts.py b/app/routes/bank_accounts.py index b3abe23..ae1d341 100644 --- a/app/routes/bank_accounts.py +++ b/app/routes/bank_accounts.py @@ -22,7 +22,13 @@ async def list_bank_accounts( ): """List all bank accounts for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - return await bank_account_service.list_bank_accounts(db, tenant_id) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + + try: + return await bank_account_service.list_bank_accounts(db, tenant_id, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.post("", status_code=status.HTTP_201_CREATED) @@ -34,10 +40,13 @@ async def create_bank_account( """Create a new bank account.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) data = body.model_dump() try: - return await bank_account_service.create_bank_account(db, tenant_id, user_id, data) + return await bank_account_service.create_bank_account(db, tenant_id, user_id, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except ValueError as exc: raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_value"}) from exc @@ -52,6 +61,7 @@ async def update_bank_account( """Update a bank account.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) try: aid = uuid.UUID(account_id) @@ -59,7 +69,10 @@ async def update_bank_account( raise HTTPException(400, detail={"detail": "Invalid account_id", "code": "invalid_id"}) from None data = body.model_dump(exclude_unset=True) - result = await bank_account_service.update_bank_account(db, tenant_id, user_id, aid, data) + try: + result = await bank_account_service.update_bank_account(db, tenant_id, user_id, aid, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException(404, detail={"detail": "Bank account not found", "code": "not_found"}) return result @@ -74,12 +87,16 @@ async def delete_bank_account( """Soft-delete a bank account.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) try: aid = uuid.UUID(account_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid account_id", "code": "invalid_id"}) from None - deleted = await bank_account_service.delete_bank_account(db, tenant_id, user_id, aid) + try: + deleted = await bank_account_service.delete_bank_account(db, tenant_id, user_id, aid, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if not deleted: raise HTTPException(404, detail={"detail": "Bank account not found", "code": "not_found"}) diff --git a/app/routes/saved_filters.py b/app/routes/saved_filters.py index 9809786..7b6d357 100644 --- a/app/routes/saved_filters.py +++ b/app/routes/saved_filters.py @@ -53,19 +53,23 @@ async def list_saved_filters( """List saved filters for the current user, optionally filtered by entity_type.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - query = select(SavedFilter).where( - SavedFilter.tenant_id == tenant_id, - SavedFilter.user_id == user_id, - SavedFilter.deleted_at.is_(None), - ) - if entity_type: - query = query.where(SavedFilter.entity_type == entity_type) - query = query.order_by(SavedFilter.name) + try: + query = select(SavedFilter).where( + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.deleted_at.is_(None), + ) + if entity_type: + query = query.where(SavedFilter.entity_type == entity_type) + query = query.order_by(SavedFilter.name) - result = await db.execute(query) - filters = result.scalars().all() - return [_filter_to_dict(f) for f in filters] + result = await db.execute(query) + filters = result.scalars().all() + return [_filter_to_dict(f) for f in filters] + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))]) @@ -77,30 +81,34 @@ async def create_saved_filter( """Create a new saved filter for the current user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - # Check uniqueness within user+entity - existing = await db.execute( - select(SavedFilter).where( - SavedFilter.tenant_id == tenant_id, - SavedFilter.user_id == user_id, - SavedFilter.entity_type == body.entity_type, - SavedFilter.name == body.name, - SavedFilter.deleted_at.is_(None), + try: + # Check uniqueness within user+entity + existing = await db.execute( + select(SavedFilter).where( + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.entity_type == body.entity_type, + SavedFilter.name == body.name, + SavedFilter.deleted_at.is_(None), + ) ) - ) - if existing.scalar_one_or_none() is not None: - raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"}) + if existing.scalar_one_or_none() is not None: + raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"}) - saved = SavedFilter( - tenant_id=tenant_id, - user_id=user_id, - name=body.name, - entity_type=body.entity_type, - filter_criteria=body.filter_criteria, - ) - db.add(saved) - await db.flush() - return _filter_to_dict(saved) + saved = SavedFilter( + tenant_id=tenant_id, + user_id=user_id, + name=body.name, + entity_type=body.entity_type, + filter_criteria=body.filter_criteria, + ) + db.add(saved) + await db.flush() + return _filter_to_dict(saved) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))]) @@ -112,25 +120,29 @@ async def delete_saved_filter( """Delete a saved filter (soft-delete).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) try: - fid = uuid.UUID(filter_id) - except (ValueError, TypeError): - raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None + try: + fid = uuid.UUID(filter_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None - result = await db.execute( - select(SavedFilter).where( - SavedFilter.id == fid, - SavedFilter.tenant_id == tenant_id, - SavedFilter.user_id == user_id, - SavedFilter.deleted_at.is_(None), + result = await db.execute( + select(SavedFilter).where( + SavedFilter.id == fid, + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.deleted_at.is_(None), + ) ) - ) - saved = result.scalar_one_or_none() - if saved is None: - raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"}) + saved = result.scalar_one_or_none() + if saved is None: + raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"}) - from datetime import datetime, timezone - saved.deleted_at = datetime.now(timezone.utc) - await db.flush() - return Response(status_code=status.HTTP_204_NO_CONTENT) + from datetime import datetime, timezone + saved.deleted_at = datetime.now(timezone.utc) + await db.flush() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e diff --git a/app/routes/saved_views.py b/app/routes/saved_views.py index 6adc241..574a20e 100644 --- a/app/routes/saved_views.py +++ b/app/routes/saved_views.py @@ -53,19 +53,23 @@ async def list_saved_views( """List saved views for the current user, optionally filtered by entity_type.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - query = select(SavedView).where( - SavedView.tenant_id == tenant_id, - SavedView.user_id == user_id, - SavedView.deleted_at.is_(None), - ) - if entity_type: - query = query.where(SavedView.entity_type == entity_type) - query = query.order_by(SavedView.name) + try: + query = select(SavedView).where( + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.deleted_at.is_(None), + ) + if entity_type: + query = query.where(SavedView.entity_type == entity_type) + query = query.order_by(SavedView.name) - result = await db.execute(query) - views = result.scalars().all() - return [_view_to_dict(v) for v in views] + result = await db.execute(query) + views = result.scalars().all() + return [_view_to_dict(v) for v in views] + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))]) @@ -77,30 +81,34 @@ async def create_saved_view( """Create a new saved view for the current user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - # Check uniqueness within user+entity - existing = await db.execute( - select(SavedView).where( - SavedView.tenant_id == tenant_id, - SavedView.user_id == user_id, - SavedView.entity_type == body.entity_type, - SavedView.name == body.name, - SavedView.deleted_at.is_(None), + try: + # Check uniqueness within user+entity + existing = await db.execute( + select(SavedView).where( + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.entity_type == body.entity_type, + SavedView.name == body.name, + SavedView.deleted_at.is_(None), + ) ) - ) - if existing.scalar_one_or_none() is not None: - raise HTTPException(409, detail={"detail": "View name already exists", "code": "duplicate"}) + if existing.scalar_one_or_none() is not None: + raise HTTPException(409, detail={"detail": "View name already exists", "code": "duplicate"}) - saved = SavedView( - tenant_id=tenant_id, - user_id=user_id, - name=body.name, - entity_type=body.entity_type, - view_config=body.view_config, - ) - db.add(saved) - await db.flush() - return _view_to_dict(saved) + saved = SavedView( + tenant_id=tenant_id, + user_id=user_id, + name=body.name, + entity_type=body.entity_type, + view_config=body.view_config, + ) + db.add(saved) + await db.flush() + return _view_to_dict(saved) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.put("/{view_id}", dependencies=[Depends(require_permission("contacts:read"))]) @@ -113,30 +121,34 @@ async def update_saved_view( """Update a saved view.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) try: - vid = uuid.UUID(view_id) - except (ValueError, TypeError): - raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None + try: + vid = uuid.UUID(view_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None - result = await db.execute( - select(SavedView).where( - SavedView.id == vid, - SavedView.tenant_id == tenant_id, - SavedView.user_id == user_id, - SavedView.deleted_at.is_(None), + result = await db.execute( + select(SavedView).where( + SavedView.id == vid, + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.deleted_at.is_(None), + ) ) - ) - saved = result.scalar_one_or_none() - if saved is None: - raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"}) + saved = result.scalar_one_or_none() + if saved is None: + raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"}) - if body.name is not None: - saved.name = body.name - if body.view_config is not None: - saved.view_config = body.view_config - await db.flush() - return _view_to_dict(saved) + if body.name is not None: + saved.name = body.name + if body.view_config is not None: + saved.view_config = body.view_config + await db.flush() + return _view_to_dict(saved) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.delete("/{view_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))]) @@ -148,25 +160,29 @@ async def delete_saved_view( """Delete a saved view (soft-delete).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) try: - vid = uuid.UUID(view_id) - except (ValueError, TypeError): - raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None + try: + vid = uuid.UUID(view_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None - result = await db.execute( - select(SavedView).where( - SavedView.id == vid, - SavedView.tenant_id == tenant_id, - SavedView.user_id == user_id, - SavedView.deleted_at.is_(None), + result = await db.execute( + select(SavedView).where( + SavedView.id == vid, + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.deleted_at.is_(None), + ) ) - ) - saved = result.scalar_one_or_none() - if saved is None: - raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"}) + saved = result.scalar_one_or_none() + if saved is None: + raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"}) - from datetime import datetime, timezone - saved.deleted_at = datetime.now(timezone.utc) - await db.flush() - return Response(status_code=status.HTTP_204_NO_CONTENT) + from datetime import datetime, timezone + saved.deleted_at = datetime.now(timezone.utc) + await db.flush() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e diff --git a/app/routes/sequences.py b/app/routes/sequences.py index 02bb4d5..855eba9 100644 --- a/app/routes/sequences.py +++ b/app/routes/sequences.py @@ -22,9 +22,13 @@ async def list_sequences( ): """List all sequences for the current tenant. Admin only.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - - return await sequence_service.list_sequences(db, tenant_id) + try: + return await sequence_service.list_sequences(db, tenant_id, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.post("", status_code=status.HTTP_201_CREATED) @@ -36,10 +40,13 @@ async def create_sequence( """Create a new sequence. Admin only.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) data = body.model_dump() - return await sequence_service.create_sequence(db, tenant_id, user_id, data) + try: + return await sequence_service.create_sequence(db, tenant_id, user_id, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.patch("/{sequence_id}") @@ -52,7 +59,7 @@ async def update_sequence( """Update a sequence (name, prefix, padding only). Admin only.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) try: sid = uuid.UUID(sequence_id) @@ -60,7 +67,10 @@ async def update_sequence( raise HTTPException(400, detail={"detail": "Invalid sequence_id", "code": "invalid_id"}) from None data = body.model_dump(exclude_unset=True) - result = await sequence_service.update_sequence(db, tenant_id, user_id, sid, data) + try: + result = await sequence_service.update_sequence(db, tenant_id, user_id, sid, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException(404, detail={"detail": "Sequence not found", "code": "not_found"}) return result @@ -75,13 +85,16 @@ async def delete_sequence( """Delete a sequence (soft-delete). Admin only.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) try: sid = uuid.UUID(sequence_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid sequence_id", "code": "invalid_id"}) from None - deleted = await sequence_service.delete_sequence(db, tenant_id, user_id, sid) + try: + deleted = await sequence_service.delete_sequence(db, tenant_id, user_id, sid, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if not deleted: raise HTTPException(404, detail={"detail": "Sequence not found", "code": "not_found"}) diff --git a/app/routes/webhooks.py b/app/routes/webhooks.py index 067dd2c..a9a5121 100644 --- a/app/routes/webhooks.py +++ b/app/routes/webhooks.py @@ -31,8 +31,14 @@ async def list_webhooks( ): """List all webhooks for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - webhooks = await webhook_service.list_webhooks(db, tenant_id, event=event) - return webhooks + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + + try: + webhooks = await webhook_service.list_webhooks(db, tenant_id, event=event, user_id=user_id, is_system_admin=is_admin) + return webhooks + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.post( @@ -49,10 +55,15 @@ async def create_webhook( """Create a new webhook subscription.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - webhook = await webhook_service.create_webhook( - db, tenant_id, user_id, body.model_dump() - ) - return webhook + is_admin = current_user.get("is_system_admin", False) + + try: + webhook = await webhook_service.create_webhook( + db, tenant_id, user_id, body.model_dump(), is_system_admin=is_admin + ) + return webhook + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.get( @@ -67,12 +78,18 @@ async def get_webhook( ): """Get a single webhook by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + try: wh_id = uuid.UUID(webhook_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None - webhook = await webhook_service.get_webhook(db, tenant_id, wh_id) + try: + webhook = await webhook_service.get_webhook(db, tenant_id, wh_id, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if webhook is None: raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) return webhook @@ -92,6 +109,8 @@ async def update_webhook( """Update an existing webhook subscription.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + try: wh_id = uuid.UUID(webhook_id) except (ValueError, TypeError): @@ -101,9 +120,12 @@ async def update_webhook( if not update_data: raise HTTPException(400, detail={"detail": "No fields to update", "code": "no_updates"}) - webhook = await webhook_service.update_webhook( - db, tenant_id, wh_id, update_data, user_id=user_id - ) + try: + webhook = await webhook_service.update_webhook( + db, tenant_id, wh_id, update_data, user_id=user_id, is_system_admin=is_admin + ) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if webhook is None: raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) return webhook @@ -121,12 +143,18 @@ async def delete_webhook( ): """Delete a webhook subscription.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + try: wh_id = uuid.UUID(webhook_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None - deleted = await webhook_service.delete_webhook(db, tenant_id, wh_id) + try: + deleted = await webhook_service.delete_webhook(db, tenant_id, wh_id, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if not deleted: raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) return None @@ -143,12 +171,18 @@ async def test_webhook( ): """Send a test payload to a webhook to verify connectivity.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + try: wh_id = uuid.UUID(webhook_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None - webhook = await webhook_service.get_webhook(db, tenant_id, wh_id) + try: + webhook = await webhook_service.get_webhook(db, tenant_id, wh_id, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if webhook is None: raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) @@ -157,5 +191,8 @@ async def test_webhook( "message": "This is a test webhook from LeoCRM", "webhook_id": str(webhook.id), } - result = await webhook_service.send_webhook(webhook, "webhook.test", test_payload) + try: + result = await webhook_service.send_webhook(webhook, "webhook.test", test_payload, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e return result diff --git a/app/routes/workflows.py b/app/routes/workflows.py index f9bffaa..17dc575 100644 --- a/app/routes/workflows.py +++ b/app/routes/workflows.py @@ -28,13 +28,21 @@ async def list_workflows( ): """List workflows with pagination.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - return await workflow_service.list_workflows( - db, - tenant_id, - page=page, - page_size=page_size, - is_active=is_active, - ) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + + try: + return await workflow_service.list_workflows( + db, + tenant_id, + page=page, + page_size=page_size, + is_active=is_active, + user_id=user_id, + is_system_admin=is_admin, + ) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.post("", status_code=status.HTTP_201_CREATED) @@ -46,10 +54,13 @@ async def create_workflow( """Create a new workflow definition. Requires write permission.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) data = body.model_dump() - return await workflow_service.create_workflow(db, tenant_id, user_id, data) + try: + return await workflow_service.create_workflow(db, tenant_id, user_id, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.get("/instances") @@ -62,13 +73,21 @@ async def list_instances( ): """List workflow instances with optional status filter.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - return await workflow_service.list_instances( - db, - tenant_id, - page=page, - page_size=page_size, - status_filter=status, - ) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + + try: + return await workflow_service.list_instances( + db, + tenant_id, + page=page, + page_size=page_size, + status_filter=status, + user_id=user_id, + is_system_admin=is_admin, + ) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e @router.get("/{workflow_id}") @@ -79,7 +98,13 @@ async def get_workflow( ): """Get a single workflow by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - result = await workflow_service.get_workflow(db, tenant_id, workflow_id) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + + try: + result = await workflow_service.get_workflow(db, tenant_id, workflow_id, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -98,10 +123,13 @@ async def update_workflow( """Update a workflow definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - + is_admin = current_user.get("is_system_admin", False) data = body.model_dump(exclude_unset=True) - result = await workflow_service.update_workflow(db, tenant_id, user_id, workflow_id, data) + try: + result = await workflow_service.update_workflow(db, tenant_id, user_id, workflow_id, data, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -119,9 +147,12 @@ async def delete_workflow( """Delete a workflow definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - - deleted = await workflow_service.delete_workflow(db, tenant_id, user_id, workflow_id) + try: + deleted = await workflow_service.delete_workflow(db, tenant_id, user_id, workflow_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -143,15 +174,20 @@ async def create_instance( """Create a new workflow instance.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - result = await workflow_service.create_instance( - db, - tenant_id, - user_id, - workflow_id=workflow_id, - context=body.context, - timeout_hours=body.timeout_hours, - ) + try: + result = await workflow_service.create_instance( + db, + tenant_id, + user_id, + workflow_id=workflow_id, + context=body.context, + timeout_hours=body.timeout_hours, + is_system_admin=is_admin, + ) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -168,7 +204,13 @@ async def get_instance( ): """Get a workflow instance with step history.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - result = await workflow_service.get_instance(db, tenant_id, instance_id) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + + try: + result = await workflow_service.get_instance(db, tenant_id, instance_id, user_id=user_id, is_system_admin=is_admin) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -191,15 +233,20 @@ async def advance_instance( """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - result = await workflow_service.advance_instance( - db, - tenant_id, - user_id, - instance_id=instance_id, - decision=body.decision, - comment=body.comment, - ) + try: + result = await workflow_service.advance_instance( + db, + tenant_id, + user_id, + instance_id=instance_id, + decision=body.decision, + comment=body.comment, + is_system_admin=is_admin, + ) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -222,13 +269,18 @@ async def cancel_instance( """Cancel a workflow instance. Returns 200 with cancelled instance.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) - result = await workflow_service.cancel_instance( - db, - tenant_id, - user_id, - instance_id=instance_id, - ) + try: + result = await workflow_service.cancel_instance( + db, + tenant_id, + user_id, + instance_id=instance_id, + is_system_admin=is_admin, + ) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/app/services/address_service.py b/app/services/address_service.py index f0a2bcc..08f883e 100644 --- a/app/services/address_service.py +++ b/app/services/address_service.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.models.address import Address +from app.core.visibility import apply_visibility_filter, check_single_entity_access VALID_ENTITY_TYPES = {"contact"} @@ -42,6 +43,8 @@ async def list_addresses( tenant_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any]: """List all addresses for a given entity within a tenant.""" q = ( @@ -54,6 +57,10 @@ async def list_addresses( ) .order_by(Address.is_default.desc(), Address.label.asc()) ) + if user_id and not is_system_admin: + q = await apply_visibility_filter( + db, q, "address", Address, user_id, tenant_id, is_system_admin + ) result = await db.execute(q) addresses = result.scalars().all() return { @@ -104,6 +111,7 @@ async def create_address( state=data.get("state"), country=data.get("country"), is_default=data.get("is_default", False), + owner_id=user_id, ) db.add(address) await db.flush() @@ -121,6 +129,7 @@ async def update_address( user_id: uuid.UUID, address_id: uuid.UUID, data: dict[str, Any], + is_system_admin: bool = False, ) -> dict[str, Any] | None: """Update an address. If setting is_default=True, unset other defaults of same type first.""" q = select(Address).where( @@ -133,6 +142,13 @@ async def update_address( if address is None: return None + if not is_system_admin: + has_access = await check_single_entity_access( + db, "address", address.id, user_id, tenant_id, "write", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + if data.get("is_default") is True and not address.is_default: await db.execute( update(Address) @@ -166,6 +182,7 @@ async def delete_address( tenant_id: uuid.UUID, user_id: uuid.UUID, address_id: uuid.UUID, + is_system_admin: bool = False, ) -> bool: """Soft-delete an address.""" q = select(Address).where( @@ -178,6 +195,13 @@ async def delete_address( if address is None: return False + if not is_system_admin: + has_access = await check_single_entity_access( + db, "address", address.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + address.deleted_at = datetime.now(UTC) await db.flush() await log_audit( diff --git a/app/services/attachment_service.py b/app/services/attachment_service.py index b642fed..f6a8d0f 100644 --- a/app/services/attachment_service.py +++ b/app/services/attachment_service.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.storage import get_storage_backend from app.models.attachment import Attachment +from app.core.visibility import apply_visibility_filter, check_single_entity_access def _attachment_to_dict(a: Attachment) -> dict[str, Any]: @@ -68,6 +69,7 @@ async def save_attachment( mime_type=mime_type, file_size=file_size, uploaded_by=user_id, + owner_id=user_id, ) db.add(attachment) await db.flush() @@ -84,6 +86,8 @@ async def list_attachments( tenant_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any]: """List attachments for a specific entity.""" q = select(Attachment).where( @@ -92,6 +96,10 @@ async def list_attachments( Attachment.entity_id == entity_id, Attachment.deleted_at.is_(None), ).order_by(Attachment.created_at.desc()) + if user_id and not is_system_admin: + q = await apply_visibility_filter( + db, q, "attachment", Attachment, user_id, tenant_id, is_system_admin + ) result = await db.execute(q) attachments = result.scalars().all() return { @@ -104,6 +112,8 @@ async def get_attachment( db: AsyncSession, tenant_id: uuid.UUID, attachment_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any] | None: """Get a single attachment by ID.""" q = select(Attachment).where( @@ -115,6 +125,12 @@ async def get_attachment( attachment = result.scalar_one_or_none() if attachment is None: return None + if user_id and not is_system_admin: + has_access = await check_single_entity_access( + db, "attachment", attachment.id, user_id, tenant_id, "read", is_system_admin + ) + if not has_access: + raise PermissionError("No access") return _attachment_to_dict(attachment) @@ -123,6 +139,7 @@ async def delete_attachment( tenant_id: uuid.UUID, user_id: uuid.UUID, attachment_id: uuid.UUID, + is_system_admin: bool = False, ) -> bool: """Soft-delete an attachment (keeps file on disk for audit trail).""" q = select(Attachment).where( @@ -135,6 +152,13 @@ async def delete_attachment( if attachment is None: return False + if not is_system_admin: + has_access = await check_single_entity_access( + db, "attachment", attachment.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + attachment.deleted_at = datetime.now(UTC) await db.flush() await log_audit( diff --git a/app/services/bank_account_service.py b/app/services/bank_account_service.py index dfc1878..14c17d3 100644 --- a/app/services/bank_account_service.py +++ b/app/services/bank_account_service.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.models.bank_account import BankAccount +from app.core.visibility import apply_visibility_filter, check_single_entity_access def _account_to_dict(a: BankAccount) -> dict[str, Any]: @@ -31,6 +32,8 @@ def _account_to_dict(a: BankAccount) -> dict[str, Any]: async def list_bank_accounts( db: AsyncSession, tenant_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any]: """List all bank accounts for a tenant.""" q = ( @@ -41,6 +44,10 @@ async def list_bank_accounts( ) .order_by(BankAccount.is_default.desc(), BankAccount.bank_name.asc()) ) + if user_id and not is_system_admin: + q = await apply_visibility_filter( + db, q, "bank_account", BankAccount, user_id, tenant_id, is_system_admin + ) result = await db.execute(q) accounts = result.scalars().all() return { @@ -75,6 +82,7 @@ async def create_bank_account( account_holder=data.get("account_holder"), default_tax=data.get("default_tax"), is_default=data.get("is_default", False), + owner_id=user_id, ) db.add(account) await db.flush() @@ -92,6 +100,7 @@ async def update_bank_account( user_id: uuid.UUID, account_id: uuid.UUID, data: dict[str, Any], + is_system_admin: bool = False, ) -> dict[str, Any] | None: """Update a bank account. If setting is_default=True, unset other defaults first.""" q = select(BankAccount).where( @@ -104,6 +113,13 @@ async def update_bank_account( if account is None: return None + if not is_system_admin: + has_access = await check_single_entity_access( + db, "bank_account", account.id, user_id, tenant_id, "write", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + if data.get("is_default") is True and not account.is_default: await db.execute( update(BankAccount) @@ -134,6 +150,7 @@ async def delete_bank_account( tenant_id: uuid.UUID, user_id: uuid.UUID, account_id: uuid.UUID, + is_system_admin: bool = False, ) -> bool: """Soft-delete a bank account.""" q = select(BankAccount).where( @@ -146,6 +163,13 @@ async def delete_bank_account( if account is None: return False + if not is_system_admin: + has_access = await check_single_entity_access( + db, "bank_account", account.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + account.deleted_at = datetime.now(UTC) await db.flush() await log_audit( diff --git a/app/services/saved_filter_service.py b/app/services/saved_filter_service.py new file mode 100644 index 0000000..1cfa83a --- /dev/null +++ b/app/services/saved_filter_service.py @@ -0,0 +1,162 @@ +"""SavedFilter service — CRUD with tenant isolation and visibility filter.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.visibility import apply_visibility_filter, check_single_entity_access +from app.models.saved_filter import SavedFilter + + +def _filter_to_dict(f: SavedFilter) -> dict[str, Any]: + """Serialize a SavedFilter ORM object to dict.""" + return { + "id": str(f.id), + "name": f.name, + "entity_type": f.entity_type, + "filter_criteria": f.filter_criteria, + "user_id": str(f.user_id), + "created_at": f.created_at.isoformat() if f.created_at else None, + "updated_at": f.updated_at.isoformat() if f.updated_at else None, + } + + +async def list_saved_filters( + db: AsyncSession, + tenant_id: uuid.UUID, + entity_type: str | None = None, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, +) -> list[dict[str, Any]]: + """List saved filters for a tenant, optionally filtered by entity_type.""" + q = select(SavedFilter).where( + SavedFilter.tenant_id == tenant_id, + SavedFilter.deleted_at.is_(None), + ) + if entity_type: + q = q.where(SavedFilter.entity_type == entity_type) + if user_id and not is_system_admin: + q = await apply_visibility_filter( + db, q, "saved_filter", SavedFilter, user_id, tenant_id, is_system_admin + ) + q = q.order_by(SavedFilter.name) + result = await db.execute(q) + return [_filter_to_dict(f) for f in result.scalars().all()] + + +async def get_saved_filter( + db: AsyncSession, + tenant_id: uuid.UUID, + filter_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, +) -> dict[str, Any] | None: + """Get a single saved filter by ID.""" + q = select(SavedFilter).where( + SavedFilter.id == filter_id, + SavedFilter.tenant_id == tenant_id, + SavedFilter.deleted_at.is_(None), + ) + result = await db.execute(q) + saved = result.scalar_one_or_none() + if saved is None: + return None + if user_id and not is_system_admin: + has_access = await check_single_entity_access( + db, "saved_filter", saved.id, user_id, tenant_id, "read", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + return _filter_to_dict(saved) + + +async def create_saved_filter( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + data: dict[str, Any], +) -> dict[str, Any]: + """Create a new saved filter.""" + saved = SavedFilter( + tenant_id=tenant_id, + user_id=user_id, + name=data["name"], + entity_type=data["entity_type"], + filter_criteria=data.get("filter_criteria", {}), + owner_id=user_id, + ) + db.add(saved) + await db.flush() + await db.refresh(saved) + return _filter_to_dict(saved) + + +async def update_saved_filter( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + filter_id: uuid.UUID, + data: dict[str, Any], + is_system_admin: bool = False, +) -> dict[str, Any] | None: + """Update a saved filter.""" + q = select(SavedFilter).where( + SavedFilter.id == filter_id, + SavedFilter.tenant_id == tenant_id, + SavedFilter.deleted_at.is_(None), + ) + result = await db.execute(q) + saved = result.scalar_one_or_none() + if saved is None: + return None + + if not is_system_admin: + has_access = await check_single_entity_access( + db, "saved_filter", saved.id, user_id, tenant_id, "write", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + + if "name" in data and data["name"] is not None: + saved.name = data["name"] + if "filter_criteria" in data and data["filter_criteria"] is not None: + saved.filter_criteria = data["filter_criteria"] + + await db.flush() + await db.refresh(saved) + return _filter_to_dict(saved) + + +async def delete_saved_filter( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + filter_id: uuid.UUID, + is_system_admin: bool = False, +) -> bool: + """Soft-delete a saved filter.""" + q = select(SavedFilter).where( + SavedFilter.id == filter_id, + SavedFilter.tenant_id == tenant_id, + SavedFilter.deleted_at.is_(None), + ) + result = await db.execute(q) + saved = result.scalar_one_or_none() + if saved is None: + return False + + if not is_system_admin: + has_access = await check_single_entity_access( + db, "saved_filter", saved.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + + saved.deleted_at = datetime.now(UTC) + await db.flush() + return True diff --git a/app/services/saved_view_service.py b/app/services/saved_view_service.py new file mode 100644 index 0000000..eb48044 --- /dev/null +++ b/app/services/saved_view_service.py @@ -0,0 +1,162 @@ +"""SavedView service — CRUD with tenant isolation and visibility filter.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.visibility import apply_visibility_filter, check_single_entity_access +from app.models.saved_view import SavedView + + +def _view_to_dict(v: SavedView) -> dict[str, Any]: + """Serialize a SavedView ORM object to dict.""" + return { + "id": str(v.id), + "name": v.name, + "entity_type": v.entity_type, + "view_config": v.view_config, + "user_id": str(v.user_id), + "created_at": v.created_at.isoformat() if v.created_at else None, + "updated_at": v.updated_at.isoformat() if v.updated_at else None, + } + + +async def list_saved_views( + db: AsyncSession, + tenant_id: uuid.UUID, + entity_type: str | None = None, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, +) -> list[dict[str, Any]]: + """List saved views for a tenant, optionally filtered by entity_type.""" + q = select(SavedView).where( + SavedView.tenant_id == tenant_id, + SavedView.deleted_at.is_(None), + ) + if entity_type: + q = q.where(SavedView.entity_type == entity_type) + if user_id and not is_system_admin: + q = await apply_visibility_filter( + db, q, "saved_view", SavedView, user_id, tenant_id, is_system_admin + ) + q = q.order_by(SavedView.name) + result = await db.execute(q) + return [_view_to_dict(v) for v in result.scalars().all()] + + +async def get_saved_view( + db: AsyncSession, + tenant_id: uuid.UUID, + view_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, +) -> dict[str, Any] | None: + """Get a single saved view by ID.""" + q = select(SavedView).where( + SavedView.id == view_id, + SavedView.tenant_id == tenant_id, + SavedView.deleted_at.is_(None), + ) + result = await db.execute(q) + saved = result.scalar_one_or_none() + if saved is None: + return None + if user_id and not is_system_admin: + has_access = await check_single_entity_access( + db, "saved_view", saved.id, user_id, tenant_id, "read", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + return _view_to_dict(saved) + + +async def create_saved_view( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + data: dict[str, Any], +) -> dict[str, Any]: + """Create a new saved view.""" + saved = SavedView( + tenant_id=tenant_id, + user_id=user_id, + name=data["name"], + entity_type=data["entity_type"], + view_config=data.get("view_config", {}), + owner_id=user_id, + ) + db.add(saved) + await db.flush() + await db.refresh(saved) + return _view_to_dict(saved) + + +async def update_saved_view( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + view_id: uuid.UUID, + data: dict[str, Any], + is_system_admin: bool = False, +) -> dict[str, Any] | None: + """Update a saved view.""" + q = select(SavedView).where( + SavedView.id == view_id, + SavedView.tenant_id == tenant_id, + SavedView.deleted_at.is_(None), + ) + result = await db.execute(q) + saved = result.scalar_one_or_none() + if saved is None: + return None + + if not is_system_admin: + has_access = await check_single_entity_access( + db, "saved_view", saved.id, user_id, tenant_id, "write", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + + if "name" in data and data["name"] is not None: + saved.name = data["name"] + if "view_config" in data and data["view_config"] is not None: + saved.view_config = data["view_config"] + + await db.flush() + await db.refresh(saved) + return _view_to_dict(saved) + + +async def delete_saved_view( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + view_id: uuid.UUID, + is_system_admin: bool = False, +) -> bool: + """Soft-delete a saved view.""" + q = select(SavedView).where( + SavedView.id == view_id, + SavedView.tenant_id == tenant_id, + SavedView.deleted_at.is_(None), + ) + result = await db.execute(q) + saved = result.scalar_one_or_none() + if saved is None: + return False + + if not is_system_admin: + has_access = await check_single_entity_access( + db, "saved_view", saved.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + + saved.deleted_at = datetime.now(UTC) + await db.flush() + return True diff --git a/app/services/sequence_service.py b/app/services/sequence_service.py index 1b07eeb..0927a38 100644 --- a/app/services/sequence_service.py +++ b/app/services/sequence_service.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.models.sequence import Sequence +from app.core.visibility import apply_visibility_filter, check_single_entity_access def _sequence_to_dict(s: Sequence) -> dict[str, Any]: @@ -73,6 +74,7 @@ async def create_sequence( prefix=data.get("prefix", ""), next_number=1, padding=data.get("padding", 4), + owner_id=user_id, ) db.add(sequence) await db.flush() @@ -87,12 +89,18 @@ async def create_sequence( async def list_sequences( db: AsyncSession, tenant_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any]: """List all sequences for a tenant.""" q = select(Sequence).where( Sequence.tenant_id == tenant_id, Sequence.deleted_at.is_(None), ).order_by(Sequence.name.asc()) + if user_id and not is_system_admin: + q = await apply_visibility_filter( + db, q, "sequence", Sequence, user_id, tenant_id, is_system_admin + ) result = await db.execute(q) sequences = result.scalars().all() return { @@ -107,6 +115,7 @@ async def update_sequence( user_id: uuid.UUID, sequence_id: uuid.UUID, data: dict[str, Any], + is_system_admin: bool = False, ) -> dict[str, Any] | None: """Update a sequence (name, prefix, padding only — NOT next_number).""" q = select(Sequence).where( @@ -119,6 +128,13 @@ async def update_sequence( if sequence is None: return None + if not is_system_admin: + has_access = await check_single_entity_access( + db, "sequence", sequence.id, user_id, tenant_id, "write", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + changes: dict[str, Any] = {} for field in ("name", "prefix", "padding"): if field in data and data[field] is not None: @@ -137,6 +153,7 @@ async def delete_sequence( tenant_id: uuid.UUID, user_id: uuid.UUID, sequence_id: uuid.UUID, + is_system_admin: bool = False, ) -> bool: """Soft-delete a sequence.""" q = select(Sequence).where( @@ -149,6 +166,13 @@ async def delete_sequence( if sequence is None: return False + if not is_system_admin: + has_access = await check_single_entity_access( + db, "sequence", sequence.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + sequence.deleted_at = datetime.now(UTC) await db.flush() await log_audit( diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py index 264c558..b9a2f27 100644 --- a/app/services/webhook_service.py +++ b/app/services/webhook_service.py @@ -17,6 +17,7 @@ from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession from app.models.webhook import Webhook +from app.core.visibility import apply_visibility_filter, check_single_entity_access logger = logging.getLogger(__name__) @@ -64,6 +65,8 @@ async def list_webhooks( db: AsyncSession, tenant_id: uuid.UUID, event: str | None = None, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> list[Webhook]: """List webhooks for a tenant, optionally filtered by event.""" stmt = select(Webhook).where( @@ -71,6 +74,10 @@ async def list_webhooks( ) if event: stmt = stmt.where(Webhook.events.any(event)) + if user_id and not is_system_admin: + stmt = await apply_visibility_filter( + db, stmt, "webhook", Webhook, user_id, tenant_id, is_system_admin + ) stmt = stmt.order_by(Webhook.created_at.desc()) result = await db.execute(stmt) return list(result.scalars().all()) @@ -80,6 +87,8 @@ async def get_webhook( db: AsyncSession, tenant_id: uuid.UUID, webhook_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> Webhook | None: """Get a single webhook by ID.""" stmt = select(Webhook).where( @@ -87,7 +96,16 @@ async def get_webhook( Webhook.tenant_id == tenant_id, ) result = await db.execute(stmt) - return result.scalar_one_or_none() + webhook = result.scalar_one_or_none() + if webhook is None: + return None + if user_id and not is_system_admin: + has_access = await check_single_entity_access( + db, "webhook", webhook.id, user_id, tenant_id, "read", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + return webhook async def create_webhook( @@ -107,6 +125,7 @@ async def create_webhook( timeout_seconds=data.get("timeout_seconds", 30), created_by=user_id, updated_by=user_id, + owner_id=user_id, ) db.add(webhook) await db.flush() @@ -120,12 +139,20 @@ async def update_webhook( webhook_id: uuid.UUID, data: dict[str, Any], user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> Webhook | None: """Update an existing webhook subscription.""" webhook = await get_webhook(db, tenant_id, webhook_id) if webhook is None: return None + if not is_system_admin: + has_access = await check_single_entity_access( + db, "webhook", webhook.id, user_id, tenant_id, "write", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + update_fields = ["url", "events", "secret", "is_active", "retry_count", "timeout_seconds"] for field in update_fields: if field in data: @@ -143,6 +170,8 @@ async def delete_webhook( db: AsyncSession, tenant_id: uuid.UUID, webhook_id: uuid.UUID, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> bool: """Delete a webhook subscription.""" stmt = select(Webhook).where( @@ -153,6 +182,14 @@ async def delete_webhook( webhook = result.scalar_one_or_none() if webhook is None: return False + + if not is_system_admin: + has_access = await check_single_entity_access( + db, "webhook", webhook.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + await db.delete(webhook) await db.flush() return True diff --git a/app/services/workflow_service.py b/app/services/workflow_service.py index 6c24e4e..0befe05 100644 --- a/app/services/workflow_service.py +++ b/app/services/workflow_service.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.models.notification import Notification from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory +from app.core.visibility import apply_visibility_filter, check_single_entity_access def _safe_iso(dt) -> str | None: @@ -135,6 +136,7 @@ async def create_workflow( steps=steps_json, is_active=data.get("is_active", True), created_by=user_id, + owner_id=user_id, ) db.add(workflow) await db.flush() @@ -158,6 +160,8 @@ async def list_workflows( page: int = 1, page_size: int = 20, is_active: bool | None = None, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any]: """List workflows with pagination.""" page = max(1, page) @@ -167,6 +171,11 @@ async def list_workflows( if is_active is not None: base = base.where(Workflow.is_active == is_active) + if user_id and not is_system_admin: + base = await apply_visibility_filter( + db, base, "workflow", Workflow, user_id, tenant_id, is_system_admin + ) + count_q = select(func.count()).select_from(base.subquery()) total_result = await db.execute(count_q) total = total_result.scalar_one() @@ -188,6 +197,8 @@ async def get_workflow( db: AsyncSession, tenant_id: uuid.UUID, workflow_id: str, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any] | None: """Get a single workflow by ID.""" wf_uuid = uuid.UUID(workflow_id) @@ -200,6 +211,12 @@ async def get_workflow( workflow = result.scalar_one_or_none() if workflow is None: return None + if user_id and not is_system_admin: + has_access = await check_single_entity_access( + db, "workflow", workflow.id, user_id, tenant_id, "read", is_system_admin + ) + if not has_access: + raise PermissionError("No access") return _workflow_to_dict(workflow) @@ -209,6 +226,7 @@ async def update_workflow( user_id: uuid.UUID, workflow_id: str, data: dict[str, Any], + is_system_admin: bool = False, ) -> dict[str, Any] | None: """Update a workflow definition.""" wf_uuid = uuid.UUID(workflow_id) @@ -222,6 +240,13 @@ async def update_workflow( if workflow is None: return None + if not is_system_admin: + has_access = await check_single_entity_access( + db, "workflow", workflow.id, user_id, tenant_id, "write", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + if "name" in data: workflow.name = data["name"] if "description" in data: @@ -254,6 +279,7 @@ async def delete_workflow( tenant_id: uuid.UUID, user_id: uuid.UUID, workflow_id: str, + is_system_admin: bool = False, ) -> bool: """Delete a workflow definition.""" wf_uuid = uuid.UUID(workflow_id) @@ -267,6 +293,13 @@ async def delete_workflow( if workflow is None: return False + if not is_system_admin: + has_access = await check_single_entity_access( + db, "workflow", workflow.id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_access: + raise PermissionError("No access") + await db.delete(workflow) await db.flush()