diff --git a/alembic/versions/0053_mail_owner_id.py b/alembic/versions/0053_mail_owner_id.py new file mode 100644 index 0000000..5988a38 --- /dev/null +++ b/alembic/versions/0053_mail_owner_id.py @@ -0,0 +1,41 @@ +"""Add owner_id to mail_accounts for row-level permissions. + +Revision ID: 0053 +Revises: 0052 +Create Date: 2026-07-29 + +This migration adds owner_id to mail_accounts so that the universal +visibility/permission system (apply_visibility_filter, check_single_entity_access) +can be used for mail accounts. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "0053" +down_revision = "0052" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "mail_accounts", + sa.Column( + "owner_id", + UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index( + "ix_mail_accounts_owner", + "mail_accounts", + ["owner_id"], + ) + + +def downgrade(): + op.drop_index("ix_mail_accounts_owner", table_name="mail_accounts") + op.drop_column("mail_accounts", "owner_id") diff --git a/app/core/notifications.py b/app/core/notifications.py index d0a1de6..05e29d8 100644 --- a/app/core/notifications.py +++ b/app/core/notifications.py @@ -23,6 +23,8 @@ async def create_notification( type: str, title: str, body: str | None = None, + entity_type: str | None = None, + entity_id: uuid.UUID | None = None, ) -> Notification | None: """Create a new notification for a user if they have not disabled this type. @@ -60,6 +62,8 @@ async def create_notification( type=type, title=title, body=body, + entity_type=entity_type, + entity_id=entity_id, ) db.add(notif) await db.flush() @@ -73,6 +77,8 @@ async def create_notification( 'user_id': str(user_id), 'type': type, 'title': title, + 'entity_type': entity_type, + 'entity_id': str(entity_id) if entity_id else None, }) return notif @@ -173,6 +179,8 @@ def _notification_to_dict(n: Notification) -> dict[str, Any]: "type": n.type, "title": n.title, "body": n.body, + "entity_type": n.entity_type, + "entity_id": str(n.entity_id) if n.entity_id else None, "read_at": n.read_at.isoformat() if n.read_at else None, "created_at": n.created_at.isoformat() if n.created_at else None, } diff --git a/app/models/notification.py b/app/models/notification.py index 8ab6670..cbe0b4d 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -33,6 +33,10 @@ class Notification(Base, TenantMixin): id: Mapped[uuid.UUID] = mapped_column( PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) + entity_type: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + entity_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), nullable=True + ) user_id: Mapped[uuid.UUID] = mapped_column( PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) diff --git a/app/plugins/builtins/mail/models.py b/app/plugins/builtins/mail/models.py index 7871f82..7f4ed40 100644 --- a/app/plugins/builtins/mail/models.py +++ b/app/plugins/builtins/mail/models.py @@ -20,11 +20,12 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin # --- Mail Accounts (F-MAIL-14, F-MAIL-18) --- -class MailAccount(Base, TenantMixin): +class MailAccount(Base, TenantMixin, OwnedMixin): """Mail account configuration with encrypted IMAP/SMTP credentials.""" __tablename__ = "mail_accounts" diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index 1f46212..4f2374e 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.core.storage import get_storage_backend +from app.core.visibility import apply_visibility_filter, check_single_entity_access from app.deps import require_permission from app.plugins.builtins.mail.models import ( ContactPgpKey, @@ -139,7 +140,8 @@ async def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]: async def _get_account( - db: AsyncSession, account_id: uuid.UUID, tenant_id: uuid.UUID, user_id: uuid.UUID + db: AsyncSession, account_id: uuid.UUID, tenant_id: uuid.UUID, user_id: uuid.UUID, + is_system_admin: bool = False, ) -> MailAccount: account = ( await db.execute( @@ -150,21 +152,12 @@ async def _get_account( ).scalar_one_or_none() if not account: raise HTTPException(404, detail={"detail": "Mail account not found", "code": "not_found"}) - if account.user_id == user_id: - return account - delegate = ( - await db.execute( - select(MailAccountDelegate).where( - and_( - MailAccountDelegate.account_id == account_id, - MailAccountDelegate.delegate_user_id == user_id, - ) - ) - ) - ).scalar_one_or_none() - if delegate: - return account - raise HTTPException(403, detail={"detail": "No access to this account", "code": "forbidden"}) + has_access = await check_single_entity_access( + db, "mail_account", account_id, user_id, tenant_id, "read", is_system_admin + ) + if not has_access: + raise HTTPException(403, detail={"detail": "No access to this account", "code": "forbidden"}) + return account async def _check_send_permission( @@ -229,20 +222,12 @@ async def list_accounts( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - accounts = ( - ( - await db.execute( - select(MailAccount).where( - and_( - MailAccount.tenant_id == tenant_id, - or_(MailAccount.user_id == user_id, MailAccount.is_shared), - ) - ) - ) - ) - .scalars() - .all() + is_system_admin = current_user.get("is_system_admin", False) + query = select(MailAccount).where(MailAccount.tenant_id == tenant_id) + query = await apply_visibility_filter( + db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin ) + accounts = (await db.execute(query)).scalars().all() return [account_to_response(a) for a in accounts] @@ -254,6 +239,7 @@ async def create_account( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) account = await create_mail_account( db, tenant_id=tenant_id, user_id=user_id, data=data.model_dump() ) @@ -288,8 +274,9 @@ async def update_account( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) await update_mail_account(db, account, data.model_dump(exclude_unset=True)) return account_to_response(account) @@ -302,9 +289,13 @@ async def delete_account( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) - if account.user_id != user_id: + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) + has_admin = await check_single_entity_access( + db, "mail_account", acc_id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_admin: raise HTTPException(403, detail={"detail": "Only owner can delete", "code": "forbidden"}) await db.delete(account) @@ -318,9 +309,13 @@ async def assign_shared_users( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) - if account.user_id != user_id: + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) + has_admin = await check_single_entity_access( + db, "mail_account", acc_id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_admin: raise HTTPException( 403, detail={"detail": "Only owner can assign users", "code": "forbidden"} ) @@ -360,9 +355,13 @@ async def create_delegate( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) - if account.user_id != user_id: + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) + has_admin = await check_single_entity_access( + db, "mail_account", acc_id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_admin: raise HTTPException( 403, detail={"detail": "Only owner can create delegates", "code": "forbidden"} ) @@ -401,9 +400,13 @@ async def create_send_permission( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) - if account.user_id != user_id: + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) + has_admin = await check_single_entity_access( + db, "mail_account", acc_id, user_id, tenant_id, "admin", is_system_admin + ) + if not has_admin: raise HTTPException( 403, detail={"detail": "Only owner can grant send permissions", "code": "forbidden"} ) @@ -434,8 +437,9 @@ async def test_connection( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) password = await get_account_password(account) try: import aioimaplib @@ -470,8 +474,9 @@ async def trigger_imap_sync( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - await _get_account(db, acc_id, tenant_id, user_id) + await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) result = await imap_sync_account(db, acc_id, tenant_id) return result @@ -487,8 +492,9 @@ async def list_folders( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - await _get_account(db, acc_id, tenant_id, user_id) + await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) folders = ( ( await db.execute( @@ -511,8 +517,9 @@ async def create_folder( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(data.account_id, "account_id") - await _get_account(db, acc_id, tenant_id, user_id) + await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) parent_id = _parse_uuid(data.parent_id, "parent_id") if data.parent_id else None folder = MailFolder( tenant_id=tenant_id, @@ -544,6 +551,7 @@ async def update_folder( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) f_id = _parse_uuid(folder_id, "folder_id") folder = ( await db.execute( @@ -552,7 +560,7 @@ async def update_folder( ).scalar_one_or_none() if not folder: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) - await _get_account(db, folder.account_id, tenant_id, user_id) + await _get_account(db, folder.account_id, tenant_id, user_id, is_system_admin=is_system_admin) if data.name: folder.name = data.name await db.flush() @@ -567,6 +575,7 @@ async def delete_folder( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) f_id = _parse_uuid(folder_id, "folder_id") folder = ( await db.execute( @@ -575,7 +584,7 @@ async def delete_folder( ).scalar_one_or_none() if not folder: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) - account = await _get_account(db, folder.account_id, tenant_id, user_id) + account = await _get_account(db, folder.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "full") try: await imap_delete_folder(db, f_id, tenant_id) @@ -605,6 +614,7 @@ async def empty_folder( """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) f_id = _parse_uuid(folder_id, "folder_id") folder = ( await db.execute( @@ -613,7 +623,7 @@ async def empty_folder( ).scalar_one_or_none() if not folder: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) - account = await _get_account(db, folder.account_id, tenant_id, user_id) + account = await _get_account(db, folder.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "delete") # Check if this folder IS the Trash folder @@ -705,6 +715,7 @@ async def sync_folder( """Sync a single folder from IMAP server immediately.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) f_id = _parse_uuid(folder_id, "folder_id") folder = ( await db.execute( @@ -713,7 +724,7 @@ async def sync_folder( ).scalar_one_or_none() if not folder: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) - account = await _get_account(db, folder.account_id, tenant_id, user_id) + account = await _get_account(db, folder.account_id, tenant_id, user_id, is_system_admin=is_system_admin) result = await mail_services.imap_sync_folder(db, f_id, tenant_id) await db.flush() return result @@ -735,6 +746,7 @@ async def upload_attachment( """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) # Read file content and check size content = await file.read() @@ -777,8 +789,9 @@ async def send_mail( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(data.account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_send_permission(db, account, user_id) signature = None if data.signature_id: @@ -899,6 +912,7 @@ async def create_template( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) template = MailTemplate( tenant_id=tenant_id, user_id=user_id, @@ -958,6 +972,7 @@ async def create_signature( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(data.account_id, "account_id") if data.account_id else None sig = MailSignature( tenant_id=tenant_id, @@ -1054,8 +1069,9 @@ async def configure_vacation( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(data.account_id, "account_id") - await _get_account(db, acc_id, tenant_id, user_id) + await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) vacation_data = { "is_enabled": data.is_enabled, "subject": data.subject, @@ -1083,8 +1099,9 @@ async def test_vacation_dedup( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(account_id, "account_id") - await _get_account(db, acc_id, tenant_id, user_id) + await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) should_send_1 = await should_send_vacation_reply(db, acc_id, sender, tenant_id) if should_send_1: await log_vacation_sent(db, acc_id, sender, tenant_id) @@ -1107,6 +1124,7 @@ async def import_pgp_key( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) key_id, public_key_armored = import_pgp_private_key(data.private_key_armored, data.passphrase) encrypted_private = encrypt_password(data.private_key_armored) pgp_key = PgpKey( @@ -1127,6 +1145,7 @@ async def list_pgp_keys( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) keys = ( ( await db.execute( @@ -1178,6 +1197,7 @@ async def create_label( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) label = MailLabel(tenant_id=tenant_id, name=data.name, color=data.color, user_id=user_id) db.add(label) await db.flush() @@ -1265,13 +1285,14 @@ async def reply_mail( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") original = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not original: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, original.account_id, tenant_id, user_id) + account = await _get_account(db, original.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "read") await _check_send_permission(db, account, user_id) signature = None @@ -1311,13 +1332,14 @@ async def forward_mail_endpoint( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") original = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not original: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, original.account_id, tenant_id, user_id) + account = await _get_account(db, original.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "read") await _check_send_permission(db, account, user_id) signature = None @@ -1358,13 +1380,14 @@ async def update_flags( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "write") if data.is_seen is not None: mail.is_seen = data.is_seen @@ -1397,6 +1420,7 @@ async def download_attachment( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") a_id = _parse_uuid(att_id, "att_id") mail = ( @@ -1404,7 +1428,7 @@ async def download_attachment( ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "read") attachment = ( await db.execute( @@ -1442,13 +1466,14 @@ async def link_mail( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "write") if data.contact_id: mail.contact_id = _parse_uuid(data.contact_id, "contact_id") @@ -1468,13 +1493,14 @@ async def create_event_from_mail( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "write") try: from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract @@ -1517,6 +1543,7 @@ async def assign_label( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") l_id = _parse_uuid(data.label_id, "label_id") mail = ( @@ -1524,7 +1551,7 @@ async def assign_label( ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "write") label = ( await db.execute( @@ -1558,8 +1585,9 @@ async def create_draft( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) acc_id = _parse_uuid(data.account_id, "account_id") - account = await _get_account(db, acc_id, tenant_id, user_id) + account = await _get_account(db, acc_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "write") mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump()) return mail_to_response(mail) @@ -1574,13 +1602,14 @@ async def update_draft_route( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "write") mail = await update_draft(db, m_id, tenant_id, data.model_dump()) return mail_to_response(mail) @@ -1597,13 +1626,14 @@ async def delete_mail( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "delete") # Find the Trash folder for this account @@ -1700,6 +1730,7 @@ async def move_mail( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id") mail = ( @@ -1707,7 +1738,7 @@ async def move_mail( ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "write") # Verify target folder exists and belongs to same tenant target_folder = ( @@ -1790,13 +1821,14 @@ async def get_mail( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) - account = await _get_account(db, mail.account_id, tenant_id, user_id) + account = await _get_account(db, mail.account_id, tenant_id, user_id, is_system_admin=is_system_admin) await _check_delegate_access(db, account, user_id, "read") attachments = ( (await db.execute(select(MailAttachment).where(MailAttachment.mail_id == mail.id))) diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 9981056..99ad8be 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -16,10 +16,12 @@ from app.core.notifications import ( ) from app.deps import require_permission from app.models.notification import ( + Notification, NotificationPreference, NotificationType, ) from app.schemas.common import NotificationPreferenceUpdate, UnreadCountResponse +from app.services import entity_permission_service router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"]) @@ -31,10 +33,30 @@ async def list_notifications_endpoint( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("notifications:read")), ): - """List notifications (unread first).""" + """List notifications (unread first), filtered by entity access.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - return await list_notifications(db, tenant_id, user_id, page, page_size) + result = await list_notifications(db, tenant_id, user_id, page, page_size) + # Filter out notifications referencing entities the user cannot access + filtered_items = [] + for item in result.get("items", []): + entity_type = item.get("entity_type") + entity_id_str = item.get("entity_id") + if entity_type and entity_id_str: + try: + eid = uuid.UUID(entity_id_str) + has_access = await entity_permission_service.check_entity_access( + db, tenant_id, user_id, entity_type, eid, required_level="read" + ) + if not has_access: + continue + except (ValueError, Exception): + # If entity doesn't exist or error, skip this notification + continue + filtered_items.append(item) + result["items"] = filtered_items + result["total"] = len(filtered_items) + return result @router.patch("/{notification_id}/read") diff --git a/app/services/entity_permission_service.py b/app/services/entity_permission_service.py index 8bfd4ba..52dc682 100644 --- a/app/services/entity_permission_service.py +++ b/app/services/entity_permission_service.py @@ -21,6 +21,8 @@ import redis.asyncio as aioredis from sqlalchemy import and_, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession +from app.core.audit import log_audit +from app.core.notifications import create_notification from app.models.entity_permission import EntityPermission from app.models.group import Group, UserGroup from app.models.role import Role @@ -129,6 +131,24 @@ async def create_permission( await db.commit() await db.refresh(existing) names = await _load_principal_names(db, [existing]) + # Audit log for permission update + await log_audit( + db, tenant_id, created_by, + action='permission_update', + entity_type='entity_permission', + entity_id=existing.id, + changes={'permission_level': permission_level, 'principal_type': principal_type, 'principal_id': principal_id} + ) + # Notify user if direct permission + if principal_type == 'user': + await create_notification( + db, tenant_id, principal_uuid, + type='permission_granted', + title='Neue Berechtigung', + body=f'{entity_type} wurde mit dir geteilt', + entity_type=entity_type, + entity_id=entity_uuid, + ) return _serialize_permission(existing, names.get(existing.principal_id)) perm = EntityPermission( @@ -158,6 +178,25 @@ async def create_permission( for (uid,) in members_q: await _invalidate_user_cache(None, tenant_id, uid, entity_type) + # Audit log for new permission + await log_audit( + db, tenant_id, created_by, + action='permission_grant', + entity_type='entity_permission', + entity_id=perm.id, + changes={'permission_level': permission_level, 'principal_type': principal_type, 'principal_id': principal_id} + ) + # Notify user if direct permission + if principal_type == 'user': + await create_notification( + db, tenant_id, principal_uuid, + type='permission_granted', + title='Neue Berechtigung', + body=f'{entity_type} wurde mit dir geteilt', + entity_type=entity_type, + entity_id=entity_uuid, + ) + names = await _load_principal_names(db, [perm]) return _serialize_permission(perm, names.get(perm.principal_id)) @@ -203,6 +242,15 @@ async def update_permission( for (uid,) in members_q: await _invalidate_user_cache(None, tenant_id, uid, old_entity_type) + # Audit log for permission update + await log_audit( + db, tenant_id, None, + action='permission_update', + entity_type='entity_permission', + entity_id=perm.id, + changes={'permission_level': permission_level, 'principal_type': old_principal_type, 'principal_id': str(old_principal_id)} + ) + names = await _load_principal_names(db, [perm]) return _serialize_permission(perm, names.get(perm.principal_id)) @@ -224,6 +272,26 @@ async def delete_permission( old_principal_type = perm.principal_type old_principal_id = perm.principal_id old_entity_type = perm.entity_type + old_entity_id = perm.entity_id + + # Audit log for permission revoke + await log_audit( + db, tenant_id, None, + action='permission_revoke', + entity_type='entity_permission', + entity_id=perm.id, + changes={'permission_level': perm.permission_level, 'principal_type': old_principal_type, 'principal_id': str(old_principal_id)} + ) + # Notify user if direct permission + if old_principal_type == 'user': + await create_notification( + db, tenant_id, old_principal_id, + type='permission_revoked', + title='Berechtigung entfernt', + body=f'{old_entity_type} wurde nicht mehr mit dir geteilt', + entity_type=old_entity_type, + entity_id=old_entity_id, + ) await db.delete(perm) await db.commit()