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
+8
View File
@@ -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,
}
+4
View File
@@ -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
)
+2 -1
View File
@@ -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"
+93 -61
View File
@@ -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)))
+24 -2
View File
@@ -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")
+68
View File
@@ -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()