Просмотр исходного кода

Security hardening (maziggy/bambuddy-security #31)

maziggy 6 дней назад
Родитель
Сommit
7b27d2a654

+ 111 - 40
backend/app/api/routes/notifications.py

@@ -12,6 +12,7 @@ from sqlalchemy import delete, desc, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     ScopedCaller,
     ScopedCaller,
     probe_permissions_if_auth_enabled,
     probe_permissions_if_auth_enabled,
@@ -19,7 +20,7 @@ from backend.app.core.auth import (
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
-from backend.app.core.printer_scope import ensure_printers_exist
+from backend.app.core.printer_scope import PrinterScope, ensure_printers_exist
 from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
 from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -193,14 +194,58 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
 # ============================================================================
 # ============================================================================
 
 
 
 
+def _provider_in_scope(provider: NotificationProvider, printer_scope: PrinterScope) -> bool:
+    """Whether a caller limited to some printers may see and manage ``provider``.
+
+    Only one bound to one of their printers: a provider for all printers (or
+    another printer) delivers events of printers outside their groups.
+    """
+    if printer_scope.is_unrestricted:
+        return True
+    return provider.printer_id is not None and printer_scope.allows(provider.printer_id)
+
+
+def _log_scope_clause(printer_scope: PrinterScope):
+    """The log entries a caller limited to some printers sees (None: all).
+
+    Those of their printers, and those with no printer sent by a provider they
+    can see: an entry with no printer belongs to whoever owns its provider.
+    """
+    if printer_scope.printer_ids is None:
+        return None
+    own_providers = select(NotificationProvider.id).where(
+        NotificationProvider.printer_id.in_(printer_scope.printer_ids)
+    )
+    return NotificationLog.printer_id.in_(printer_scope.printer_ids) | (
+        NotificationLog.printer_id.is_(None) & NotificationLog.provider_id.in_(own_providers)
+    )
+
+
+async def _load_provider(db: AsyncSession, provider_id: int, printer_scope: PrinterScope) -> NotificationProvider:
+    provider = await db.get(NotificationProvider, provider_id)
+    if provider is None or not _provider_in_scope(provider, printer_scope):
+        raise HTTPException(status_code=404, detail="Notification provider not found")
+    return provider
+
+
+def _ensure_target_in_scope(printer_id: int | None, printer_scope: PrinterScope) -> None:
+    """A caller limited to some printers binds a provider to one of them, never to all."""
+    if printer_scope.is_unrestricted:
+        return
+    if printer_id is None:
+        raise HTTPException(status_code=400, detail="Choose one of your printers for this provider")
+    printer_scope.ensure(printer_id)
+
+
 @router.get("/", response_model=list[NotificationProviderResponse])
 @router.get("/", response_model=list[NotificationProviderResponse])
 async def list_notification_providers(
 async def list_notification_providers(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List all notification providers."""
     """List all notification providers."""
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
-    providers = result.scalars().all()
+    providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
 
 
     return [_provider_to_dict(provider) for provider in providers]
     return [_provider_to_dict(provider) for provider in providers]
 
 
@@ -210,8 +255,10 @@ async def create_notification_provider(
     provider_data: NotificationProviderCreate,
     provider_data: NotificationProviderCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Create a new notification provider."""
     """Create a new notification provider."""
+    _ensure_target_in_scope(provider_data.printer_id, printer_scope)
     await ensure_printers_exist(db, [provider_data.printer_id])
     await ensure_printers_exist(db, [provider_data.printer_id])
     try:
     try:
         reject_placeholder(provider_data.config)
         reject_placeholder(provider_data.config)
@@ -308,6 +355,7 @@ async def test_notification_config(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
     can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Test notification configuration before saving.
     """Test notification configuration before saving.
 
 
@@ -321,9 +369,7 @@ async def test_notification_config(
         else:
         else:
             if not can_update:
             if not can_update:
                 raise HTTPException(status_code=403, detail="Testing a saved provider requires notifications:update")
                 raise HTTPException(status_code=403, detail="Testing a saved provider requires notifications:update")
-            provider = await db.get(NotificationProvider, test_request.provider_id)
-            if provider is None:
-                raise HTTPException(status_code=404, detail="Notification provider not found")
+            provider = await _load_provider(db, test_request.provider_id, printer_scope)
             config = merge_config(
             config = merge_config(
                 provider.provider_type, _stored_config(provider), test_request.provider_type.value, config
                 provider.provider_type, _stored_config(provider), test_request.provider_type.value, config
             )
             )
@@ -340,10 +386,11 @@ async def test_notification_config(
 async def test_all_notification_providers(
 async def test_all_notification_providers(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Send a test notification to all enabled providers."""
     """Send a test notification to all enabled providers."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
-    providers = result.scalars().all()
+    providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
 
 
     if not providers:
     if not providers:
         return {"tested": 0, "success": 0, "failed": 0, "results": []}
         return {"tested": 0, "success": 0, "failed": 0, "results": []}
@@ -402,9 +449,12 @@ async def get_notification_logs(
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get notification logs with optional filters."""
     """Get notification logs with optional filters."""
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
+    if (scope_clause := _log_scope_clause(printer_scope)) is not None:
+        query = query.where(scope_clause)
 
 
     # Apply filters
     # Apply filters
     if provider_id is not None:
     if provider_id is not None:
@@ -434,6 +484,13 @@ async def get_notification_logs(
             providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
             providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
 
 
         provider = providers_cache[log.provider_id]
         provider = providers_cache[log.provider_id]
+        # A provider the caller can't see is not named, as it isn't listed, and
+        # neither is its error, which names its kind and destination.
+        hidden = not printer_scope.is_unrestricted and (
+            provider is None or not _provider_in_scope(provider, printer_scope)
+        )
+        if hidden:
+            provider = None
         response.append(
         response.append(
             NotificationLogResponse(
             NotificationLogResponse(
                 id=log.id,
                 id=log.id,
@@ -444,7 +501,7 @@ async def get_notification_logs(
                 title=log.title,
                 title=log.title,
                 message=log.message,
                 message=log.message,
                 success=log.success,
                 success=log.success,
-                error_message=log.error_message,
+                error_message=None if hidden else log.error_message,
                 printer_id=log.printer_id,
                 printer_id=log.printer_id,
                 printer_name=log.printer_name,
                 printer_name=log.printer_name,
                 created_at=log.created_at,
                 created_at=log.created_at,
@@ -459,37 +516,44 @@ async def get_notification_log_stats(
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get notification log statistics."""
     """Get notification log statistics."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+    # Counted over the entries the log list shows this caller.
+    window = [NotificationLog.created_at >= cutoff]
+    if (scope_clause := _log_scope_clause(printer_scope)) is not None:
+        window.append(scope_clause)
 
 
     # Total counts
     # Total counts
-    total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
+    total_result = await db.execute(select(func.count(NotificationLog.id)).where(*window))
     total = total_result.scalar() or 0
     total = total_result.scalar() or 0
 
 
     success_result = await db.execute(
     success_result = await db.execute(
-        select(func.count(NotificationLog.id)).where(
-            NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
-        )
+        select(func.count(NotificationLog.id)).where(*window, NotificationLog.success.is_(True))
     )
     )
     success_count = success_result.scalar() or 0
     success_count = success_result.scalar() or 0
 
 
     # By event type
     # By event type
     event_result = await db.execute(
     event_result = await db.execute(
         select(NotificationLog.event_type, func.count(NotificationLog.id))
         select(NotificationLog.event_type, func.count(NotificationLog.id))
-        .where(NotificationLog.created_at >= cutoff)
+        .where(*window)
         .group_by(NotificationLog.event_type)
         .group_by(NotificationLog.event_type)
     )
     )
     by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
     by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
 
 
     # By provider (need to join to get name)
     # By provider (need to join to get name)
     provider_result = await db.execute(
     provider_result = await db.execute(
-        select(NotificationProvider.name, func.count(NotificationLog.id))
+        select(NotificationProvider, func.count(NotificationLog.id))
         .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
         .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
-        .where(NotificationLog.created_at >= cutoff)
-        .group_by(NotificationProvider.name)
+        .where(*window)
+        .group_by(NotificationProvider.id)
     )
     )
-    by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
+    # Providers the caller can't see are counted together, not by name.
+    by_provider: dict[str, int] = {}
+    for provider, count in provider_result.all():
+        name = provider.name if _provider_in_scope(provider, printer_scope) else "Other"
+        by_provider[name] = by_provider.get(name, 0) + count
 
 
     return NotificationLogStats(
     return NotificationLogStats(
         total=total,
         total=total,
@@ -505,11 +569,16 @@ async def clear_notification_logs(
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Clear old notification logs."""
     """Clear old notification logs."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
     cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
 
 
-    result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
+    statement = delete(NotificationLog).where(NotificationLog.created_at < cutoff)
+    # A caller limited to some printers clears only those printers' entries.
+    if (scope_clause := printer_scope.where_strict(NotificationLog.printer_id)) is not None:
+        statement = statement.where(scope_clause)
+    result = await db.execute(statement)
     await db.commit()
     await db.commit()
 
 
     deleted_count = result.rowcount
     deleted_count = result.rowcount
@@ -581,14 +650,21 @@ async def send_app_message(
     data: AppMessage,
     data: AppMessage,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     caller: ScopedCaller = Depends(require_notification_send()),
     caller: ScopedCaller = Depends(require_notification_send()),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Send a message through every enabled channel that has "Messages from
     """Send a message through every enabled channel that has "Messages from
     connected apps" on. For other applications, e.g. Bambuddy Orders; an API
     connected apps" on. For other applications, e.g. Bambuddy Orders; an API
-    key needs the "Send notifications" permission."""
+    key needs the "Send notifications" permission. A caller limited to some
+    printers reaches only the channels of those printers."""
     key, sender = _app_sender(caller)
     key, sender = _app_sender(caller)
     _check_app_message_rate(key)
     _check_app_message_rate(key)
     channels = await notification_service.on_app_message(
     channels = await notification_service.on_app_message(
-        db, sender=sender, title=data.title, message=data.message, url=data.url
+        db,
+        sender=sender,
+        title=data.title,
+        message=data.message,
+        url=data.url,
+        allowed=lambda provider: _provider_in_scope(provider, printer_scope),
     )
     )
     return AppMessageResult(channels=channels)
     return AppMessageResult(channels=channels)
 
 
@@ -597,6 +673,7 @@ async def send_app_message(
 async def app_message_channels(
 async def app_message_channels(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: ScopedCaller = Depends(require_notification_send()),
     _: ScopedCaller = Depends(require_notification_send()),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """The enabled channels that deliver app messages: names and types only,
     """The enabled channels that deliver app messages: names and types only,
     so an app can tell its user where its messages will arrive."""
     so an app can tell its user where its messages will arrive."""
@@ -605,7 +682,11 @@ async def app_message_channels(
         .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
         .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
         .order_by(NotificationProvider.name)
         .order_by(NotificationProvider.name)
     )
     )
-    return [AppMessageChannel(name=p.name, provider_type=p.provider_type) for p in rows.scalars()]
+    return [
+        AppMessageChannel(name=p.name, provider_type=p.provider_type)
+        for p in rows.scalars()
+        if _provider_in_scope(p, printer_scope)
+    ]
 
 
 
 
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
@@ -613,13 +694,10 @@ async def get_notification_provider(
     provider_id: int,
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get a specific notification provider."""
     """Get a specific notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
     return _provider_to_dict(provider)
     return _provider_to_dict(provider)
 
 
@@ -630,19 +708,18 @@ async def update_notification_provider(
     update_data: NotificationProviderUpdate,
     update_data: NotificationProviderUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a notification provider."""
     """Update a notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
     old_config = _stored_config(provider)
     old_config = _stored_config(provider)
     old_type = provider.provider_type
     old_type = provider.provider_type
 
 
     # Update only provided fields
     # Update only provided fields
     update_dict = update_data.model_dump(exclude_unset=True)
     update_dict = update_data.model_dump(exclude_unset=True)
+    if "printer_id" in update_dict:
+        _ensure_target_in_scope(update_dict["printer_id"], printer_scope)
     await ensure_printers_exist(db, [update_dict.get("printer_id")])
     await ensure_printers_exist(db, [update_dict.get("printer_id")])
 
 
     effective_type = update_dict.get("provider_type") or provider.provider_type
     effective_type = update_dict.get("provider_type") or provider.provider_type
@@ -720,13 +797,10 @@ async def delete_notification_provider(
     provider_id: int,
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete a notification provider."""
     """Delete a notification provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
     name = provider.name
     name = provider.name
     notify = await delete_provider(db, provider)
     notify = await delete_provider(db, provider)
@@ -743,13 +817,10 @@ async def test_notification_provider(
     provider_id: int,
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Send a test notification using an existing provider."""
     """Send a test notification using an existing provider."""
-    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
-    provider = result.scalar_one_or_none()
-
-    if not provider:
-        raise HTTPException(status_code=404, detail="Notification provider not found")
+    provider = await _load_provider(db, provider_id, printer_scope)
 
 
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     success, message = await notification_service.send_test_notification(
     success, message = await notification_service.send_test_notification(

+ 6 - 1
backend/app/services/notification_service.py

@@ -6,6 +6,7 @@ import json
 import logging
 import logging
 import re
 import re
 import smtplib
 import smtplib
+from collections.abc import Callable
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 from email.mime.image import MIMEImage
 from email.mime.image import MIMEImage
 from email.mime.multipart import MIMEMultipart
 from email.mime.multipart import MIMEMultipart
@@ -1712,6 +1713,7 @@ class NotificationService:
         title: str,
         title: str,
         message: str,
         message: str,
         url: str | None = None,
         url: str | None = None,
+        allowed: Callable[[NotificationProvider], bool] | None = None,
     ) -> int:
     ) -> int:
         """A message another application sends through Bambuddy.
         """A message another application sends through Bambuddy.
 
 
@@ -1719,9 +1721,12 @@ class NotificationService:
         through the same path as Bambuddy's own events: quiet hours, the daily
         through the same path as Bambuddy's own events: quiet hours, the daily
         digest and the log (whose event type names the sender). The text is
         digest and the log (whose event type names the sender). The text is
         the app's own; a link, when given, is appended so every channel type
         the app's own; a link, when given, is appended so every channel type
-        carries it. Returns how many channels it was handed to.
+        carries it. Returns how many channels it was handed to. ``allowed``
+        narrows the channels to those the sender may reach.
         """
         """
         providers = await self._get_providers_for_event(db, "on_app_message")
         providers = await self._get_providers_for_event(db, "on_app_message")
+        if allowed is not None:
+            providers = [provider for provider in providers if allowed(provider)]
         if not providers:
         if not providers:
             return 0
             return 0
         body = f"{message}\n{url}" if url else message
         body = f"{message}\n{url}" if url else message

+ 227 - 0
backend/tests/integration/test_notification_scope.py

@@ -0,0 +1,227 @@
+"""Notification providers and logs keep to the caller's printers."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.notification import NotificationLog
+from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+PERMS = [
+    "printers:read",
+    "notifications:read",
+    "notifications:create",
+    "notifications:update",
+    "notifications:delete",
+]
+
+
+def _provider(name: str, printer_id: int | None) -> dict:
+    return {
+        "name": name,
+        "provider_type": "callmebot",
+        "enabled": True,
+        "config": {"phone_number": "+1234567890", "api_key": "test-api-key"},
+        "printer_id": printer_id,
+    }
+
+
+async def _setup(client: AsyncClient, printer_factory):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    admin = await _admin_token(client)
+    perms = await _group(client, admin, "perms", permissions=PERMS)
+    team = await _group(client, admin, "team", printer_ids=[a.id])
+    member = _auth((await _user(client, admin, "member", [perms, team]))[0])
+    ids = {}
+    for name, printer in (("ours", a.id), ("theirs", b.id), ("everyone", None)):
+        created = await client.post("/api/v1/notifications/", headers=_auth(admin), json=_provider(name, printer))
+        assert created.status_code == 200, created.text
+        ids[name] = created.json()["id"]
+    return a, b, member, ids
+
+
+async def test_a_member_sees_and_manages_only_providers_of_their_printers(async_client: AsyncClient, printer_factory):
+    a, b, member, ids = await _setup(async_client, printer_factory)
+
+    listed = await async_client.get("/api/v1/notifications/", headers=member)
+
+    assert [p["name"] for p in listed.json()] == ["ours"]
+    for name in ("theirs", "everyone"):
+        pid = ids[name]
+        assert (await async_client.get(f"/api/v1/notifications/{pid}", headers=member)).status_code == 404
+        patched = await async_client.patch(f"/api/v1/notifications/{pid}", headers=member, json={"name": "mine now"})
+        assert patched.status_code == 404
+        assert (await async_client.post(f"/api/v1/notifications/{pid}/test", headers=member)).status_code == 404
+        assert (await async_client.delete(f"/api/v1/notifications/{pid}", headers=member)).status_code == 404
+    assert (await async_client.get(f"/api/v1/notifications/{ids['ours']}", headers=member)).status_code == 200
+
+
+async def test_a_member_binds_providers_to_their_printers_only(async_client: AsyncClient, printer_factory):
+    a, b, member, ids = await _setup(async_client, printer_factory)
+
+    to_other = await async_client.post("/api/v1/notifications/", headers=member, json=_provider("x", b.id))
+    to_all = await async_client.post("/api/v1/notifications/", headers=member, json=_provider("y", None))
+    to_own = await async_client.post("/api/v1/notifications/", headers=member, json=_provider("z", a.id))
+    retarget_other = await async_client.patch(
+        f"/api/v1/notifications/{ids['ours']}", headers=member, json={"printer_id": b.id}
+    )
+    retarget_all = await async_client.patch(
+        f"/api/v1/notifications/{ids['ours']}", headers=member, json={"printer_id": None}
+    )
+
+    assert (to_other.status_code, to_all.status_code, to_own.status_code) == (404, 400, 200)
+    assert (retarget_other.status_code, retarget_all.status_code) == (404, 400)
+
+
+async def test_logs_and_their_counts_keep_to_the_team_printers(async_client: AsyncClient, db_session, printer_factory):
+    a, b, member, ids = await _setup(async_client, printer_factory)
+    a_id, b_id = a.id, b.id
+    old = datetime.utcnow() - timedelta(days=40)
+    for printer, when in ((a_id, None), (b_id, None), (None, None), (a_id, old), (b_id, old), (None, old)):
+        row = NotificationLog(
+            provider_id=ids["ours"], event_type="print_start", title="t", message="m", printer_id=printer
+        )
+        if when is not None:
+            row.created_at = when
+        db_session.add(row)
+    await db_session.commit()
+
+    listed = await async_client.get("/api/v1/notifications/logs", headers=member)
+    stats = await async_client.get("/api/v1/notifications/logs/stats", headers=member)
+    cleared = await async_client.delete("/api/v1/notifications/logs", headers=member, params={"older_than_days": 30})
+
+    assert sorted((row["printer_id"] or 0) for row in listed.json()) == [0, a_id]
+    assert stats.json()["total"] == 2
+    assert cleared.json()["deleted"] == 1
+    db_session.expire_all()
+    left = sorted((pid or 0) for (pid,) in (await db_session.execute(select(NotificationLog.printer_id))).all())
+    assert left == sorted([a_id, b_id, 0, b_id, 0])
+
+
+async def test_testing_a_stored_provider_answers_a_hidden_one_like_a_missing_one(
+    async_client: AsyncClient, printer_factory
+):
+    a, b, member, ids = await _setup(async_client, printer_factory)
+    body = {"provider_type": "callmebot", "config": {"phone_number": "+1234567890", "api_key": "********"}}
+
+    hidden = await async_client.post(
+        "/api/v1/notifications/test-config", headers=member, json={**body, "provider_id": ids["theirs"]}
+    )
+    missing = await async_client.post(
+        "/api/v1/notifications/test-config", headers=member, json={**body, "provider_id": 999_999}
+    )
+
+    assert (
+        (hidden.status_code, hidden.json())
+        == (missing.status_code, missing.json())
+        == (
+            404,
+            {"detail": "Notification provider not found"},
+        )
+    )
+
+
+async def test_app_messages_reach_only_the_team_printers_channels(async_client: AsyncClient, printer_factory):
+    from unittest.mock import AsyncMock, patch
+
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    admin = await _admin_token(async_client)
+    perms = await _group(async_client, admin, "perms", permissions=PERMS)
+    team = await _group(async_client, admin, "team", printer_ids=[a.id])
+    member = _auth((await _user(async_client, admin, "member", [perms, team]))[0])
+    for name, printer in (("ours", a.id), ("theirs", b.id), ("everyone", None)):
+        created = await async_client.post(
+            "/api/v1/notifications/", headers=_auth(admin), json={**_provider(name, printer), "on_app_message": True}
+        )
+        assert created.status_code == 200, created.text
+
+    channels = await async_client.get("/api/v1/notifications/app-message/channels", headers=member)
+    with patch(
+        "backend.app.services.notification_service.NotificationService._send_to_providers", new=AsyncMock()
+    ) as send:
+        sent = await async_client.post(
+            "/api/v1/notifications/app-message", headers=member, json={"title": "t", "message": "m"}
+        )
+
+    assert [c["name"] for c in channels.json()] == ["ours"]
+    assert sent.json()["channels"] == 1
+    assert [p.name for p in send.await_args.args[0]] == ["ours"]
+
+
+async def test_the_log_does_not_name_providers_the_caller_cannot_see(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    a, b, member, ids = await _setup(async_client, printer_factory)
+    a_id = a.id
+    for provider in ("ours", "everyone"):
+        db_session.add(
+            NotificationLog(
+                provider_id=ids[provider],
+                event_type="print_start",
+                title="t",
+                message="m",
+                printer_id=a_id,
+                success=False,
+                error_message=f"failed for {provider}",
+            )
+        )
+    await db_session.commit()
+
+    listed = await async_client.get("/api/v1/notifications/logs", headers=member)
+    stats = await async_client.get("/api/v1/notifications/logs/stats", headers=member)
+
+    rows = sorted(listed.json(), key=lambda row: str(row["provider_name"]))
+    assert [(row["provider_name"], row["error_message"]) for row in rows] == [
+        (None, None),
+        ("ours", "failed for ours"),
+    ]
+    assert stats.json()["by_provider"] == {"ours": 1, "Other": 1}
+
+
+async def test_log_entries_without_a_printer_stay_with_their_provider(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    a, b, member, ids = await _setup(async_client, printer_factory)
+    for provider in ("ours", "everyone"):
+        db_session.add(
+            NotificationLog(
+                provider_id=ids[provider], event_type="app:key", title=provider, message="m", printer_id=None
+            )
+        )
+    await db_session.commit()
+
+    listed = await async_client.get("/api/v1/notifications/logs", headers=member)
+    stats = await async_client.get("/api/v1/notifications/logs/stats", headers=member)
+
+    assert [row["title"] for row in listed.json()] == ["ours"]
+    assert stats.json()["total"] == 1
+
+
+async def test_a_log_entry_whose_provider_is_gone_shows_no_error(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    if db_session.bind.dialect.name != "sqlite":
+        pytest.skip("only SQLite can keep such an entry")
+    a, b, member, ids = await _setup(async_client, printer_factory)
+    db_session.add(
+        NotificationLog(
+            provider_id=999_999,
+            event_type="print_start",
+            title="t",
+            message="m",
+            printer_id=a.id,
+            success=False,
+            error_message="failed",
+        )
+    )
+    await db_session.commit()
+
+    listed = await async_client.get("/api/v1/notifications/logs", headers=member)
+
+    assert [(row["provider_name"], row["error_message"]) for row in listed.json()] == [(None, None)]