fix(notifications): secure inbox and post-commit delivery
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -51,17 +53,46 @@ def _response(notification) -> NotificationResponse:
|
||||
return NotificationResponse.model_validate(notification_response(notification))
|
||||
|
||||
|
||||
def _principal_recipient_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
||||
"""Return the stable actor identifiers accepted for personal notifications.
|
||||
|
||||
Existing producers use both tenant membership/user ids and account ids. Both
|
||||
identify the same authenticated actor; identity/service-account ids are
|
||||
included when present so producers can address those stable identities too.
|
||||
"""
|
||||
|
||||
candidates = (
|
||||
getattr(principal.user, "id", None),
|
||||
principal.membership_id,
|
||||
principal.account_id,
|
||||
principal.identity_id,
|
||||
principal.principal.service_account_id,
|
||||
)
|
||||
return tuple(dict.fromkeys(str(value) for value in candidates if value))
|
||||
|
||||
|
||||
def _recipient_ids_for_view(principal: ApiPrincipal, view: Literal["personal", "tenant"]) -> tuple[str, ...] | None:
|
||||
if view == "tenant":
|
||||
_require_scope(principal, ADMIN_SCOPE)
|
||||
return None
|
||||
return _principal_recipient_ids(principal)
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
def api_list_notifications(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
channel: str | None = None,
|
||||
source_module: str | None = None,
|
||||
recipient_id: str | None = None,
|
||||
view: Literal["personal", "tenant"] = Query(default="personal"),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> NotificationListResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
recipient_ids = _recipient_ids_for_view(principal, view)
|
||||
if recipient_id is not None and recipient_ids is not None and recipient_id not in recipient_ids:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot read another recipient's notifications")
|
||||
notifications = list_notifications(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
@@ -69,6 +100,7 @@ def api_list_notifications(
|
||||
channel=channel,
|
||||
source_module=source_module,
|
||||
recipient_id=recipient_id,
|
||||
recipient_ids=recipient_ids,
|
||||
limit=limit,
|
||||
)
|
||||
return NotificationListResponse(notifications=[_response(notification) for notification in notifications])
|
||||
@@ -76,11 +108,19 @@ def api_list_notifications(
|
||||
|
||||
@router.get("/summary", response_model=NotificationSummaryResponse)
|
||||
def api_notification_summary(
|
||||
view: Literal["personal", "tenant"] = Query(default="personal"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> NotificationSummaryResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
return NotificationSummaryResponse.model_validate(notification_summary(session, tenant_id=principal.tenant_id, user_id=principal.user.id))
|
||||
return NotificationSummaryResponse.model_validate(
|
||||
notification_summary(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
recipient_ids=_recipient_ids_for_view(principal, view),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preferences/me", response_model=NotificationPreferencesResponse)
|
||||
@@ -137,12 +177,20 @@ def api_deliver_pending(
|
||||
@router.get("/{notification_id}", response_model=NotificationResponse)
|
||||
def api_get_notification(
|
||||
notification_id: str,
|
||||
view: Literal["personal", "tenant"] = Query(default="personal"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> NotificationResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
try:
|
||||
return _response(get_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id))
|
||||
return _response(
|
||||
get_notification(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
notification_id=notification_id,
|
||||
recipient_ids=_recipient_ids_for_view(principal, view),
|
||||
)
|
||||
)
|
||||
except NotificationError as exc:
|
||||
raise _notification_http_error(exc) from exc
|
||||
|
||||
@@ -151,12 +199,19 @@ def api_get_notification(
|
||||
def api_update_notification(
|
||||
notification_id: str,
|
||||
payload: NotificationUpdateRequest,
|
||||
view: Literal["personal", "tenant"] = Query(default="personal"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> NotificationResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
try:
|
||||
notification = update_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id, payload=payload)
|
||||
notification = update_notification(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
notification_id=notification_id,
|
||||
payload=payload,
|
||||
recipient_ids=_recipient_ids_for_view(principal, view),
|
||||
)
|
||||
except NotificationError as exc:
|
||||
raise _notification_http_error(exc) from exc
|
||||
session.commit()
|
||||
@@ -171,6 +226,7 @@ def api_deliver_notification(
|
||||
) -> NotificationDeliveryResultResponse:
|
||||
_require_scope(principal, DISPATCH_SCOPE)
|
||||
try:
|
||||
get_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id)
|
||||
notification = deliver_notification(session, notification_id=notification_id)
|
||||
except NotificationError as exc:
|
||||
raise _notification_http_error(exc) from exc
|
||||
|
||||
Reference in New Issue
Block a user