sprint6+7: permission notifications + audit trail + notification entity filter + mail account permissions + migration 0053
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-07-29 02:18:17 +02:00
parent 71ed592aa2
commit 88c04286af
7 changed files with 240 additions and 64 deletions
+41
View File
@@ -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")
+8
View File
@@ -23,6 +23,8 @@ async def create_notification(
type: str, type: str,
title: str, title: str,
body: str | None = None, body: str | None = None,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
) -> Notification | None: ) -> Notification | None:
"""Create a new notification for a user if they have not disabled this type. """Create a new notification for a user if they have not disabled this type.
@@ -60,6 +62,8 @@ async def create_notification(
type=type, type=type,
title=title, title=title,
body=body, body=body,
entity_type=entity_type,
entity_id=entity_id,
) )
db.add(notif) db.add(notif)
await db.flush() await db.flush()
@@ -73,6 +77,8 @@ async def create_notification(
'user_id': str(user_id), 'user_id': str(user_id),
'type': type, 'type': type,
'title': title, 'title': title,
'entity_type': entity_type,
'entity_id': str(entity_id) if entity_id else None,
}) })
return notif return notif
@@ -173,6 +179,8 @@ def _notification_to_dict(n: Notification) -> dict[str, Any]:
"type": n.type, "type": n.type,
"title": n.title, "title": n.title,
"body": n.body, "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, "read_at": n.read_at.isoformat() if n.read_at else None,
"created_at": n.created_at.isoformat() if n.created_at else None, "created_at": n.created_at.isoformat() if n.created_at else None,
} }
+4
View File
@@ -33,6 +33,10 @@ class Notification(Base, TenantMixin):
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 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( user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
) )
+2 -1
View File
@@ -20,11 +20,12 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
# --- Mail Accounts (F-MAIL-14, F-MAIL-18) --- # --- 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.""" """Mail account configuration with encrypted IMAP/SMTP credentials."""
__tablename__ = "mail_accounts" __tablename__ = "mail_accounts"
+91 -59
View File
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.storage import get_storage_backend 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.deps import require_permission
from app.plugins.builtins.mail.models import ( from app.plugins.builtins.mail.models import (
ContactPgpKey, ContactPgpKey,
@@ -139,7 +140,8 @@ async def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
async def _get_account( 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: ) -> MailAccount:
account = ( account = (
await db.execute( await db.execute(
@@ -150,21 +152,12 @@ async def _get_account(
).scalar_one_or_none() ).scalar_one_or_none()
if not account: if not account:
raise HTTPException(404, detail={"detail": "Mail account not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Mail account not found", "code": "not_found"})
if account.user_id == user_id: has_access = await check_single_entity_access(
return account db, "mail_account", account_id, user_id, tenant_id, "read", is_system_admin
delegate = (
await db.execute(
select(MailAccountDelegate).where(
and_(
MailAccountDelegate.account_id == account_id,
MailAccountDelegate.delegate_user_id == user_id,
) )
) if not has_access:
)
).scalar_one_or_none()
if delegate:
return account
raise HTTPException(403, detail={"detail": "No access to this account", "code": "forbidden"}) raise HTTPException(403, detail={"detail": "No access to this account", "code": "forbidden"})
return account
async def _check_send_permission( async def _check_send_permission(
@@ -229,20 +222,12 @@ async def list_accounts(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
accounts = ( is_system_admin = current_user.get("is_system_admin", False)
( query = select(MailAccount).where(MailAccount.tenant_id == tenant_id)
await db.execute( query = await apply_visibility_filter(
select(MailAccount).where( db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin
and_(
MailAccount.tenant_id == tenant_id,
or_(MailAccount.user_id == user_id, MailAccount.is_shared),
)
)
)
)
.scalars()
.all()
) )
accounts = (await db.execute(query)).scalars().all()
return [account_to_response(a) for a in accounts] 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
account = await create_mail_account( account = await create_mail_account(
db, tenant_id=tenant_id, user_id=user_id, data=data.model_dump() 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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)) await update_mail_account(db, account, data.model_dump(exclude_unset=True))
return account_to_response(account) return account_to_response(account)
@@ -302,9 +289,13 @@ async def delete_account(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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)
if account.user_id != user_id: 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"}) raise HTTPException(403, detail={"detail": "Only owner can delete", "code": "forbidden"})
await db.delete(account) await db.delete(account)
@@ -318,9 +309,13 @@ async def assign_shared_users(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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)
if account.user_id != user_id: 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( raise HTTPException(
403, detail={"detail": "Only owner can assign users", "code": "forbidden"} 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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)
if account.user_id != user_id: 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( raise HTTPException(
403, detail={"detail": "Only owner can create delegates", "code": "forbidden"} 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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)
if account.user_id != user_id: 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( raise HTTPException(
403, detail={"detail": "Only owner can grant send permissions", "code": "forbidden"} 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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) password = await get_account_password(account)
try: try:
import aioimaplib import aioimaplib
@@ -470,8 +474,9 @@ async def trigger_imap_sync(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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) result = await imap_sync_account(db, acc_id, tenant_id)
return result return result
@@ -487,8 +492,9 @@ async def list_folders(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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 = ( folders = (
( (
await db.execute( await db.execute(
@@ -511,8 +517,9 @@ async def create_folder(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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 parent_id = _parse_uuid(data.parent_id, "parent_id") if data.parent_id else None
folder = MailFolder( folder = MailFolder(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -544,6 +551,7 @@ async def update_folder(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") f_id = _parse_uuid(folder_id, "folder_id")
folder = ( folder = (
await db.execute( await db.execute(
@@ -552,7 +560,7 @@ async def update_folder(
).scalar_one_or_none() ).scalar_one_or_none()
if not folder: if not folder:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) 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: if data.name:
folder.name = data.name folder.name = data.name
await db.flush() await db.flush()
@@ -567,6 +575,7 @@ async def delete_folder(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") f_id = _parse_uuid(folder_id, "folder_id")
folder = ( folder = (
await db.execute( await db.execute(
@@ -575,7 +584,7 @@ async def delete_folder(
).scalar_one_or_none() ).scalar_one_or_none()
if not folder: if not folder:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "full")
try: try:
await imap_delete_folder(db, f_id, tenant_id) 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") f_id = _parse_uuid(folder_id, "folder_id")
folder = ( folder = (
await db.execute( await db.execute(
@@ -613,7 +623,7 @@ async def empty_folder(
).scalar_one_or_none() ).scalar_one_or_none()
if not folder: if not folder:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "delete")
# Check if this folder IS the Trash folder # Check if this folder IS the Trash folder
@@ -705,6 +715,7 @@ async def sync_folder(
"""Sync a single folder from IMAP server immediately.""" """Sync a single folder from IMAP server immediately."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") f_id = _parse_uuid(folder_id, "folder_id")
folder = ( folder = (
await db.execute( await db.execute(
@@ -713,7 +724,7 @@ async def sync_folder(
).scalar_one_or_none() ).scalar_one_or_none()
if not folder: if not folder:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) 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) result = await mail_services.imap_sync_folder(db, f_id, tenant_id)
await db.flush() await db.flush()
return result return result
@@ -735,6 +746,7 @@ async def upload_attachment(
""" """
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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 # Read file content and check size
content = await file.read() content = await file.read()
@@ -777,8 +789,9 @@ async def send_mail(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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) await _check_send_permission(db, account, user_id)
signature = None signature = None
if data.signature_id: if data.signature_id:
@@ -899,6 +912,7 @@ async def create_template(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
template = MailTemplate( template = MailTemplate(
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, user_id=user_id,
@@ -958,6 +972,7 @@ async def create_signature(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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 acc_id = _parse_uuid(data.account_id, "account_id") if data.account_id else None
sig = MailSignature( sig = MailSignature(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1054,8 +1069,9 @@ async def configure_vacation(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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 = { vacation_data = {
"is_enabled": data.is_enabled, "is_enabled": data.is_enabled,
"subject": data.subject, "subject": data.subject,
@@ -1083,8 +1099,9 @@ async def test_vacation_dedup(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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) should_send_1 = await should_send_vacation_reply(db, acc_id, sender, tenant_id)
if should_send_1: if should_send_1:
await log_vacation_sent(db, acc_id, sender, tenant_id) 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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) key_id, public_key_armored = import_pgp_private_key(data.private_key_armored, data.passphrase)
encrypted_private = encrypt_password(data.private_key_armored) encrypted_private = encrypt_password(data.private_key_armored)
pgp_key = PgpKey( pgp_key = PgpKey(
@@ -1127,6 +1145,7 @@ async def list_pgp_keys(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
keys = ( keys = (
( (
await db.execute( await db.execute(
@@ -1178,6 +1197,7 @@ async def create_label(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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) label = MailLabel(tenant_id=tenant_id, name=data.name, color=data.color, user_id=user_id)
db.add(label) db.add(label)
await db.flush() await db.flush()
@@ -1265,13 +1285,14 @@ async def reply_mail(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
original = ( original = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not original: if not original:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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_delegate_access(db, account, user_id, "read")
await _check_send_permission(db, account, user_id) await _check_send_permission(db, account, user_id)
signature = None signature = None
@@ -1311,13 +1332,14 @@ async def forward_mail_endpoint(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
original = ( original = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not original: if not original:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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_delegate_access(db, account, user_id, "read")
await _check_send_permission(db, account, user_id) await _check_send_permission(db, account, user_id)
signature = None signature = None
@@ -1358,13 +1380,14 @@ async def update_flags(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "write")
if data.is_seen is not None: if data.is_seen is not None:
mail.is_seen = data.is_seen mail.is_seen = data.is_seen
@@ -1397,6 +1420,7 @@ async def download_attachment(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
a_id = _parse_uuid(att_id, "att_id") a_id = _parse_uuid(att_id, "att_id")
mail = ( mail = (
@@ -1404,7 +1428,7 @@ async def download_attachment(
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "read")
attachment = ( attachment = (
await db.execute( await db.execute(
@@ -1442,13 +1466,14 @@ async def link_mail(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "write")
if data.contact_id: if data.contact_id:
mail.contact_id = _parse_uuid(data.contact_id, "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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "write")
try: try:
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
l_id = _parse_uuid(data.label_id, "label_id") l_id = _parse_uuid(data.label_id, "label_id")
mail = ( mail = (
@@ -1524,7 +1551,7 @@ async def assign_label(
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "write")
label = ( label = (
await db.execute( await db.execute(
@@ -1558,8 +1585,9 @@ async def create_draft(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") 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") await _check_delegate_access(db, account, user_id, "write")
mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump()) mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump())
return mail_to_response(mail) return mail_to_response(mail)
@@ -1574,13 +1602,14 @@ async def update_draft_route(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "write")
mail = await update_draft(db, m_id, tenant_id, data.model_dump()) mail = await update_draft(db, m_id, tenant_id, data.model_dump())
return mail_to_response(mail) return mail_to_response(mail)
@@ -1597,13 +1626,14 @@ async def delete_mail(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "delete")
# Find the Trash folder for this account # Find the Trash folder for this account
@@ -1700,6 +1730,7 @@ async def move_mail(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id") target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id")
mail = ( mail = (
@@ -1707,7 +1738,7 @@ async def move_mail(
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "write")
# Verify target folder exists and belongs to same tenant # Verify target folder exists and belongs to same tenant
target_folder = ( target_folder = (
@@ -1790,13 +1821,14 @@ async def get_mail(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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") await _check_delegate_access(db, account, user_id, "read")
attachments = ( attachments = (
(await db.execute(select(MailAttachment).where(MailAttachment.mail_id == mail.id))) (await db.execute(select(MailAttachment).where(MailAttachment.mail_id == mail.id)))
+24 -2
View File
@@ -16,10 +16,12 @@ from app.core.notifications import (
) )
from app.deps import require_permission from app.deps import require_permission
from app.models.notification import ( from app.models.notification import (
Notification,
NotificationPreference, NotificationPreference,
NotificationType, NotificationType,
) )
from app.schemas.common import NotificationPreferenceUpdate, UnreadCountResponse from app.schemas.common import NotificationPreferenceUpdate, UnreadCountResponse
from app.services import entity_permission_service
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"]) router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
@@ -31,10 +33,30 @@ async def list_notifications_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("notifications:read")), 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_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") @router.patch("/{notification_id}/read")
+68
View File
@@ -21,6 +21,8 @@ import redis.asyncio as aioredis
from sqlalchemy import and_, func, or_, select, text from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession 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.entity_permission import EntityPermission
from app.models.group import Group, UserGroup from app.models.group import Group, UserGroup
from app.models.role import Role from app.models.role import Role
@@ -129,6 +131,24 @@ async def create_permission(
await db.commit() await db.commit()
await db.refresh(existing) await db.refresh(existing)
names = await _load_principal_names(db, [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)) return _serialize_permission(existing, names.get(existing.principal_id))
perm = EntityPermission( perm = EntityPermission(
@@ -158,6 +178,25 @@ async def create_permission(
for (uid,) in members_q: for (uid,) in members_q:
await _invalidate_user_cache(None, tenant_id, uid, entity_type) 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]) names = await _load_principal_names(db, [perm])
return _serialize_permission(perm, names.get(perm.principal_id)) return _serialize_permission(perm, names.get(perm.principal_id))
@@ -203,6 +242,15 @@ async def update_permission(
for (uid,) in members_q: for (uid,) in members_q:
await _invalidate_user_cache(None, tenant_id, uid, old_entity_type) 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]) names = await _load_principal_names(db, [perm])
return _serialize_permission(perm, names.get(perm.principal_id)) 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_type = perm.principal_type
old_principal_id = perm.principal_id old_principal_id = perm.principal_id
old_entity_type = perm.entity_type 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.delete(perm)
await db.commit() await db.commit()