Security hardening (maziggy/bambuddy-security #31)

This commit is contained in:
maziggy
2026-10-15 19:28:22 +02:00
parent fa8d670691
commit 7b27d2a654
3 changed files with 344 additions and 41 deletions
+111 -40
View File
@@ -12,6 +12,7 @@ from sqlalchemy import delete, desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import (
RequestPrinterScope,
RequirePermissionIfAuthEnabled,
ScopedCaller,
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.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_lock_screen_widget import NotificationLockScreenWidget
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])
async def list_notification_providers(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""List all notification providers."""
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]
@@ -210,8 +255,10 @@ async def create_notification_provider(
provider_data: NotificationProviderCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""Create a new notification provider."""
_ensure_target_in_scope(provider_data.printer_id, printer_scope)
await ensure_printers_exist(db, [provider_data.printer_id])
try:
reject_placeholder(provider_data.config)
@@ -308,6 +355,7 @@ async def test_notification_config(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""Test notification configuration before saving.
@@ -321,9 +369,7 @@ async def test_notification_config(
else:
if not can_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(
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(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""Send a test notification to all enabled providers."""
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:
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"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""Get notification logs with optional filters."""
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
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()
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(
NotificationLogResponse(
id=log.id,
@@ -444,7 +501,7 @@ async def get_notification_logs(
title=log.title,
message=log.message,
success=log.success,
error_message=log.error_message,
error_message=None if hidden else log.error_message,
printer_id=log.printer_id,
printer_name=log.printer_name,
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"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""Get notification log statistics."""
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_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
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
# By event type
event_result = await db.execute(
select(NotificationLog.event_type, func.count(NotificationLog.id))
.where(NotificationLog.created_at >= cutoff)
.where(*window)
.group_by(NotificationLog.event_type)
)
by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
# By provider (need to join to get name)
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)
.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(
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"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""Clear old notification logs."""
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()
deleted_count = result.rowcount
@@ -581,14 +650,21 @@ async def send_app_message(
data: AppMessage,
db: AsyncSession = Depends(get_db),
caller: ScopedCaller = Depends(require_notification_send()),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""Send a message through every enabled channel that has "Messages from
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)
_check_app_message_rate(key)
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)
@@ -597,6 +673,7 @@ async def send_app_message(
async def app_message_channels(
db: AsyncSession = Depends(get_db),
_: ScopedCaller = Depends(require_notification_send()),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""The enabled channels that deliver app messages: names and types only,
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))
.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)
@@ -613,13 +694,10 @@ async def get_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""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)
@@ -630,19 +708,18 @@ async def update_notification_provider(
update_data: NotificationProviderUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""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_type = provider.provider_type
# Update only provided fields
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")])
effective_type = update_dict.get("provider_type") or provider.provider_type
@@ -720,13 +797,10 @@ async def delete_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""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
notify = await delete_provider(db, provider)
@@ -743,13 +817,10 @@ async def test_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
printer_scope: PrinterScope = RequestPrinterScope,
):
"""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
success, message = await notification_service.send_test_notification(
+6 -1
View File
@@ -6,6 +6,7 @@ import json
import logging
import re
import smtplib
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
@@ -1712,6 +1713,7 @@ class NotificationService:
title: str,
message: str,
url: str | None = None,
allowed: Callable[[NotificationProvider], bool] | None = None,
) -> int:
"""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
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
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")
if allowed is not None:
providers = [provider for provider in providers if allowed(provider)]
if not providers:
return 0
body = f"{message}\n{url}" if url else message
@@ -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)]