diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 5d08fc6877..5e638ba8a9 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -58,8 +58,6 @@ from .models import AdapterInstance, UserDefaultAdapter notification_plugin = get_plugin("notification") -if notification_plugin: - from plugins.notification.constants import ResourceType logger = logging.getLogger(__name__) @@ -162,12 +160,11 @@ class AdapterInstanceViewSet( def get_notification_resource_type(self, resource: Any) -> str | None: if not notification_plugin: return None - return { - "LLM": ResourceType.LLM.value, - "EMBEDDING": ResourceType.EMBEDDING.value, - "VECTOR_DB": ResourceType.VECTOR_DB.value, - "X2TEXT": ResourceType.X2TEXT.value, - }.get(resource.adapter_type, ResourceType.LLM.value) + from tenant_account_v2.notification_resource_types import ( + adapter_notification_type, + ) + + return adapter_notification_type(resource.adapter_type) def get_permissions(self) -> list[Any]: # Frictionless adapters: hidden from non-owners (update/retrieve), @@ -410,17 +407,6 @@ def destroy( raise DeleteAdapterInUseError(adapter_name=adapter_instance.adapter_name) return Response(status=status.HTTP_204_NO_CONTENT) - def partial_update( - self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any] - ) -> Response: - adapter = self.get_object() - before = self.snapshot_share_axes(adapter) - - response = super().partial_update(request, *args, **kwargs) - if response.status_code == 200 and notification_plugin: - self._notify_shared_users(adapter, before, request.data, request.user) - return response - @action(detail=True, methods=["post"], url_path="share") def share(self, request: Request, pk: str | None = None) -> Response: """Apply share state, then clear default-adapter links for any user @@ -467,42 +453,6 @@ def on_owner_removed(self, resource: AdapterInstance, user: User) -> None: return self._clear_default_adapter_for_removed_users(resource, {user.pk}) - def _notify_shared_users( - self, - adapter: AdapterInstance, - before: dict[str, set[Any]], - request_data: dict[str, Any], - actor: Any, - ) -> None: - """Email users newly added to ``shared_users`` (best-effort).""" - users_diff = self.diff_share_axes(adapter, before, request_data).get( - "shared_users" - ) - if not (users_diff and users_diff.added): - return - try: - adapter_type_to_resource = { - "LLM": ResourceType.LLM.value, - "EMBEDDING": ResourceType.EMBEDDING.value, - "VECTOR_DB": ResourceType.VECTOR_DB.value, - "X2TEXT": ResourceType.X2TEXT.value, - } - resource_type = adapter_type_to_resource.get( - adapter.adapter_type, ResourceType.LLM.value - ) - service_class = notification_plugin["service_class"] - notification_service = service_class() - notification_service.send_sharing_notification( - resource_type=resource_type, - resource_name=adapter.adapter_name, - resource_id=str(adapter.id), - shared_by=actor, - shared_to=list(users_diff.added), - resource_instance=adapter, - ) - except Exception as e: - logger.exception("Failed to send sharing notification: %s", e) - def _clear_default_adapter_for_removed_users( self, adapter: AdapterInstance, diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 891ed1c570..842bce8416 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -466,40 +466,3 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo instance = self.get_object() serializer = SharedUserListSerializer(instance) return Response(serializer.data) - - def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Override partial_update to handle sharing notifications.""" - instance = self.get_object() - before = self.snapshot_share_axes(instance) - - response = super().partial_update(request, *args, **kwargs) - if response.status_code == 200 and notification_plugin: - self._notify_shared_users(instance, before, request.data, request.user) - return response - - def _notify_shared_users( - self, - instance: APIDeployment, - before: dict[str, set[Any]], - request_data: dict[str, Any], - actor: Any, - ) -> None: - """Email users newly added to ``shared_users`` (best-effort).""" - users_diff = self.diff_share_axes(instance, before, request_data).get( - "shared_users" - ) - if not (users_diff and users_diff.added): - return - try: - service_class = notification_plugin["service_class"] - notification_service = service_class() - notification_service.send_sharing_notification( - resource_type=ResourceType.API_DEPLOYMENT.value, - resource_name=instance.display_name, - resource_id=str(instance.id), - shared_by=actor, - shared_to=list(users_diff.added), - resource_instance=instance, - ) - except Exception as e: - logger.exception("Failed to send sharing notification: %s", e) diff --git a/backend/backend/internal_base_urls.py b/backend/backend/internal_base_urls.py index 30d4c8df22..c7022affb5 100644 --- a/backend/backend/internal_base_urls.py +++ b/backend/backend/internal_base_urls.py @@ -276,4 +276,10 @@ def test_middleware_debug(request): include("prompt_studio.prompt_studio_core_v2.internal_urls"), name="prompt_studio_internal", ), + # Group-sharing email notification APIs + path( + "v1/group-notification/", + include("tenant_account_v2.internal_urls"), + name="group_notification_internal", + ), ] diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index 28f8038213..ac60db475b 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -35,7 +35,6 @@ notification_plugin = get_plugin("notification") if notification_plugin: from plugins.notification.constants import ResourceType - from plugins.notification.sharing_notification import SharingNotificationService logger = logging.getLogger(__name__) @@ -283,45 +282,3 @@ def perform_destroy(self, instance: ConnectorInstance) -> None: f" named {instance.connector_name}" ) raise DeleteConnectorInUseError(connector_name=instance.connector_name) - - def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Override to handle sharing notifications.""" - instance = self.get_object() - before = self.snapshot_share_axes(instance) - - response = super().partial_update(request, *args, **kwargs) - if response.status_code == 200 and notification_plugin: - self._notify_shared_users(instance, before, request.data, request.user) - return response - - def _notify_shared_users( - self, - instance: ConnectorInstance, - before: dict[str, set[Any]], - request_data: dict[str, Any], - actor: Any, - ) -> None: - """Email users newly added to ``shared_users`` (best-effort).""" - users_diff = self.diff_share_axes(instance, before, request_data).get( - "shared_users" - ) - if not (users_diff and users_diff.added): - return - try: - SharingNotificationService().send_sharing_notification( - resource_type=ResourceType.CONNECTOR.value, - resource_name=instance.connector_name, - resource_id=str(instance.id), - shared_by=actor, - shared_to=list(users_diff.added), - resource_instance=instance, - ) - logger.info( - "Sent sharing notifications for connector to %d users", - len(users_diff.added), - ) - except Exception as e: - logger.exception( - "Failed to send sharing notification, continuing update though: %s", - str(e), - ) diff --git a/backend/permissions/membership_views.py b/backend/permissions/membership_views.py index e9ade97e9c..6c296beb30 100644 --- a/backend/permissions/membership_views.py +++ b/backend/permissions/membership_views.py @@ -8,7 +8,7 @@ from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response -from tenant_account_v2.sharing_helpers import serialize_owner_refs +from tenant_account_v2.sharing_helpers import retained_user_ids, serialize_owner_refs from permissions.membership_serializers import AddOwnerSerializer, RemoveOwnerSerializer @@ -86,6 +86,11 @@ def _owner_refs(resource: Any) -> list[dict[str, Any]]: # --- notifications: reuse the user-sharing service, best-effort --- def _notification_context(self, resource: Any) -> tuple[str, str] | None: + """``(resource_type, resource_name)``, or ``None`` if not notifiable. + + Also used by ``ResourceShareManagementMixin``, which every host mixes + in alongside this one. + """ if not notification_plugin or not self.notification_resource_name_field: return None resource_type = self.get_notification_resource_type(resource) @@ -115,6 +120,13 @@ def _notify_owner_removed(self, resource: Any, user: User, actor: Any) -> None: ctx = self._notification_context(resource) if ctx is None: return + # The OWNER row is already gone by the time this runs (serializer.save() + # ran first) -- but the demoted user may still reach the resource another + # way (a group, a direct share, an org-wide share, org admin), same as + # the direct-share and group-revoke paths already check. + retained = retained_user_ids(resource) + if retained is None or user.pk in retained: + return resource_type, resource_name = ctx try: notification_plugin["service_class"]().send_access_removed_notification( diff --git a/backend/permissions/resource_share_views.py b/backend/permissions/resource_share_views.py index a38ff400f1..a042f5d782 100644 --- a/backend/permissions/resource_share_views.py +++ b/backend/permissions/resource_share_views.py @@ -1,22 +1,28 @@ """Shared share-management surface for resource ViewSets. -The mixin is **axis-agnostic** — it operates over the sharing "axes" declared -in :attr:`ResourceShareManagementMixin.share_axes`. ``shared_users`` is an M2M -on the resource model, while ``shared_groups`` is stored polymorphically in -``ResourceGroupShare`` (not an M2M) and routed through the sharing helpers; new -axes can be added by extending that attribute. +The write side accepts exactly the three axes named in +``_SUPPORTED_SHARE_AXES``; the read side (``_read_axis``) only knows the two +per-recipient ones by name. ``shared_users`` is the direct-viewer axis, backed +by VIEWER membership rows, while ``shared_groups`` is stored polymorphically in +``ResourceGroupShare`` (not an M2M) and routed through the sharing helpers. """ +import logging from dataclasses import dataclass, field from typing import Any, ClassVar from django.db.models import Model +from plugins import get_plugin from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.request import Request from rest_framework.response import Response +logger = logging.getLogger(__name__) + +notification_plugin = get_plugin("notification") + _SUPPORTED_SHARE_AXES = ("shared_users", "shared_groups", "shared_to_org") @@ -55,9 +61,71 @@ def _coerce_id_list(axis: str, value: Any) -> list[int]: return coerced +def _users_left_without_access(instance: Model, users: set[Any]) -> list[Any]: + """Narrow ``users`` to those with no remaining access to ``instance``. + + Someone dropped from ``shared_users`` may still reach the resource via a + group or an org-wide share; telling them their access was removed would be + wrong. + """ + if not users: + return [] + from tenant_account_v2.sharing_helpers import retained_user_ids + + retained = retained_user_ids(instance) + if retained is None: + # Org-wide or frictionless: access never depended on the share, so + # nobody lost anything. + return [] + return [user for user in users if user.pk not in retained] + + +def _send_share_notification( + instance: Model, context: tuple[str, str], users: set[Any], actor: Any +) -> None: + """Email users newly granted direct access. Best-effort. + + Sent inline, matching every other direct-share call site in the codebase + (pipelines, API deployments, connectors, ...). Group shares, by contrast, + dispatch through PGMQ (see ``share_notifications.notify_resource_group_share_changed``) + because a group's member count -- and so its send time -- is unbounded in + a way a handful of direct users is not. + """ + resource_type, resource_name = context + try: + notification_plugin["service_class"]().send_sharing_notification( + resource_type=resource_type, + resource_name=resource_name, + resource_id=str(instance.pk), + shared_by=actor, + shared_to=list(users), + resource_instance=instance, + ) + except Exception: + logger.exception("Failed to send sharing notification for %s", instance.pk) + + +def _send_revoke_notification( + instance: Model, context: tuple[str, str], users: list[Any], actor: Any +) -> None: + """Email users whose direct access was revoked. Best-effort.""" + resource_type, resource_name = context + try: + notification_plugin["service_class"]().send_access_removed_notification( + resource_type=resource_type, + resource_name=resource_name, + resource_id=str(instance.pk), + removed_from=users, + removed_by=actor, + resource_instance=instance, + ) + except Exception: + logger.exception("Failed to send access-removed notification for %s", instance.pk) + + @dataclass class AxisDiff: - """Pre/post snapshot for a single share axis (M2M field).""" + """DEPRECATED shim -- see ``ResourceShareManagementMixin.share_axes`` below.""" before: set[Any] = field(default_factory=set) after: set[Any] = field(default_factory=set) @@ -72,14 +140,36 @@ def removed(self) -> set[Any]: class ResourceShareManagementMixin: - """Adds the shared share-management surface to a resource ViewSet. - - Subclasses declare share axes via :attr:`share_axes`. The default - covers ``shared_users`` + ``shared_groups``. - """ + """Adds the shared share-management surface to a resource ViewSet.""" + # DEPRECATED, temporary: ``share_axes``, ``snapshot_share_axes`` and + # ``diff_share_axes`` (with ``AxisDiff`` above) were removed on this branch + # -- the PATCH-based sharing path they backed is dead, its diff is always + # empty (see 999e443b4). Restored here only because cloud's + # ``AgenticProjectViewSet.partial_update`` on ``origin/main`` still calls + # them, and OSS merges before cloud (Zipstack/unstract-cloud#1698 carries + # the real removal). Delete this whole block, and this comment, once #1698 + # merges -- at that point nothing on cloud main calls it anymore. share_axes: ClassVar[tuple[str, ...]] = ("shared_users", "shared_groups") + def snapshot_share_axes(self, instance: Model) -> dict[str, set[Any]]: + """DEPRECATED shim. See the block comment above ``share_axes``.""" + return {axis: self._read_axis(instance, axis) for axis in self.share_axes} + + def diff_share_axes( + self, + instance: Model, + before: dict[str, set[Any]], + request_data: dict[str, Any], + ) -> dict[str, AxisDiff]: + """DEPRECATED shim. See the block comment above ``share_axes``.""" + instance.refresh_from_db() + return { + axis: AxisDiff(before=before[axis], after=self._read_axis(instance, axis)) + for axis in self.share_axes + if axis in request_data + } + @action(detail=True, methods=["post"], url_path="share") def share(self, request: Request, pk: str | None = None) -> Response: """Apply a replace-style share state for the resource. @@ -90,15 +180,87 @@ def share(self, request: Request, pk: str | None = None) -> Response: users, group-membership for groups) live in ``ShareAuthorizationService``. """ + from tenant_account_v2.share_notifications import ( + notify_resource_group_share_changed, + ) from tenant_account_v2.sharing_helpers import ShareAuthorizationService resource = self.get_object() # type: ignore[attr-defined] desired = _extract_desired_share_state(request.data) + # Only read an axis this request actually touches. authorize_and_commit + # takes long enough (auth checks, a DB write) that a concurrent request + # changing an axis this one left alone would otherwise land inside the + # window and get diffed as if this request made the change — the wrong + # actor's name in the notification. + users_before = ( + self._read_axis(resource, "shared_users") + if "shared_users" in desired + else set() + ) + groups_before = ( + self._read_axis(resource, "shared_groups") + if "shared_groups" in desired + else set() + ) ShareAuthorizationService.authorize_and_commit( actor=request.user, resource=resource, desired=desired ) + # ``authorize_and_commit`` has already committed here, on the current + # deployment: ``ATOMIC_REQUESTS`` is a settings knob, currently off, so + # this view isn't wrapped in a transaction and the diffs below read + # persisted state. Flipping that knob would flip this premise too. + resource.refresh_from_db() + # Only the two per-recipient axes notify. ``shared_to_org`` is left out + # deliberately: a toggle has no recipient list short of the whole org, + # and it is read below as a reason someone KEPT access, not lost it. + users_after = ( + self._read_axis(resource, "shared_users") + if "shared_users" in desired + else users_before + ) + groups_after = ( + self._read_axis(resource, "shared_groups") + if "shared_groups" in desired + else groups_before + ) + notify_resource_group_share_changed( + resource=resource, + added=groups_after - groups_before, + removed=groups_before - groups_after, + actor=request.user, + ) + self._notify_shared_users( + resource, users_after - users_before, users_before - users_after, request.user + ) return Response(status=status.HTTP_200_OK) + def _notify_shared_users( + self, + instance: Any, + added: set[Any], + removed: set[Any], + actor: Any, + /, + ) -> None: + """Email users granted or denied direct access. Best-effort. + + Resource type and name come from the host's ``OwnerManagementMixin`` + seam. The share has already committed by the time this runs, so no + failure here — a raising seam, a dropped DB connection — may surface + as a 500 on a share that succeeded. + """ + try: + context = self._notification_context(instance) # type: ignore[attr-defined] + if context is None: + return + if added: + _send_share_notification(instance, context, added, actor) + revoked = _users_left_without_access(instance, removed) + if revoked: + _send_revoke_notification(instance, context, revoked, actor) + except Exception: + logger.exception("Failed to send share notifications for %s", instance.pk) + @action(detail=True, methods=["get"], url_path="effective-members") def effective_members(self, request: Request, pk: str | None = None) -> Response: """Return all users with access (direct/group/org), priority-deduped.""" @@ -112,36 +274,6 @@ def effective_members(self, request: Request, pk: str | None = None) -> Response members = compute_effective_members(self.get_object()) # type: ignore[attr-defined] return Response(EffectiveMemberSerializer(members, many=True).data) - def snapshot_share_axes(self, instance: Model) -> dict[str, set[Any]]: - """Capture every declared axis's current contents. - - Call BEFORE ``super().partial_update(...)``; pair with - :meth:`diff_share_axes` afterward. - """ - return {axis: self._read_axis(instance, axis) for axis in self.share_axes} - - def diff_share_axes( - self, - instance: Model, - before: dict[str, set[Any]], - request_data: dict[str, Any], - ) -> dict[str, AxisDiff]: - """Diff each axis that was touched by the request. - - Returns a dict keyed by axis name with only the axes present in - ``request_data`` — callers can skip notification fan-out for axes - the client did not modify. - """ - instance.refresh_from_db() - return { - axis: AxisDiff( - before=before[axis], - after=self._read_axis(instance, axis), - ) - for axis in self.share_axes - if axis in request_data - } - @staticmethod def _read_axis(instance: Model, axis: str) -> set[Any]: """Return the current set of related objects on the given axis. diff --git a/backend/permissions/tests/test_owner_management.py b/backend/permissions/tests/test_owner_management.py index 1b1b124a9c..b5804799f7 100644 --- a/backend/permissions/tests/test_owner_management.py +++ b/backend/permissions/tests/test_owner_management.py @@ -16,7 +16,7 @@ import pytest from account_v2.models import User from django.test import TestCase -from permissions.roles import ResourceRole +from prompt_studio.permission import ParentToolAccess from rest_framework import status from rest_framework.parsers import JSONParser from rest_framework.request import Request as DRFRequest @@ -27,7 +27,7 @@ from workflow_manager.workflow_v2.views import WorkflowViewSet from permissions.membership_serializers import AddOwnerSerializer -from prompt_studio.permission import ParentToolAccess +from permissions.roles import ResourceRole from permissions.tests.base import ( RESOURCE_SPECS, CoOwnerOrgTestMixin, @@ -126,7 +126,8 @@ def test_can_remove_when_multiple_owners(self) -> None: def test_remove_rejects_service_account(self) -> None: """Symmetric with the add-side guard: a service-account owner cannot be - removed, so it can't be stranded off the resource (UN-2202 review #7).""" + removed, so it can't be stranded off the resource (UN-2202 review #7). + """ svc = make_user("svc@example.com", is_service_account=True) self.workflow.memberships.create(user=svc, role=ResourceRole.OWNER) response = self._remove(self.owner, svc.pk) @@ -136,7 +137,8 @@ def test_remove_rejects_service_account(self) -> None: def test_shared_viewer_can_list_shared_users(self) -> None: """``list_of_shared_users`` is viewer-tier by design (spec §10): a shared user may open the owner popup. Guards against a re-tighten to IsOwner - (UN-2202 review #6).""" + (UN-2202 review #6). + """ self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) view = WorkflowViewSet.as_view({"get": "list_of_shared_users"}) request = self.factory.get("/x/") @@ -193,7 +195,8 @@ def test_has_members_mixin_accessors(self) -> None: def test_service_account_excluded_from_owner_roster(self) -> None: """A service-account OWNER row is a real grant but must not surface as a - removable co-owner or inflate the count badge (UN-2202 review #7).""" + removable co-owner or inflate the count badge (UN-2202 review #7). + """ svc = make_user("svc@example.com", is_service_account=True) self.workflow.memberships.create(user=self.coowner, role=ResourceRole.OWNER) self.workflow.memberships.create(user=svc, role=ResourceRole.OWNER) @@ -203,9 +206,7 @@ def test_service_account_excluded_from_owner_roster(self) -> None: self.assertEqual(self.workflow.co_owners_count(), 2) # the filter is display-only — the SA still genuinely owns the resource self.assertTrue( - self.workflow.memberships.filter( - user=svc, role=ResourceRole.OWNER - ).exists() + self.workflow.memberships.filter(user=svc, role=ResourceRole.OWNER).exists() ) def test_membership_save_derives_organization(self) -> None: @@ -218,7 +219,8 @@ def test_membership_save_derives_organization(self) -> None: class CrossResourceOwnerManagementTests(CoOwnerOrgTestMixin, TestCase): """The shared owner-management surface behaves identically for every OSS - shareable resource (AgenticProject is covered cloud-side).""" + shareable resource (AgenticProject is covered cloud-side). + """ def setUp(self) -> None: self._seed_org() @@ -255,7 +257,8 @@ class OwnerNotificationWiringTests(CoOwnerOrgTestMixin, TestCase): notifications with the right payload, and swallow notification failures so the owners request is never broken (best-effort). The resource-type hook is patched to a fixed value so the test does not depend on the cloud-only - notification plugin's conditional ``ResourceType`` import.""" + notification plugin's conditional ``ResourceType`` import. + """ def setUp(self) -> None: self._seed_org() @@ -313,6 +316,20 @@ def test_remove_fires_removed_notification_with_payload(self) -> None: self.assertEqual(kwargs["resource_id"], str(self.workflow.pk)) self.assertEqual(kwargs["resource_instance"], self.workflow) + def test_remove_skips_notification_when_demoted_owner_retains_access(self) -> None: + """Demoted from OWNER, but an org admin still reaches every resource -- + nothing was actually taken from them, so no "access removed" email. + """ + self.workflow.memberships.create(user=self.admin, role=ResourceRole.OWNER) + with patch( + "account_v2.authentication_controller.AuthenticationController" + ".is_admin_by_role", + side_effect=lambda role: role == "admin", + ): + response = self._remove(self.owner, self.admin.pk) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.service.send_access_removed_notification.assert_not_called() + def test_notification_failure_does_not_break_add(self) -> None: self.service.send_co_owner_added_notification.side_effect = RuntimeError("boom") response = self._add(self.owner, self.coowner.pk) @@ -369,9 +386,7 @@ def test_parent_tool_viewer_allowed_outsider_denied(self) -> None: def test_null_parent_falls_back_to_object_creator(self) -> None: # No parent tool → access derives from the object's own ``created_by``. - orphan = SimpleNamespace( - prompt_studio_tool=None, created_by_id=self.owner.pk - ) + orphan = SimpleNamespace(prompt_studio_tool=None, created_by_id=self.owner.pk) self.assertTrue(self._perm(self.owner, orphan)) self.assertFalse(self._perm(self.coowner, orphan)) @@ -425,9 +440,7 @@ def test_owner_exempt_from_default_adapter_clear(self) -> None: before = {self.owner.pk, self.coowner.pk, self.viewer.pk} after = {self.owner.pk} with ( - patch.object( - AdapterInstanceViewSet, "get_object", return_value=self.adapter - ), + patch.object(AdapterInstanceViewSet, "get_object", return_value=self.adapter), patch.object( AdapterInstanceViewSet, "_effective_member_ids", @@ -566,9 +579,7 @@ def test_adapter_create_grants_creator_ownership(self) -> None: from adapter_processor_v2.views import AdapterInstanceViewSet # Only the SDK context-window lookup (a provider-shaped call) is mocked. - with patch.object( - AdapterInstance, "get_context_window_size", return_value=4096 - ): + with patch.object(AdapterInstance, "get_context_window_size", return_value=4096): response = self._create( AdapterInstanceViewSet, { @@ -645,7 +656,8 @@ def test_api_deployment_create_grants_creator_ownership(self) -> None: def test_import_path_grants_creator_ownership(self) -> None: """The 7th grant site — ``create_tool_from_import_data`` — is a helper - the viewset sweep can't reach; pin it directly.""" + the viewset sweep can't reach; pin it directly. + """ from prompt_studio.prompt_studio_core_v2.models import CustomTool from prompt_studio.prompt_studio_core_v2.prompt_studio_helper import ( PromptStudioHelper, @@ -659,3 +671,35 @@ def test_import_path_grants_creator_ownership(self) -> None: ) self.assertTrue(tool.is_owner(self.coowner)) self.assertIn(tool, CustomTool.objects.for_user(self.coowner)) + + +class DeprecatedAxisShimTests(CoOwnerOrgTestMixin, TestCase): + """The temporary ``share_axes``/``snapshot_share_axes``/``diff_share_axes`` + shim (see the block comment in ``resource_share_views.py``) -- restored + only so cloud's still-stale ``origin/main`` ``AgenticProjectViewSet`` + doesn't ``AttributeError`` once this branch merges and deletes the real + thing on OSS main. Delete this test alongside that block, once + Zipstack/unstract-cloud#1698 merges. + """ + + def setUp(self) -> None: + self._seed_org() + self.workflow = Workflow.objects.create( + workflow_name="wf-shim", organization=self.org, created_by=self.owner + ) + self.workflow.memberships.create(user=self.owner, role=ResourceRole.OWNER) + + def test_snapshot_then_diff_reproduces_the_original_always_empty_shape(self) -> None: + # Matches the pre-removal contract exactly: the PATCH path this shim + # exists for has no way to actually change shared_users/shared_groups + # (see 999e443b4), so the diff for either axis is always empty. + view = WorkflowViewSet() + before = view.snapshot_share_axes(self.workflow) + self.assertEqual(before, {"shared_users": set(), "shared_groups": set()}) + diff = view.diff_share_axes( + self.workflow, before, {"shared_users": [], "shared_groups": []} + ) + self.assertEqual(set(diff), {"shared_users", "shared_groups"}) + for axis_diff in diff.values(): + self.assertEqual(axis_diff.added, set()) + self.assertEqual(axis_diff.removed, set()) diff --git a/backend/permissions/tests/test_share_notifications.py b/backend/permissions/tests/test_share_notifications.py new file mode 100644 index 0000000000..3c7b7f1794 --- /dev/null +++ b/backend/permissions/tests/test_share_notifications.py @@ -0,0 +1,104 @@ +"""Integration tests for direct-user share/revoke email wiring (UN-3494). + +The ``share/`` endpoint's ``shared_users`` axis mails users who gained or lost +direct access. Both notification seams are mocked, so these pin the wiring and +the payload — who is mailed, with what, and that a failing send never breaks a +share that already committed — not template or transport behavior. The group +axis is covered by ``ResourceShareNotificationTests`` in +``tenant_account_v2.tests``. + +DB-backed (Django ``TestCase``), so ``backend/conftest.py`` auto-marks these +``integration`` and the rig runs them in ``integration-backend``. +""" + +from unittest.mock import Mock, patch + +from account_v2.models import User +from django.test import TestCase +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory, force_authenticate +from workflow_manager.workflow_v2.models.workflow import Workflow +from workflow_manager.workflow_v2.views import WorkflowViewSet + +from permissions.roles import ResourceRole +from permissions.tests.base import CoOwnerOrgTestMixin + + +class DirectShareNotificationWiringTests(CoOwnerOrgTestMixin, TestCase): + """``POST share/`` mails users whose direct access was granted or revoked.""" + + def setUp(self) -> None: + self._seed_org() + self.workflow = Workflow.objects.create( + workflow_name="wf-1", organization=self.org, created_by=self.owner + ) + self.workflow.memberships.create(user=self.owner, role=ResourceRole.OWNER) + self.factory = APIRequestFactory() + self.service = Mock() + plugin = {"service_class": Mock(return_value=self.service)} + for p in ( + # The sender lives in the share mixin; ``_notification_context`` + # gates on the membership_views copy, so both need the plugin. + patch("permissions.resource_share_views.notification_plugin", plugin), + patch("permissions.membership_views.notification_plugin", plugin), + patch.object( + WorkflowViewSet, + "get_notification_resource_type", + return_value="workflow", + ), + ): + p.start() + self.addCleanup(p.stop) + + def _share(self, actor: User, payload: dict) -> Response: + view = WorkflowViewSet.as_view({"post": "share"}) + request = self.factory.post("/x/", payload, format="json") + force_authenticate(request, user=actor) + return view(request, pk=str(self.workflow.pk)) + + def test_granting_direct_access_fires_sharing_notification(self) -> None: + response = self._share(self.owner, {"shared_users": [self.viewer.pk]}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.service.send_sharing_notification.assert_called_once() + kwargs = self.service.send_sharing_notification.call_args.kwargs + self.assertEqual(kwargs["resource_type"], "workflow") + self.assertEqual(kwargs["resource_name"], "wf-1") + self.assertEqual(kwargs["resource_id"], str(self.workflow.pk)) + self.assertEqual(kwargs["shared_by"], self.owner) + self.assertEqual([u.pk for u in kwargs["shared_to"]], [self.viewer.pk]) + self.assertEqual(kwargs["resource_instance"], self.workflow) + self.service.send_access_removed_notification.assert_not_called() + + def test_revoking_direct_access_fires_access_removed_notification(self) -> None: + self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) + response = self._share(self.owner, {"shared_users": []}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.service.send_access_removed_notification.assert_called_once() + kwargs = self.service.send_access_removed_notification.call_args.kwargs + self.assertEqual(kwargs["resource_type"], "workflow") + self.assertEqual([u.pk for u in kwargs["removed_from"]], [self.viewer.pk]) + self.assertEqual(kwargs["removed_by"], self.owner) + self.assertEqual(kwargs["resource_id"], str(self.workflow.pk)) + self.service.send_sharing_notification.assert_not_called() + + def test_revoke_is_silent_when_the_user_keeps_access_another_way(self) -> None: + # Dropped from ``shared_users`` but still covered by the org-wide share — + # nothing was lost, so telling them it was removed would be wrong. + self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) + response = self._share(self.owner, {"shared_users": [], "shared_to_org": True}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.service.send_access_removed_notification.assert_not_called() + + def test_notification_failure_does_not_break_the_share(self) -> None: + # The share commits before the mail goes out; a raising sender must not + # surface as a 500 on a share that succeeded. + self.service.send_sharing_notification.side_effect = RuntimeError("boom") + response = self._share(self.owner, {"shared_users": [self.viewer.pk]}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + viewer_ids = set( + self.workflow.memberships.filter(role=ResourceRole.VIEWER).values_list( + "user_id", flat=True + ) + ) + self.assertIn(self.viewer.pk, viewer_ids) diff --git a/backend/pipeline_v2/views.py b/backend/pipeline_v2/views.py index 27c800518f..6ac0597e0c 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -42,8 +42,6 @@ from pipeline_v2.serializers.sharing import SharedUserListSerializer notification_plugin = get_plugin("notification") -if notification_plugin: - from plugins.notification.constants import ResourceType logger = logging.getLogger(__name__) @@ -61,12 +59,13 @@ class PipelineViewSet( notification_resource_name_field = "pipeline_name" def get_notification_resource_type(self, resource: Any) -> str | None: - # Only ETL/TASK pipelines map to a notification ResourceType. if not notification_plugin: return None - if resource.pipeline_type in (ResourceType.ETL.value, ResourceType.TASK.value): - return resource.pipeline_type - return None + from tenant_account_v2.notification_resource_types import ( + pipeline_notification_type, + ) + + return pipeline_notification_type(resource.pipeline_type) def get_permissions(self) -> list[Any]: # Enabling or disabling is use, not configuration, so it follows @@ -194,60 +193,6 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo serializer = SharedUserListSerializer(pipeline) return Response(serializer.data, status=status.HTTP_200_OK) - def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Override to handle sharing notifications.""" - instance = self.get_object() - before = self.snapshot_share_axes(instance) - - response = super().partial_update(request, *args, **kwargs) - if response.status_code == 200 and notification_plugin: - self._notify_shared_users(instance, before, request.data, request.user) - return response - - def _notify_shared_users( - self, - instance: Pipeline, - before: dict[str, set[Any]], - request_data: dict[str, Any], - actor: Any, - ) -> None: - """Email users newly added to ``shared_users`` (best-effort). - - Only ETL/TASK pipelines map to a notification ``ResourceType``; - DEFAULT/APP pipelines have no analogue and skip the fan-out. - """ - users_diff = self.diff_share_axes(instance, before, request_data).get( - "shared_users" - ) - if not (users_diff and users_diff.added): - return - if instance.pipeline_type not in ( - ResourceType.ETL.value, - ResourceType.TASK.value, - ): - return - try: - service_class = notification_plugin["service_class"] - notification_service = service_class() - notification_service.send_sharing_notification( - resource_type=instance.pipeline_type, - resource_name=instance.pipeline_name, - resource_id=str(instance.id), - shared_by=actor, - shared_to=list(users_diff.added), - resource_instance=instance, - ) - logger.info( - "Sent sharing notifications for %s to %d users", - instance.pipeline_type, - len(users_diff.added), - ) - except Exception as e: - logger.exception( - "Failed to send sharing notification, continuing update though: %s", - str(e), - ) - @action(detail=True, methods=["get"]) def download_postman_collection( self, request: Request, pk: str | None = None diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 6da770a060..2c638c87ae 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -280,54 +280,6 @@ def destroy( ) return super().destroy(request, *args, **kwargs) - def partial_update( - self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any] - ) -> Response: - custom_tool = self.get_object() - before = self.snapshot_share_axes(custom_tool) - - response = super().partial_update(request, *args, **kwargs) - if response.status_code == 200: - self._notify_shared_users(custom_tool, before, request.data, request.user) - return response - - def _notify_shared_users( - self, - custom_tool: CustomTool, - before: dict[str, set[Any]], - request_data: dict[str, Any], - actor: Any, - ) -> None: - """Email users newly added to ``shared_users`` (best-effort).""" - notification_plugin = get_plugin("notification") - if not notification_plugin: - return - users_diff = self.diff_share_axes(custom_tool, before, request_data).get( - "shared_users" - ) - if not (users_diff and users_diff.added): - return - - from plugins.notification.constants import ResourceType - - try: - service_class = notification_plugin["service_class"] - notification_service = service_class() - notification_service.send_sharing_notification( - resource_type=ResourceType.TEXT_EXTRACTOR.value, - resource_name=custom_tool.tool_name, - resource_id=str(custom_tool.tool_id), - shared_by=actor, - shared_to=list(users_diff.added), - resource_instance=custom_tool, - ) - except Exception as e: - logger.exception( - "Failed to send sharing notification for custom tool %s: %s", - custom_tool.tool_id, - str(e), - ) - @action(detail=True, methods=["get"]) def get_select_choices(self, request: HttpRequest) -> Response: """Method to return all static dropdown field values. diff --git a/backend/tenant_account_v2/group_notification_service.py b/backend/tenant_account_v2/group_notification_service.py new file mode 100644 index 0000000000..c95929ca07 --- /dev/null +++ b/backend/tenant_account_v2/group_notification_service.py @@ -0,0 +1,502 @@ +"""Send-side logic for group-sharing email notifications. + +Reached over the internal API by the notification worker. The enqueue side +(:mod:`tenant_account_v2.share_notifications`) only records *what happened*; +everything that needs Django — group expansion, org re-validation, resource +lookup, the email plugin — happens here, because ``workers/`` has no Django. + +Sending is a cloud plugin. In OSS ``notification_plugin`` is empty and every +entry point below no-ops cleanly. +""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from account_v2.models import Organization, User +from django.apps import apps +from django.conf import settings +from django.db.models import QuerySet +from plugins import get_plugin + +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, + OrganizationMember, +) +from tenant_account_v2.notification_resource_types import ( + adapter_notification_type, + pipeline_notification_type, +) +from tenant_account_v2.share_notifications import MembershipAction, ShareAction +from tenant_account_v2.shareable_resources import ShareableResource, descriptor_for_kind + +if TYPE_CHECKING: + from collections.abc import Iterable + from datetime import datetime + +logger = logging.getLogger(__name__) + +notification_plugin = get_plugin("notification") + +# Ceiling on concurrent per-group sends, mirroring the email plugin's own +# ``MAX_CONCURRENT_CHUNK_SENDS`` -- a resource shared with many groups would +# otherwise serialize one blocking SendGrid call per group. +_MAX_CONCURRENT_GROUP_SENDS = 10 + +# OSS ``ShareableResource.kind`` → the email plugin's ``ResourceType`` value, +# for the 6 kinds that are a plain 1:1 rename. Pipelines and adapters resolve +# via ``notification_resource_types`` instead, the same helpers the +# direct-share viewsets use, so a new adapter/pipeline type only needs +# registering once. These 6 are still a second, hand-maintained copy of what +# each ViewSet's own ``get_notification_resource_type`` already states -- +# unifying them the same way is a larger change than this one, tracked +# separately. Plain strings here, not the cloud enum: this dict is only ever +# read once the plugin is confirmed loaded (see ``_service()``), but nothing +# enforces that path if a future caller reached it another way. +_STATIC_RESOURCE_TYPES = { + "workflow": "workflow", + "api_deployment": "api", + "connector_instance": "connector", + "custom_tool": "text_extractor", + "agentic_project": "agentic_project", + "lookup": "lookup", +} + + +class ResourceNotFoundError(Exception): + """The shared resource no longer exists, or is not in the given org.""" + + +@dataclass(frozen=True) +class _SharedResource: + """A resolved resource, reused across every group email in one task.""" + + instance: Any + name: str + type: str | None + + +def send_resource_shared( + *, + organization: Organization, + group_ids: Iterable[int], + actor_id: int, + resource_kind: str, + resource_id: str, + share_action: str, + revoked_at: datetime | None = None, +) -> bool: + """Mail every current member of each group whose resource access changed. + + One email per group, so ``group_name`` in the template is always the group + the recipient actually belongs to. ``share_action`` picks the wording, and + on a revoke ``revoked_at`` bounds who counts as "current". + + Returns: + ``False`` only when a group with real recipients was actually attempted + and the plugin reported a send failure -- the caller's cue to ask for + redelivery. Every other outcome (nothing to send, plugin absent, + misconfigured) is ``True``: there is nothing a retry would fix. + """ + service = _service() + if service is None: + return True + resolved = _resolve_share(organization, actor_id, resource_kind, resource_id) + if resolved is None: + return True + actor, shared = resolved + retained = _retained_user_ids(organization, shared.instance, share_action) + if retained is None: + return True + groups = list(_groups_to_mail(organization, group_ids, shared.instance, share_action)) + recipients_by_group = _group_recipients_batch( + organization, groups, retained, revoked_at + ) + return _mail_all_groups( + service, groups, recipients_by_group, shared, actor, share_action + ) + + +def _resolve_share( + organization: Organization, actor_id: int, resource_kind: str, resource_id: str +) -> tuple[User, _SharedResource] | None: + """The actor and resolved resource, or ``None`` to skip (already logged).""" + actor = _get_user(organization, actor_id) + shared = _load_resource(organization, resource_kind, resource_id) + if actor is None: + # Actor left the org between the share and the send -- routine race, + # not a bug. + logger.info( + "metric=group_notification_actor_left_org_total group-notification: " + "skipping resource share for %s/%s (actor no longer in org)", + resource_kind, + resource_id, + ) + return None + if shared.type is None: + # A registered resource kind with no notification-plugin type mapping + # -- a real gap worth an operator's attention, unlike the actor case. + logger.warning( + "metric=group_notification_unresolved_resource_type_total " + "group-notification: skipping resource share for %s/%s " + "(resource type not registered)", + resource_kind, + resource_id, + ) + return None + return actor, shared + + +def _mail_all_groups( + service: Any, + groups: list[OrganizationGroup], + recipients_by_group: dict[int, list[User]], + shared: _SharedResource, + actor: User, + share_action: str, +) -> bool: + """Send each group's copy concurrently. + + Returns ``False`` (ask for redelivery) only when every attempted group + genuinely failed to send. A skipped group (the plugin's tri-state + ``None`` -- unconfigured, disabled, bad input) never counts as a failure. + A *partial* failure -- some groups sent, others didn't -- is deliberately + not retried either: redelivery would re-mail the groups that already + succeeded, which is the exact duplication the worker's own retry + classification exists to avoid. The lost group is logged instead. + """ + to_mail = [g for g in groups if recipients_by_group.get(g.pk)] + for group in groups: + logger.info( + "group-notification: task=notify_resource_shared_with_group " + "group_id=%s action=%s recipient_count=%d", + group.pk, + share_action, + len(recipients_by_group.get(group.pk, [])), + ) + if not to_mail: + return True + + def _send(group: OrganizationGroup) -> bool | None: + return _mail_group( + service, group, recipients_by_group[group.pk], shared, actor, share_action + ) + + workers = min(len(to_mail), _MAX_CONCURRENT_GROUP_SENDS) + with ThreadPoolExecutor(max_workers=workers) as pool: + # list() first: pool.map returns a lazy generator, and all() stopping + # at the first False would cancel every pending future past it -- + # groups later in the batch would silently never be mailed at all. + results = list(pool.map(_send, to_mail)) + failed = [g.pk for g, r in zip(to_mail, results, strict=True) if r is False] + if not failed: + return True + if len(failed) == len(to_mail): + return False + logger.error( + "metric=group_notification_partial_failure_total failed_group_ids=%s " + "of %d attempted", + failed, + len(to_mail), + ) + return True + + +def send_membership_changed( + *, + organization: Organization, + group_id: int, + actor_id: int, + membership_action: str, + user_ids: Iterable[int], +) -> bool: + """Mail the users whose membership of ``group_id`` just changed. + + Recipients are re-validated against ``OrganizationMember`` — this is where + the offboarding race closes, for removals as well as additions: leaving a + group does not remove someone from the org, so both directions validate the + same way. + + Returns: + ``False`` only when there were real recipients and the plugin reported + a send failure. See :func:`send_resource_shared` for the full contract. + """ + service = _service() + if service is None: + return True + actor = _get_user(organization, actor_id) + group = _groups_in_org(organization, [group_id]).first() + if actor is None or group is None: + logger.info( + "group-notification: skipping membership change for group %s " + "(actor_found=%s group_found=%s)", + group_id, + actor is not None, + group is not None, + ) + return True + recipients = _live_member_users(organization, user_ids) + logger.info( + "group-notification: task=%s group_id=%s action=%s recipient_count=%d", + "notify_group_membership_changed", + group.pk, + membership_action, + len(recipients), + ) + if not recipients: + return True + result = service.send_group_membership_notification( + group_name=group.name, + membership_action=MembershipAction(membership_action).value, + recipients=recipients, + actor=actor, + organization=organization, + ) + # Tri-state from the plugin: None (skipped -- unconfigured, disabled, bad + # input) is not a failure, only an explicit False is. + return result is not False + + +def _service() -> Any | None: + """The cloud email service, or ``None`` when the plugin is absent (OSS).""" + if not notification_plugin: + # An absent plugin is normal in OSS. Absent while email is switched on + # can only be a broken build, and the plugin loader swallows the import + # error at DEBUG, so this is the only place it can surface. + if getattr(settings, "ENABLE_EMAIL_NOTIFICATIONS", False): + logger.warning( + "group-notification: email is enabled but the notification " + "plugin did not load — no mail is being sent" + ) + else: + logger.debug("group-notification: notification plugin unavailable, skipping") + return None + return notification_plugin["service_class"]() + + +def _get_user(organization: Organization, user_id: int) -> User | None: + """The actor, re-validated against the org like every recipient is. + + Service accounts are kept: a share performed by a platform account must + still notify the group. + """ + member = ( + OrganizationMember.objects.filter(organization=organization, user_id=user_id) + .select_related("user") + .first() + ) + return member.user if member else None + + +def _retained_user_ids( + organization: Organization, resource: Any, share_action: str +) -> set[int] | None: + """Users who still reach ``resource``; empty on the share direction. + + A revoked group's members may keep access by a route the revoke did not + touch — another group, a direct share, ownership, or being an org admin, + who reaches every resource in the org. Telling any of them their access was + removed would be wrong, and the revoke email also repoints their CTA at the + dashboard. ``compute_effective_members`` covers the share routes only, so + owners and admins are added back explicitly. + + ``None`` means access never depended on the share at all, so the caller + skips the fan-out entirely. + """ + if share_action != ShareAction.REVOKED.value: + return set() + from tenant_account_v2.sharing_helpers import retained_user_ids + + retained = retained_user_ids(resource, organization) + if retained is None: + logger.info( + "group-notification: revoke on %s, whose access does not depend on " + "shares — nobody lost access, no mail", + resource.pk, + ) + return retained + + +def _groups_to_mail( + organization: Organization, + group_ids: Iterable[int], + resource: Any, + share_action: str, +) -> Iterable[OrganizationGroup]: + """Groups from the payload that should still be mailed. + + On a grant, drop any group whose access was revoked between enqueue and + delivery: the mail carries the resource name and id, so announcing access + the group no longer holds discloses both to members who cannot reach it. + The revoke direction needs no such check — its share row is already gone, + and ``_retained_user_ids`` covers who kept access another way. + """ + groups = _groups_in_org(organization, group_ids) + if share_action != ShareAction.SHARED.value: + return groups + from tenant_account_v2.sharing_helpers import get_resource_share_groups + + live = {group.pk for group in get_resource_share_groups(resource)} + to_mail = [group for group in groups if group.pk in live] + if len(to_mail) != len(groups): + logger.info( + "group-notification: dropped %d of %d groups (access revoked since enqueue)", + len(groups) - len(to_mail), + len(groups), + ) + return to_mail + + +def _group_recipients_batch( + organization: Organization, + groups: list[OrganizationGroup], + retained: set[int], + joined_before: datetime | None = None, +) -> dict[int, list[User]]: + """Live members of each of ``groups`` who did not keep access via ``retained``. + + Two queries total (one ``GroupMembership`` scan, one ``OrganizationMember`` + validation) across every group in the fan-out, rather than one pair per + group -- a resource shared with N groups issued N pairs of queries before + this, since ``joined_before`` (a revoke's timestamp) is the same + cutoff for every group being mailed in one call, so the membership lookup + batches cleanly. + + ``joined_before`` drops anyone who joined after the access was taken away: + they never held it through this group, so a revocation notice would be + about access they never had. + """ + if not groups: + return {} + memberships = GroupMembership.objects.filter(group__in=groups) + if joined_before is not None: + memberships = memberships.filter(created_at__lte=joined_before) + user_ids_by_group: dict[int, set[int]] = defaultdict(set) + all_user_ids: set[int] = set() + for group_id, user_id in memberships.values_list("group_id", "user_id"): + user_ids_by_group[group_id].add(user_id) + all_user_ids.add(user_id) + users_by_id = { + user.pk: user for user in _live_member_users(organization, all_user_ids) + } + return { + group.pk: [ + users_by_id[uid] + for uid in user_ids_by_group.get(group.pk, ()) + if uid in users_by_id and uid not in retained + ] + for group in groups + } + + +def _mail_group( + service: Any, + group: OrganizationGroup, + recipients: list[User], + shared: _SharedResource, + actor: User, + share_action: str, +) -> bool | None: + """Send one group's copy of the resource-share email. + + Passes through the plugin's tri-state result -- see + :func:`_mail_all_groups` for how ``None`` (skipped) is distinguished + from ``False`` (genuinely failed). + """ + return service.send_group_resource_shared_notification( + resource_type=shared.type, + resource_name=shared.name, + resource_id=str(shared.instance.pk), + group_name=group.name, + shared_by=actor, + shared_to=recipients, + resource_instance=shared.instance, + share_action=ShareAction(share_action).value, + ) + + +def _groups_in_org( + organization: Organization, group_ids: Iterable[int] +) -> QuerySet[OrganizationGroup]: + """Groups from ``group_ids`` that belong to ``organization``.""" + return OrganizationGroup.objects.filter( + organization=organization, pk__in=list(group_ids) + ) + + +def _live_member_users(organization: Organization, user_ids: Iterable[int]) -> list[User]: + """Users from ``user_ids`` who are still live members of ``organization``. + + Service accounts are excluded, matching ``compute_effective_members``. Also + drops anyone with a falsy ``email`` -- silently, since this list decides + who has real recipients, and that in turn decides whether a group is + attempted at all (and so whether a 502 can ever fire for it). + """ + requested = list(user_ids) + memberships = OrganizationMember.objects.filter( + organization=organization, user_id__in=requested + ).select_related("user") + users = [ + m.user + for m in memberships + if not getattr(m.user, "is_service_account", False) and m.user.email + ] + if len(users) != len(requested): + logger.info( + "group-notification: dropped %d of %d recipients " + "(left the org / service account / no email)", + len(requested) - len(users), + len(requested), + ) + return users + + +def _load_resource( + organization: Organization, kind: str, resource_id: str +) -> _SharedResource: + """Resolve the shared resource for the email senders. + + Raises: + ResourceNotFoundError: the descriptor, model, or row is missing — the + resource was deleted or belongs to another org. Callers turn this + into a success so the queue stops retrying. + """ + descriptor = descriptor_for_kind(kind) + if descriptor is None: + raise ResourceNotFoundError(f"Unknown resource kind: {kind}") + try: + model = apps.get_model(descriptor.app_label, descriptor.model_name) + except LookupError as exc: # cloud-only app not installed here + raise ResourceNotFoundError(f"Model unavailable for kind: {kind}") from exc + # Filter on the organization explicitly rather than trusting the default + # manager: ``AgenticProject``'s manager deliberately spans organizations. + resource = model.objects.filter( + organization=organization, **{descriptor.id_field: resource_id} + ).first() + if resource is None: + raise ResourceNotFoundError(f"{kind} {resource_id} not found in organization") + # Populate the FK cache with the instance we already hold: the mail send + # (``resource_instance.organization``) runs inside a pool thread, and a + # lazy query there opens a connection ``close_old_connections`` never + # cleans up (that hook only runs on the request thread). + resource.organization = organization + name = getattr(resource, descriptor.name_field, "") or "" + return _SharedResource(resource, name, _resource_type_for(descriptor, resource)) + + +def _resource_type_for(descriptor: ShareableResource, resource: Any) -> str | None: + """Map a resource to the email plugin's ``ResourceType`` value. + + Returns ``None`` for resources the plugin has no type for (e.g. a pipeline + that is neither ETL nor TASK) — the caller skips rather than guessing. + """ + if descriptor.kind == "pipeline": + return pipeline_notification_type(getattr(resource, "pipeline_type", None)) + if descriptor.kind == "adapter_instance": + return adapter_notification_type(str(resource.adapter_type or "")) + return _STATIC_RESOURCE_TYPES.get(descriptor.kind) diff --git a/backend/tenant_account_v2/group_views.py b/backend/tenant_account_v2/group_views.py index 76461951fa..4814f883f7 100644 --- a/backend/tenant_account_v2/group_views.py +++ b/backend/tenant_account_v2/group_views.py @@ -26,6 +26,10 @@ GroupMembership, OrganizationGroup, ) +from tenant_account_v2.share_notifications import ( + MembershipAction, + notify_group_membership_changed, +) logger = logging.getLogger(__name__) @@ -151,12 +155,30 @@ def members(self, request: Request, pk: str | None = None) -> Response: serializer = GroupMemberAddSerializer(data=request.data, context={"group": group}) serializer.is_valid(raise_exception=True) user_ids_to_add: list[int] = serializer.validated_data["user_ids_to_add"] + # The serializer's "already a member" check ran at validation time, not + # at this write, so a concurrent request adding the same user in + # between would make our own insert a silent no-op (ignore_conflicts) + # while we still believe we added them. Re-check right before the + # write to keep that window as small as it can be. + already_members = set( + group.memberships.filter(user_id__in=user_ids_to_add).values_list( + "user_id", flat=True + ) + ) + newly_added_ids = [uid for uid in user_ids_to_add if uid not in already_members] GroupMembership.objects.bulk_create( - [GroupMembership(group=group, user_id=uid) for uid in user_ids_to_add], + [GroupMembership(group=group, user_id=uid) for uid in newly_added_ids], ignore_conflicts=True, ) + if newly_added_ids: + notify_group_membership_changed( + group=group, + action=MembershipAction.ADDED, + user_ids=newly_added_ids, + actor=request.user, + ) return Response( - {"added_user_ids": user_ids_to_add}, + {"added_user_ids": newly_added_ids}, status=status.HTTP_201_CREATED, ) @@ -178,6 +200,12 @@ def remove_member( deleted, _ = group.memberships.filter(user_id=user_id_int).delete() if not deleted: raise NotFound("User is not a member of this group.") + notify_group_membership_changed( + group=group, + action=MembershipAction.REMOVED, + user_ids=[user_id_int], + actor=request.user, + ) return Response(status=status.HTTP_204_NO_CONTENT) # --- resources shared with this group ------------------------------------ diff --git a/backend/tenant_account_v2/internal_urls.py b/backend/tenant_account_v2/internal_urls.py new file mode 100644 index 0000000000..4049c761a5 --- /dev/null +++ b/backend/tenant_account_v2/internal_urls.py @@ -0,0 +1,20 @@ +"""Internal API URLs for group-sharing email notifications.""" + +from django.urls import path + +from . import internal_views + +app_name = "group_notification_internal" + +urlpatterns = [ + path( + "resource-shared/", + internal_views.ResourceSharedWithGroupView.as_view(), + name="resource-shared", + ), + path( + "membership-changed/", + internal_views.GroupMembershipChangedView.as_view(), + name="membership-changed", + ), +] diff --git a/backend/tenant_account_v2/internal_views.py b/backend/tenant_account_v2/internal_views.py new file mode 100644 index 0000000000..434ee453e1 --- /dev/null +++ b/backend/tenant_account_v2/internal_views.py @@ -0,0 +1,103 @@ +"""Internal API views for group-sharing email notifications. + +Mounted under ``/internal/`` and gated by ``InternalAPIAuthMiddleware``. The +notification worker calls these because ``workers/`` has no Django and every +step of the send — group expansion, org re-validation, resource lookup, the +email plugin — needs it. + +A 200 does not imply the email was sent -- see the send path in +:mod:`tenant_account_v2.group_notification_service`. +""" + +import logging + +from account_v2.models import Organization +from rest_framework import serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from utils.user_context import UserContext + +from tenant_account_v2.group_notification_service import ( + ResourceNotFoundError, + send_membership_changed, + send_resource_shared, +) +from tenant_account_v2.share_notifications import MembershipAction, ShareAction + +logger = logging.getLogger(__name__) + + +class ResourceSharedWithGroupSerializer(serializers.Serializer): + """Payload of ``notify_resource_shared_with_group``.""" + + group_ids = serializers.ListField(child=serializers.IntegerField(), allow_empty=False) + actor_id = serializers.IntegerField() + resource_kind = serializers.CharField() + resource_id = serializers.CharField() + # Required, and the worker task takes no default for it either: a default + # on both sides would turn a dropped field into a revoke mailed as a share. + share_action = serializers.ChoiceField(choices=[a.value for a in ShareAction]) + # Revoke only: members who joined after this are excluded from the mail. + # Nullable because the worker sends the key on both directions. + revoked_at = serializers.DateTimeField(allow_null=True) + + +class GroupMembershipChangedSerializer(serializers.Serializer): + """Payload of ``notify_group_membership_changed``.""" + + group_id = serializers.IntegerField() + actor_id = serializers.IntegerField() + membership_action = serializers.ChoiceField( + choices=[a.value for a in MembershipAction] + ) + user_ids = serializers.ListField(child=serializers.IntegerField(), allow_empty=False) + + +class _GroupNotificationView(APIView): + """Shared org resolution for the group-notification endpoints.""" + + @staticmethod + def _organization() -> Organization: + organization = UserContext.get_organization() + if organization is None: + raise ValidationError( + "Organization context missing. Worker must send X-Organization-ID." + ) + return organization + + +class ResourceSharedWithGroupView(_GroupNotificationView): + """Mail every current member of the groups whose resource access just changed.""" + + def post(self, request: Request) -> Response: + serializer = ResourceSharedWithGroupSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + try: + sent = send_resource_shared(organization=self._organization(), **data) + except ResourceNotFoundError as exc: + # Deleted between the share and the send — a retry cannot help. + logger.info("group-notification: dropping resource share (%s)", exc) + return Response({"status": "skipped"}, status=status.HTTP_200_OK) + if not sent: + # A group had real recipients and the plugin failed to send -- + # non-2xx so the worker's retry loop (and PG redelivery once that's + # exhausted) actually fires, instead of a lost email reporting green. + return Response({"status": "failed"}, status=status.HTTP_502_BAD_GATEWAY) + return Response({"status": "success"}, status=status.HTTP_200_OK) + + +class GroupMembershipChangedView(_GroupNotificationView): + """Mail the users whose group membership just changed.""" + + def post(self, request: Request) -> Response: + serializer = GroupMembershipChangedSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + sent = send_membership_changed( + organization=self._organization(), **serializer.validated_data + ) + if not sent: + return Response({"status": "failed"}, status=status.HTTP_502_BAD_GATEWAY) + return Response({"status": "success"}, status=status.HTTP_200_OK) diff --git a/backend/tenant_account_v2/notification_resource_types.py b/backend/tenant_account_v2/notification_resource_types.py new file mode 100644 index 0000000000..ef4a32ae49 --- /dev/null +++ b/backend/tenant_account_v2/notification_resource_types.py @@ -0,0 +1,34 @@ +"""Resource-type mapping shared between the direct-share and group-share +notification paths, so a new adapter or pipeline type only needs registering +once. +""" + +from __future__ import annotations + + +def adapter_notification_type(adapter_type: str) -> str: + """Map an ``AdapterInstance.adapter_type`` to the plugin's ``ResourceType``. + + Unknown types fall back to ``LLM`` so a newly added adapter kind still + mails rather than silently going quiet. + """ + from plugins.notification.constants import ResourceType + + return { + "LLM": ResourceType.LLM.value, + "EMBEDDING": ResourceType.EMBEDDING.value, + "VECTOR_DB": ResourceType.VECTOR_DB.value, + "X2TEXT": ResourceType.X2TEXT.value, + }.get(adapter_type, ResourceType.LLM.value) + + +def pipeline_notification_type(pipeline_type: str | None) -> str | None: + """Map a ``Pipeline.pipeline_type`` to the plugin's ``ResourceType``. + + Only ETL/TASK pipelines are notifiable; anything else returns ``None``. + """ + from plugins.notification.constants import ResourceType + + if pipeline_type in (ResourceType.ETL.value, ResourceType.TASK.value): + return pipeline_type + return None diff --git a/backend/tenant_account_v2/share_notifications.py b/backend/tenant_account_v2/share_notifications.py new file mode 100644 index 0000000000..6b68301670 --- /dev/null +++ b/backend/tenant_account_v2/share_notifications.py @@ -0,0 +1,249 @@ +"""Enqueue hooks for group-sharing email notifications. + +Two events earn a group's members an email: a resource shared with or revoked +from the group, and a user added to or removed from it. Both are dispatched +asynchronously — the caller's request returns as soon as the write lands. + +The sending itself runs in ``workers/``, which is Django-free, so the worker +task is a thin HTTP shim back to :mod:`tenant_account_v2.internal_views`; the +backend does the ORM and plugin work. Transport is the PG queue, the only +one there is — the ``notifications`` queue the notification consumer polls. + +A missing org, an absent notification plugin, or any dispatch error means no +notification, never a broken share. Sending is also bounded by the cloud +``ENABLE_EMAIL_NOTIFICATIONS`` setting and by each event's own template ID +being configured -- either being unset silently skips, same as the +direct-user mail this reuses. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from django.utils import timezone +from plugins import get_plugin + +from tenant_account_v2.shareable_resources import kind_for_instance + +if TYPE_CHECKING: + from account_v2.models import User + + from tenant_account_v2.models import OrganizationGroup + +logger = logging.getLogger(__name__) + +NOTIFY_RESOURCE_SHARED_TASK = "notify_resource_shared_with_group" +NOTIFY_MEMBERSHIP_CHANGED_TASK = "notify_group_membership_changed" + +# Mirrors the workers' ``QueueName.NOTIFICATION`` — a local literal so the +# backend does not import the workers package (same as ``pipeline_dispatch``). +NOTIFICATION_QUEUE = "notifications" + + +class MembershipAction(StrEnum): + """What happened to a user's membership of a group.""" + + ADDED = "added" + REMOVED = "removed" + + +class ShareAction(StrEnum): + """What happened to a group's access to a resource.""" + + SHARED = "shared" + REVOKED = "revoked" + + +def notify_resource_group_share_changed( + *, + resource: Any, + added: Iterable[OrganizationGroup], + removed: Iterable[OrganizationGroup], + actor: User, +) -> None: + """Queue group mail for a resource just shared with / revoked from groups.""" + for share_action, groups in ( + (ShareAction.SHARED, added), + (ShareAction.REVOKED, removed), + ): + _notify_group_share( + resource=resource, groups=groups, share_action=share_action, actor=actor + ) + + +def _notify_group_share( + *, + resource: Any, + groups: Iterable[OrganizationGroup], + share_action: ShareAction, + actor: User, +) -> None: + """Queue one group-share event. + + Recipients are resolved at delivery time rather than frozen here: anyone + who leaves the org between the click and the send simply isn't in the fresh + lookup, so offboarding safety costs nothing. Unlike a membership removal, + revoking a group's access leaves the group and its members intact, so the + fresh lookup still finds everyone who needs telling. + + A revoke carries ``revoked_at`` so that fresh lookup can still exclude + anyone who joined the group *after* the access was taken away — they never + held it through this group, and the queue can lag. One timestamp rather + than the whole member list, which would grow the payload with the group. + """ + group_ids = sorted(group.pk for group in groups) + if not group_ids: + return + # Stamped at the moment of the revoke, not at delivery: a member joining + # after it would otherwise be mailed about access they never held. + revoked_at = ( + timezone.now().isoformat() if share_action is ShareAction.REVOKED else None + ) + organization_id = _organization_slug(resource) + kind = kind_for_instance(resource) + if not organization_id or kind is None: + # Neither is a routine skip: a shareable resource always resolves an + # org, and the share endpoint accepts ``shared_groups`` for any host + # viewset, so an unregistered kind means a group share landed on a + # resource this feature cannot mail about. + logger.warning( + "group-notification: skipping %s share for %s %s (organization=%s kind=%s)", + share_action, + type(resource).__name__, + resource.pk, + organization_id, + kind, + ) + return + kwargs: dict[str, Any] = { + "group_ids": group_ids, + "actor_id": actor.pk, + "resource_kind": kind, + "resource_id": str(resource.pk), + "share_action": share_action.value, + "organization_id": organization_id, + } + if revoked_at is not None: + kwargs["revoked_at"] = revoked_at + _dispatch_quietly( + task_name=NOTIFY_RESOURCE_SHARED_TASK, + kwargs=kwargs, + organization_id=organization_id, + ) + + +def notify_group_membership_changed( + *, + group: OrganizationGroup, + action: MembershipAction, + user_ids: Iterable[int], + actor: User, +) -> None: + """Queue "you were added to / removed from a group" mail for those users. + + Unlike a resource share, the user ids ride in the payload: on removal the + membership rows are already gone by delivery time, and on add a fresh group + lookup would mail every existing member too. + """ + recipients = sorted(user_ids) + if not recipients: + return + organization_id = _organization_slug(group) + if not organization_id: + logger.warning( + "group-notification: skipping membership change for group %s " + "(no resolvable organization)", + group.pk, + ) + return + _dispatch_quietly( + task_name=NOTIFY_MEMBERSHIP_CHANGED_TASK, + kwargs={ + "group_id": group.pk, + "actor_id": actor.pk, + "membership_action": action.value, + "user_ids": recipients, + "organization_id": organization_id, + }, + organization_id=organization_id, + ) + + +def _organization_slug(obj: Any) -> str | None: + """The owning org's string identifier (``Organization.organization_id``). + + This is the ``X-Organization-ID`` value the worker echoes back, not the DB + pk, and it is what the queue row records for fairness/routing. + """ + organization = getattr(obj, "organization", None) + return getattr(organization, "organization_id", None) + + +def _dispatch_quietly( + *, + task_name: str, + kwargs: dict[str, Any], + organization_id: str, +) -> None: + """Enqueue on the PG queue; never let a failure reach the caller. + + The share or membership change has already been committed by the time this + runs — losing its email is not a reason to fail the request the user made. + + Skips the enqueue entirely when the notification plugin isn't loaded (pure + OSS): the worker would just claim the row and no-op, so there is no point + writing a queue row nobody can act on. + """ + if not get_plugin("notification"): + logger.debug( + "group-notification: notification plugin unavailable, skipping " + "enqueue for %s", + task_name, + ) + return + try: + _dispatch( + task_name=task_name, + kwargs=kwargs, + organization_id=organization_id, + ) + except Exception: + logger.exception( + "metric=group_notification_enqueue_failed_total task=%s org_id=%s", + task_name, + organization_id, + ) + + +def _dispatch( + *, + task_name: str, + kwargs: dict[str, Any], + organization_id: str, +) -> None: + """Enqueue on the PG queue. + + Deploy the notification worker at or before the backend: the consumer polls + the queue by name, so a pod still on the previous image claims these rows, + cannot resolve the task, and DELETES them (no dead-letter — nothing here + passes ``reply_key`` or ``on_error``). + """ + # Lazy import — ``pg_queue`` is heavier than this leaf module and importing + # it at load time risks a cycle during Django app loading. + from pg_queue.producer import enqueue_task + + msg_id = enqueue_task( + task_name=task_name, + queue=NOTIFICATION_QUEUE, + kwargs=kwargs, + org_id=organization_id, + ) + logger.info( + "group-notification: %s enqueued on PG queue %r (msg_id=%s)", + task_name, + NOTIFICATION_QUEUE, + msg_id, + ) diff --git a/backend/tenant_account_v2/shareable_resources.py b/backend/tenant_account_v2/shareable_resources.py index 80e7356084..ba8b02573c 100644 --- a/backend/tenant_account_v2/shareable_resources.py +++ b/backend/tenant_account_v2/shareable_resources.py @@ -9,6 +9,7 @@ """ from dataclasses import dataclass +from typing import Any @dataclass(frozen=True) @@ -50,3 +51,26 @@ class ShareableResource: ), ShareableResource("lookups", "LookupDefinition", "lookup", "name", "lookup_id"), ) + + +def descriptor_for_kind(kind: str) -> ShareableResource | None: + """Look up a descriptor by its ``kind`` key.""" + return next((r for r in SHAREABLE_RESOURCES if r.kind == kind), None) + + +def kind_for_instance(instance: Any) -> str | None: + """Reverse lookup: the ``kind`` of a resource instance, ``None`` if unlisted. + + Matches on the model's app label + class name so callers holding an + instance (e.g. the share endpoint) don't hardcode a type check per + resource. + """ + meta = instance._meta + return next( + ( + r.kind + for r in SHAREABLE_RESOURCES + if r.app_label == meta.app_label and r.model_name == meta.object_name + ), + None, + ) diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py index e7035e59d8..c7775bb682 100644 --- a/backend/tenant_account_v2/sharing_helpers.py +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -269,6 +269,38 @@ def serialize_owner_refs(resource_obj: Any) -> list[dict[str, Any]]: return [{"id": user.pk, "email": user.email} for user in resource_obj.owners()] +def access_survives_share_changes(resource_obj: Any) -> bool: + """True when access to ``resource_obj`` does not depend on shares at all. + + Revoking a share removes nothing in that case, so nobody should be told it + did. ``shared_to_org`` covers every org member; ``is_friction_less`` is the + adapter equivalent -- ``AdapterInstance.for_user`` admits it to any regular + user unconditionally (service accounts are the one exception, explicitly + excluded from frictionless adapters). + """ + return bool(getattr(resource_obj, "shared_to_org", False)) or bool( + getattr(resource_obj, "is_friction_less", False) + ) + + +def org_admin_user_ids(organization: Any) -> set[int]: + """Users who reach every resource in ``organization`` as admins. + + ``for_user`` returns the whole queryset for an org admin, so a revoke never + takes their access away and they must not be mailed about losing it. The + role STRING is plugin-dependent (it differs between the OSS and auth0 + plugins), so this goes through the auth controller rather than comparing to + a literal. + """ + from account_v2.authentication_controller import AuthenticationController + + controller = AuthenticationController() + rows = OrganizationMember.objects.filter(organization=organization).values_list( + "user_id", "role" + ) + return {uid for uid, role in rows if controller.is_admin_by_role(role)} + + def compute_effective_members(resource_obj: Any) -> list[dict[str, Any]]: """Compute effective members of a shareable resource. @@ -321,6 +353,30 @@ def compute_effective_members(resource_obj: Any) -> list[dict[str, Any]]: return list(seen.values()) +def retained_user_ids(resource_obj: Any, organization: Any = None) -> set[int] | None: + """Every user id who still reaches ``resource_obj`` after a revoke. + + ``None`` means access never depended on shares at all (see + ``access_survives_share_changes``) -- the caller's cue that nobody lost + anything and no notification is owed. Shared by the direct-share and + group-share revoke paths so they can't independently drift on what counts + as "still has access". + """ + if access_survives_share_changes(resource_obj): + return None + retained = {member["user_id"] for member in compute_effective_members(resource_obj)} + # compute_effective_members deliberately excludes owners (they hold the + # resource, they aren't "shared with" it); a revoke never touches them. + retained |= {owner.pk for owner in resource_obj.owners()} + org = ( + organization + if organization is not None + else getattr(resource_obj, "organization", None) + ) + retained |= org_admin_user_ids(org) + return retained + + def _add_org_members(seen: dict[int, dict[str, Any]], resource_obj: Any) -> None: """Add org-wide members to ``seen`` (skips users already recorded).""" if not getattr(resource_obj, "shared_to_org", False): diff --git a/backend/tenant_account_v2/test_internal_views.py b/backend/tenant_account_v2/test_internal_views.py new file mode 100644 index 0000000000..b936bf097b --- /dev/null +++ b/backend/tenant_account_v2/test_internal_views.py @@ -0,0 +1,93 @@ +"""View-level tests for the group-notification internal endpoints. + +The send-side logic (who gets mailed, the tri-state plugin contract) is +covered in ``ResourceShareNotificationTests`` in ``tests.py``. These pin the +one thing that lives only here: the status-code mapping from that result to +an HTTP response, since that mapping is what the worker's retry decision +actually reads. ``send_resource_shared`` / ``send_membership_changed`` are +patched directly so these don't need a full group/resource DB setup to +exercise the view in isolation. +""" + +from unittest.mock import patch + +from account_v2.models import Organization +from django.test import RequestFactory, TestCase +from rest_framework import status +from utils.user_context import UserContext + +from tenant_account_v2.internal_views import ( + GroupMembershipChangedView, + ResourceSharedWithGroupView, +) + +_SHARE_PAYLOAD = { + "group_ids": [1], + "actor_id": 1, + "resource_kind": "workflow", + "resource_id": "wf-1", + "share_action": "shared", + "revoked_at": None, +} + +_MEMBERSHIP_PAYLOAD = { + "group_id": 1, + "actor_id": 1, + "membership_action": "added", + "user_ids": [1], +} + + +class _InternalViewTestBase(TestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name="org-views", display_name="Org Views", organization_id="org-views" + ) + UserContext.set_organization_identifier(self.org.organization_id) + self.addCleanup(UserContext.set_organization_identifier, None) + + @staticmethod + def _post(view_cls, data: dict): + request = RequestFactory().post( + "/internal/", data=data, content_type="application/json" + ) + return view_cls.as_view()(request) + + +class ResourceSharedWithGroupViewTests(_InternalViewTestBase): + def test_sent_returns_200(self) -> None: + with patch( + "tenant_account_v2.internal_views.send_resource_shared", return_value=True + ): + response = self._post(ResourceSharedWithGroupView, _SHARE_PAYLOAD) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "success") + + def test_genuine_failure_returns_502(self) -> None: + # This is the branch the Critical hinged on: before the tri-state + # fix, every skip (unconfigured template, disabled notifications, + # bad input) collapsed into this same False -- retrying forever a + # condition no retry could fix. + with patch( + "tenant_account_v2.internal_views.send_resource_shared", return_value=False + ): + response = self._post(ResourceSharedWithGroupView, _SHARE_PAYLOAD) + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + self.assertEqual(response.data["status"], "failed") + + +class GroupMembershipChangedViewTests(_InternalViewTestBase): + def test_sent_returns_200(self) -> None: + with patch( + "tenant_account_v2.internal_views.send_membership_changed", return_value=True + ): + response = self._post(GroupMembershipChangedView, _MEMBERSHIP_PAYLOAD) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_genuine_failure_returns_502(self) -> None: + with patch( + "tenant_account_v2.internal_views.send_membership_changed", + return_value=False, + ): + response = self._post(GroupMembershipChangedView, _MEMBERSHIP_PAYLOAD) + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) diff --git a/backend/tenant_account_v2/test_share_notification_dispatch.py b/backend/tenant_account_v2/test_share_notification_dispatch.py new file mode 100644 index 0000000000..f711a3457d --- /dev/null +++ b/backend/tenant_account_v2/test_share_notification_dispatch.py @@ -0,0 +1,161 @@ +"""Unit tests for the group-notification enqueue side (UN-3494 / mfbt UNS-848). + +``share_notifications`` runs inside the user's share request: it builds the task +payload and hands it to the transport. Nothing here touches the ORM or sends +mail, so the module is patched at its three seams — ``kind_for_instance``, +``get_plugin`` and ``_dispatch`` — and these run in the rig's unit tier with no +Postgres. The transport itself is covered by ``pg_queue.tests``; the +delivery side by ``ResourceShareNotificationTests`` in ``tenant_account_v2.tests``. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import patch + +import tenant_account_v2.share_notifications as sn + +_ACTOR = SimpleNamespace(pk=7) +_RESOURCE = SimpleNamespace( + pk="wf-1", organization=SimpleNamespace(organization_id="org-a") +) + + +def _group(pk: int) -> SimpleNamespace: + return SimpleNamespace(pk=pk) + + +@contextmanager +def _seams(*, dispatch_raises: Exception | None = None): + """Patch the module's three outbound seams; yield the dispatch mock.""" + with ( + patch.object(sn, "kind_for_instance", return_value="workflow"), + patch.object(sn, "get_plugin", return_value={"service_class": object()}), + patch.object(sn, "_dispatch", side_effect=dispatch_raises) as dispatch, + ): + yield dispatch + + +class TestNotifyResourceGroupShareChanged: + def test_grant_dispatches_shared_payload_without_timestamp(self): + with _seams() as dispatch: + sn.notify_resource_group_share_changed( + resource=_RESOURCE, added=[_group(5), _group(2)], removed=[], actor=_ACTOR + ) + dispatch.assert_called_once() + call = dispatch.call_args.kwargs + assert call["task_name"] == sn.NOTIFY_RESOURCE_SHARED_TASK + assert call["organization_id"] == "org-a" + assert call["kwargs"] == { + "group_ids": [2, 5], # sorted, so the payload is stable + "actor_id": 7, + "resource_kind": "workflow", + "resource_id": "wf-1", + "share_action": "shared", + "organization_id": "org-a", + } + # A grant carries no cutoff — the delivery side mails every live member. + assert "revoked_at" not in call["kwargs"] + + def test_revoke_dispatches_revoked_payload_with_timestamp(self): + with _seams() as dispatch: + sn.notify_resource_group_share_changed( + resource=_RESOURCE, added=[], removed=[_group(3)], actor=_ACTOR + ) + payload = dispatch.call_args.kwargs["kwargs"] + assert payload["share_action"] == "revoked" + assert payload["group_ids"] == [3] + # ISO-8601 string, not a datetime — the payload is JSON-serialized. + datetime.fromisoformat(payload["revoked_at"]) + + def test_grant_and_revoke_dispatch_independently(self): + with _seams() as dispatch: + sn.notify_resource_group_share_changed( + resource=_RESOURCE, added=[_group(1)], removed=[_group(2)], actor=_ACTOR + ) + assert dispatch.call_count == 2 + actions = [c.kwargs["kwargs"]["share_action"] for c in dispatch.call_args_list] + assert actions == ["shared", "revoked"] + + def test_no_groups_skips_dispatch(self): + with _seams() as dispatch: + sn.notify_resource_group_share_changed( + resource=_RESOURCE, added=[], removed=[], actor=_ACTOR + ) + dispatch.assert_not_called() + + def test_unknown_resource_kind_skips_dispatch(self): + with ( + patch.object(sn, "kind_for_instance", return_value=None), + patch.object(sn, "get_plugin", return_value={"service_class": object()}), + patch.object(sn, "_dispatch") as dispatch, + ): + sn.notify_resource_group_share_changed( + resource=_RESOURCE, added=[_group(1)], removed=[], actor=_ACTOR + ) + dispatch.assert_not_called() + + def test_missing_organization_skips_dispatch(self): + orphan = SimpleNamespace(pk="wf-1", organization=None) + with _seams() as dispatch: + sn.notify_resource_group_share_changed( + resource=orphan, added=[_group(1)], removed=[], actor=_ACTOR + ) + dispatch.assert_not_called() + + def test_plugin_absent_skips_dispatch(self): + # Pure OSS: nothing would ever consume this row, so don't write it. + with ( + patch.object(sn, "kind_for_instance", return_value="workflow"), + patch.object(sn, "get_plugin", return_value=None), + patch.object(sn, "_dispatch") as dispatch, + ): + sn.notify_resource_group_share_changed( + resource=_RESOURCE, added=[_group(1)], removed=[], actor=_ACTOR + ) + dispatch.assert_not_called() + + def test_dispatch_failure_never_reaches_the_caller(self): + # The share has already committed — losing its email must not 500 it. + with _seams(dispatch_raises=RuntimeError("queue down")): + sn.notify_resource_group_share_changed( + resource=_RESOURCE, added=[_group(1)], removed=[], actor=_ACTOR + ) + + +class TestNotifyGroupMembershipChanged: + def test_membership_change_dispatches_user_ids_in_payload(self): + with _seams() as dispatch: + sn.notify_group_membership_changed( + group=SimpleNamespace( + pk=9, organization=SimpleNamespace(organization_id="org-a") + ), + action=sn.MembershipAction.ADDED, + user_ids=[4, 1], + actor=_ACTOR, + ) + call = dispatch.call_args.kwargs + assert call["task_name"] == sn.NOTIFY_MEMBERSHIP_CHANGED_TASK + assert call["kwargs"] == { + "group_id": 9, + "actor_id": 7, + "membership_action": "added", + # Unlike a share, the ids ride in the payload: on removal the rows + # are gone by delivery time. + "user_ids": [1, 4], + "organization_id": "org-a", + } + + def test_no_users_skips_dispatch(self): + with _seams() as dispatch: + sn.notify_group_membership_changed( + group=SimpleNamespace( + pk=9, organization=SimpleNamespace(organization_id="org-a") + ), + action=sn.MembershipAction.REMOVED, + user_ids=[], + actor=_ACTOR, + ) + dispatch.assert_not_called() diff --git a/backend/tenant_account_v2/test_sharing_retention.py b/backend/tenant_account_v2/test_sharing_retention.py new file mode 100644 index 0000000000..1c051eeb9c --- /dev/null +++ b/backend/tenant_account_v2/test_sharing_retention.py @@ -0,0 +1,52 @@ +"""Which resources have access that does not depend on shares at all. + +Revoking a share from such a resource removes nothing, so nobody may be told it +did. Two routes qualify and they are easy to miss because neither is a share +row: ``shared_to_org`` admits every org member, and ``is_friction_less`` is the +adapter equivalent -- ``AdapterInstance.for_user`` admits it unconditionally, +alongside the share clauses. + +Pure predicate, so this runs in the rig's unit tier with no database. The +admin route is the third one and is user-level rather than resource-level, so +it lives in ``org_admin_user_ids`` and is covered by the DB tests. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tenant_account_v2.sharing_helpers import access_survives_share_changes + + +class TestAccessSurvivesShareChanges: + @pytest.mark.parametrize( + "resource, expected, why", + [ + (SimpleNamespace(shared_to_org=True), True, "org-wide share"), + (SimpleNamespace(is_friction_less=True), True, "frictionless adapter"), + ( + SimpleNamespace(shared_to_org=False, is_friction_less=True), + True, + "frictionless without an org share still admits everyone", + ), + ( + SimpleNamespace(shared_to_org=True, is_friction_less=False), + True, + "org share without frictionless still admits everyone", + ), + ( + SimpleNamespace(shared_to_org=False, is_friction_less=False), + False, + "neither route: access really does depend on the share", + ), + ( + SimpleNamespace(), + False, + "a model carrying neither field, e.g. Workflow, is share-dependent", + ), + ], + ) + def test_routes(self, resource, expected, why): + assert access_survives_share_changes(resource) is expected, why diff --git a/backend/tenant_account_v2/tests.py b/backend/tenant_account_v2/tests.py index a105593599..19f3d0ca6a 100644 --- a/backend/tenant_account_v2/tests.py +++ b/backend/tenant_account_v2/tests.py @@ -11,18 +11,25 @@ """ import secrets -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import Mock, patch from account_v2.models import Organization, User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist from django.test import TestCase +from django.utils import timezone from permissions.roles import ResourceRole from rest_framework.exceptions import PermissionDenied from rest_framework.test import APIRequestFactory, force_authenticate from utils.user_context import UserContext from workflow_manager.workflow_v2.models.workflow import Workflow +from tenant_account_v2.group_notification_service import ( + ResourceNotFoundError, + send_membership_changed, + send_resource_shared, +) from tenant_account_v2.group_views import OrganizationGroupViewSet from tenant_account_v2.models import ( GroupMembership, @@ -30,6 +37,7 @@ OrganizationMember, ResourceGroupShare, ) +from tenant_account_v2.share_notifications import MembershipAction, ShareAction from tenant_account_v2.shareable_resources import SHAREABLE_RESOURCES from tenant_account_v2.sharing_helpers import ( ShareAuthorizationService, @@ -371,6 +379,32 @@ def test_service_account_can_add_members(self) -> None: GroupMembership.objects.filter(group=self.group, user=self.outsider).exists() ) + def test_add_members_response_only_lists_newly_added(self) -> None: + # self.member is already in self.group (see GroupSharingTestBase); + # only self.outsider is new. The response, and the notification it + # feeds, must both narrow to the actual insert, not the request. + with patch( + "tenant_account_v2.group_views.notify_group_membership_changed" + ) as notify: + response = self._call( + {"post": "members"}, + "post", + self.svc, + data={"user_ids": [self.member.id, self.outsider.id]}, + pk=str(self.group.pk), + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["added_user_ids"], [self.outsider.id]) + self.assertEqual( + GroupMembership.objects.filter(group=self.group, user=self.member).count(), 1 + ) + # The regression this guards against: passing the full request list + # (including the already-a-member id) would still pass the earlier + # assertions above -- only this call proves the notification itself + # was narrowed, not just the DB write and the response. + notify.assert_called_once() + self.assertEqual(notify.call_args.kwargs["user_ids"], [self.outsider.id]) + def test_service_account_can_remove_member(self) -> None: response = self._call( {"delete": "remove_member"}, @@ -482,3 +516,346 @@ def test_descriptors_resolve_and_fields_exist(self) -> None: f"{resource.kind}.{attr}={field_name!r} is not a field on " f"{resource.app_label}.{resource.model_name}" ) + + +class ResourceShareNotificationTests(GroupSharingTestBase): + """Delivery side (``group_notification_service``): who actually gets mailed. + + The email plugin is mocked, so these pin recipient selection — the live + re-read on a grant, the ``revoked_at`` cutoff, org scoping and retained + access — not template or transport behavior. The enqueue side is covered in + ``test_share_notification_dispatch`` (unit tier, no DB). + """ + + def setUp(self) -> None: + super().setUp() + self.service = Mock() + patcher = patch( + "tenant_account_v2.group_notification_service.notification_plugin", + {"service_class": Mock(return_value=self.service)}, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _send( + self, + *, + group_ids: list[int], + share_action: str = ShareAction.SHARED.value, + revoked_at=None, + ) -> bool: + return send_resource_shared( + organization=self.org, + group_ids=group_ids, + actor_id=self.owner.pk, + resource_kind="workflow", + resource_id=str(self.workflow.pk), + share_action=share_action, + revoked_at=revoked_at, + ) + + def _mailed(self) -> list[tuple[str, list[str]]]: + """``(group_name, sorted recipient emails)`` per email sent, in order.""" + return [ + (call.kwargs["group_name"], sorted(u.email for u in call.kwargs["shared_to"])) + for call in self.service.send_group_resource_shared_notification.call_args_list + ] + + def test_grant_mails_current_group_members(self) -> None: + set_resource_share_groups(self.workflow, [self.group.id]) + self._send(group_ids=[self.group.id]) + self.assertEqual(self._mailed(), [("Team", ["member@example.com"])]) + + def test_grant_dropped_when_share_revoked_before_delivery(self) -> None: + # The queue can lag; announcing access the group no longer holds would + # disclose the resource name and id to members who cannot reach it. + set_resource_share_groups(self.workflow, [self.group.id]) + set_resource_share_groups(self.workflow, []) + self._send(group_ids=[self.group.id]) + self.service.send_group_resource_shared_notification.assert_not_called() + + def test_revoke_mails_members_although_the_share_row_is_gone(self) -> None: + # Mirror image of the check above: on a revoke the row is *expected* to + # be absent, so the live re-read must not suppress the mail. + self._send(group_ids=[self.group.id], share_action=ShareAction.REVOKED.value) + self.assertEqual(self._mailed(), [("Team", ["member@example.com"])]) + kwargs = self.service.send_group_resource_shared_notification.call_args.kwargs + self.assertEqual(kwargs["share_action"], "revoked") + self.assertEqual(kwargs["resource_type"], "workflow") + self.assertEqual(kwargs["resource_name"], "wf-1") + + def test_multi_group_fan_out_keeps_each_group_and_its_own_members_separate( + self, + ) -> None: + # ``self.member`` is in both groups (overlapping); ``self.outsider`` is + # only in the second (disjoint). A naive union across groups would + # either merge them into one mail or mislabel which group a recipient + # actually belongs to -- both are exactly what one email per group + # exists to prevent. + other_group = OrganizationGroup.objects.create( + organization=self.org, name="Ops", created_by=self.owner + ) + GroupMembership.objects.create(group=other_group, user=self.member) + GroupMembership.objects.create(group=other_group, user=self.outsider) + set_resource_share_groups(self.workflow, [self.group.id, other_group.id]) + + self._send(group_ids=[self.group.id, other_group.id]) + + self.assertEqual( + sorted(self._mailed()), + sorted( + [ + ("Team", ["member@example.com"]), + ("Ops", ["member@example.com", "outsider@example.com"]), + ] + ), + ) + + def test_plugin_skip_result_is_not_a_failure(self) -> None: + # The plugin's tri-state None means "skipped" (unconfigured, disabled, + # bad input) -- never a reason to ask for redelivery. + set_resource_share_groups(self.workflow, [self.group.id]) + self.service.send_group_resource_shared_notification.return_value = None + self.assertTrue(self._send(group_ids=[self.group.id])) + + def test_all_groups_failing_asks_for_redelivery(self) -> None: + other_group = OrganizationGroup.objects.create( + organization=self.org, name="Ops", created_by=self.owner + ) + GroupMembership.objects.create(group=other_group, user=self.outsider) + set_resource_share_groups(self.workflow, [self.group.id, other_group.id]) + self.service.send_group_resource_shared_notification.return_value = False + self.assertFalse(self._send(group_ids=[self.group.id, other_group.id])) + + def test_partial_group_failure_is_not_retried_and_every_group_is_attempted( + self, + ) -> None: + # A retry would re-mail the group that already succeeded -- accept the + # partial loss instead. Every group must still be attempted, not just + # the ones before the first failure: ThreadPoolExecutor.map's pending + # futures must not be cancelled by an early result. + other_group = OrganizationGroup.objects.create( + organization=self.org, name="Ops", created_by=self.owner + ) + GroupMembership.objects.create(group=other_group, user=self.outsider) + set_resource_share_groups(self.workflow, [self.group.id, other_group.id]) + self.service.send_group_resource_shared_notification.side_effect = [False, True] + result = self._send(group_ids=[self.group.id, other_group.id]) + self.assertTrue(result) + self.assertEqual( + self.service.send_group_resource_shared_notification.call_count, 2 + ) + + def test_group_from_another_org_is_never_mailed(self) -> None: + """The foreign group's member is deliberately also an org-A member. + + Users belong to any number of orgs here, so without that the group has + no resolvable recipients and the test passes on an empty list rather + than on the org filter -- green even with the filter deleted. + """ + other_org = Organization.objects.create( + name="org-b", display_name="Org B", organization_id="org-b" + ) + foreign_group = OrganizationGroup.objects.create( + organization=other_org, name="Foreign", created_by=self.owner + ) + dual = _make_user("dual@example.com") + OrganizationMember.objects.create(organization=self.org, user=dual, role="user") + OrganizationMember.objects.create(organization=other_org, user=dual, role="user") + GroupMembership.objects.create(group=foreign_group, user=dual) + + for action in (ShareAction.SHARED.value, ShareAction.REVOKED.value): + self._send(group_ids=[foreign_group.id], share_action=action) + self.assertEqual(self._mailed(), []) + + def test_group_member_outside_the_org_is_never_mailed(self) -> None: + """A group row that outlives the org membership must not produce mail. + + Pins the outcome, not the layer: ``OrganizationMember``'s default + manager is org-scoped, so deleting the explicit filter in + ``_live_member_users`` changes nothing. ``OrganizationGroup`` has no + such manager, which is why the group-level filter IS pinnable -- see + ``test_group_from_another_org_is_never_mailed``. + """ + other_org = Organization.objects.create( + name="org-d", display_name="Org D", organization_id="org-d" + ) + # A member of ANOTHER org, not of no org: a user with no membership row + # at all is excluded by the table rather than by the org clause, which + # would leave this test green with the filter deleted. + stranger = _make_user("stranger@example.com") + OrganizationMember.objects.create( + organization=other_org, user=stranger, role="user" + ) + GroupMembership.objects.create(group=self.group, user=stranger) + set_resource_share_groups(self.workflow, [self.group.id]) + self._send(group_ids=[self.group.id]) + self.assertEqual(self._mailed(), [("Team", ["member@example.com"])]) + + def test_actor_outside_the_org_is_not_resolved(self) -> None: + """The actor's name and email render into the outgoing mail, so an + actor from another org must not resolve. Outcome-level, like the + recipient case above: the org-scoped manager enforces it either way. + """ + other_org = Organization.objects.create( + name="org-e", display_name="Org E", organization_id="org-e" + ) + # Again a member of another org rather than of none, so the org clause + # is the only thing that can exclude them. + foreign_actor = _make_user("foreign-actor@example.com") + OrganizationMember.objects.create( + organization=other_org, user=foreign_actor, role="user" + ) + # The grant direction drops a group with no live share row, which would + # stop the mail before the actor is ever resolved. + set_resource_share_groups(self.workflow, [self.group.id]) + send_resource_shared( + organization=self.org, + group_ids=[self.group.id], + actor_id=foreign_actor.pk, + resource_kind="workflow", + resource_id=str(self.workflow.pk), + share_action=ShareAction.SHARED.value, + revoked_at=None, + ) + self.service.send_group_resource_shared_notification.assert_not_called() + + def test_resource_from_another_org_is_not_resolved(self) -> None: + """A resource id belonging to another org must not resolve. + + For ``Workflow`` the org-scoped manager already enforces this, so this + pins the outcome rather than the explicit filter. That filter exists + for ``AgenticProject``, whose manager deliberately spans orgs -- a + cloud-only model, so the case it guards cannot be exercised here. + """ + other_org = Organization.objects.create( + name="org-c", display_name="Org C", organization_id="org-c" + ) + foreign_wf = Workflow.objects.create( + workflow_name="wf-other", organization=other_org, created_by=self.owner + ) + with self.assertRaises(ResourceNotFoundError): + send_resource_shared( + organization=self.org, + group_ids=[self.group.id], + actor_id=self.owner.pk, + resource_kind="workflow", + resource_id=str(foreign_wf.pk), + share_action=ShareAction.SHARED.value, + revoked_at=None, + ) + self.service.send_group_resource_shared_notification.assert_not_called() + + def test_revoke_on_an_org_shared_resource_mails_nobody(self) -> None: + """Nobody lost access, so nobody is told. + + The short-circuit that skips hydrating every org member to reach this + answer is an optimisation, not a behaviour change -- removing it leaves + this assertion green. Only a query count would pin that half. + """ + self.workflow.shared_to_org = True + self.workflow.save(update_fields=["shared_to_org"]) + self._send(group_ids=[self.group.id], share_action=ShareAction.REVOKED.value) + self.assertEqual(self._mailed(), []) + + def test_revoke_does_not_tell_an_owner_they_lost_access(self) -> None: + """Owners sit outside ``compute_effective_members``, so they have to be + added back explicitly or an owner inside a revoked group is mailed a + false removal notice. + """ + GroupMembership.objects.create(group=self.group, user=self.owner) + self._send(group_ids=[self.group.id], share_action=ShareAction.REVOKED.value) + self.assertEqual(self._mailed(), [("Team", ["member@example.com"])]) + + def test_revoke_does_not_tell_an_org_admin_they_lost_access(self) -> None: + """An admin reaches every resource in the org via ``for_user``, so a + revoke takes nothing from them. + + The admin ROLE STRING differs between the OSS and auth0 auth plugins, + so the predicate is patched rather than resolved for real. + """ + GroupMembership.objects.create(group=self.group, user=self.admin) + with patch( + "account_v2.authentication_controller.AuthenticationController" + ".is_admin_by_role", + side_effect=lambda role: role == "admin", + ): + self._send(group_ids=[self.group.id], share_action=ShareAction.REVOKED.value) + self.assertEqual(self._mailed(), [("Team", ["member@example.com"])]) + + def test_membership_removal_is_mailed_as_a_removal(self) -> None: + """The ADDED direction was the only one exercised, so hardcoding the + action passed every test while telling removed users they were added. + """ + send_membership_changed( + organization=self.org, + group_id=self.group.id, + actor_id=self.owner.pk, + membership_action=MembershipAction.REMOVED.value, + user_ids=[self.member.pk], + ) + kwargs = self.service.send_group_membership_notification.call_args.kwargs + self.assertEqual(kwargs["membership_action"], "removed") + + def test_membership_recipients_are_revalidated_against_the_org(self) -> None: + """Leaving a group does not remove someone from the org, and leaving the + org does not delete their group rows -- so the recipient list is filtered + on OrganizationMember rather than taken from the payload. + """ + stranger = _make_user("ex@example.com") + send_membership_changed( + organization=self.org, + group_id=self.group.id, + actor_id=self.owner.pk, + membership_action=MembershipAction.REMOVED.value, + user_ids=[self.member.pk, stranger.pk], + ) + kwargs = self.service.send_group_membership_notification.call_args.kwargs + self.assertEqual([u.email for u in kwargs["recipients"]], ["member@example.com"]) + + def test_revoke_skips_members_who_joined_after_the_cutoff(self) -> None: + revoked_at = timezone.now() + latecomer = GroupMembership.objects.create(group=self.group, user=self.outsider) + # ``created_at`` is auto-set on save, so move it past the cutoff directly. + GroupMembership.objects.filter(pk=latecomer.pk).update( + created_at=revoked_at + timedelta(minutes=1) + ) + self._send( + group_ids=[self.group.id], + share_action=ShareAction.REVOKED.value, + revoked_at=revoked_at, + ) + # ``outsider`` never held access through this group, so no revoke notice. + self.assertEqual(self._mailed(), [("Team", ["member@example.com"])]) + + def test_revoke_skips_members_who_keep_access_another_way(self) -> None: + _add_viewers(self.workflow, self.member) + self._send(group_ids=[self.group.id], share_action=ShareAction.REVOKED.value) + # Nothing was lost — a direct VIEWER row still reaches the resource. + self.service.send_group_resource_shared_notification.assert_not_called() + + def test_revoke_skips_members_who_keep_access_via_another_group(self) -> None: + # ``self.member`` is in both groups; only ``self.group`` is revoked. + other_group = OrganizationGroup.objects.create( + organization=self.org, name="Ops", created_by=self.owner + ) + GroupMembership.objects.create(group=other_group, user=self.member) + set_resource_share_groups(self.workflow, [other_group.id]) + self._send(group_ids=[self.group.id], share_action=ShareAction.REVOKED.value) + # Still reaches the resource through "Ops" -- nothing was lost. + self.service.send_group_resource_shared_notification.assert_not_called() + + def test_membership_change_mails_only_the_changed_users(self) -> None: + send_membership_changed( + organization=self.org, + group_id=self.group.id, + actor_id=self.owner.pk, + membership_action=MembershipAction.ADDED.value, + user_ids=[self.outsider.pk], + ) + kwargs = self.service.send_group_membership_notification.call_args.kwargs + self.assertEqual(kwargs["group_name"], "Team") + self.assertEqual(kwargs["membership_action"], "added") + self.assertEqual( + [u.email for u in kwargs["recipients"]], ["outsider@example.com"] + ) diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index 845b254177..938ed6c534 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -171,51 +171,6 @@ def perform_create(self, serializer: WorkflowSerializer) -> Workflow: raise WorkflowGenerationError return workflow - def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Override partial_update to handle sharing notifications.""" - workflow = self.get_object() - before = self.snapshot_share_axes(workflow) - - response = super().partial_update(request, *args, **kwargs) - if response.status_code == 200 and notification_plugin: - self._notify_shared_users(workflow, before, request.data, request.user) - return response - - def _notify_shared_users( - self, - workflow: Workflow, - before: dict[str, set[Any]], - request_data: dict[str, Any], - actor: Any, - ) -> None: - """Email users newly added to ``shared_users`` (best-effort).""" - users_diff = self.diff_share_axes(workflow, before, request_data).get( - "shared_users" - ) - if not (users_diff and users_diff.added): - return - try: - service_class = notification_plugin["service_class"] - notification_service = service_class() - notification_service.send_sharing_notification( - resource_type=ResourceType.WORKFLOW.value, - resource_name=workflow.workflow_name, - resource_id=str(workflow.id), - shared_by=actor, - shared_to=list(users_diff.added), - resource_instance=workflow, - ) - logger.info( - "Sent sharing notifications for workflow %s to %d users", - workflow.id, - len(users_diff.added), - ) - except Exception as e: - logger.exception( - "Failed to send sharing notification, continuing update though: %s", - str(e), - ) - def get_execution(self, request: Request, pk: str) -> Response: execution = WorkflowHelper.get_current_execution(pk) return Response(make_execution_response(execution), status=status.HTTP_200_OK) diff --git a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx index 21ff2a44c3..3cae374d22 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx @@ -65,8 +65,7 @@ function ApiDeployment() { coOwnerAllUsers, coOwnerResourceId, handleCoOwner: handleCoOwnerAction, - onAddCoOwner, - onRemoveCoOwner, + onApplyCoOwners, } = useCoOwnerManagement({ service: apiDeploymentsApiService, setAlertDetails, @@ -410,10 +409,8 @@ function ApiDeployment() { resourceType="API Deployment" allUsers={coOwnerAllUsers} coOwners={coOwnerData.coOwners} - createdBy={coOwnerData.createdBy} loading={coOwnerLoading} - onAddCoOwner={onAddCoOwner} - onRemoveCoOwner={onRemoveCoOwner} + onApplyCoOwners={onApplyCoOwners} /> ); diff --git a/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx b/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx index 5ed4382b1a..44c6df5a16 100644 --- a/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx +++ b/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx @@ -1,5 +1,5 @@ import PropTypes from "prop-types"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/shims/antd-button"; import { Form } from "@/components/ui/shims/antd-form"; import { Checkbox, Input, Select } from "@/components/ui/shims/antd-inputs"; @@ -68,23 +68,11 @@ function CreateNotification({ editDetails, }) { const [form] = Form.useForm(); - const [formDetails, setFormDetails] = useState(DEFAULT_FORM_DETAILS); + // Lazy init: the Form shim seeds initialValues only on first mount. + const [formDetails, setFormDetails] = useState( + () => editDetails ?? DEFAULT_FORM_DETAILS, + ); const [backendErrors, setBackendErrors] = useState(null); - const [resetForm, setResetForm] = useState(false); - - useEffect(() => { - if (editDetails) { - setFormDetails(editDetails); - setResetForm(true); - } - }, [editDetails]); - - useEffect(() => { - if (resetForm) { - form.resetFields(); - setResetForm(false); - } - }, [formDetails]); const handleInputChange = (changedValues, allValues) => { const nextValues = { ...formDetails, ...allValues }; diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx index edde00911e..52c38e0f8f 100644 --- a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx +++ b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx @@ -69,8 +69,7 @@ function Pipelines({ type }) { coOwnerAllUsers, coOwnerResourceId, handleCoOwner: handleCoOwnerAction, - onAddCoOwner, - onRemoveCoOwner, + onApplyCoOwners, } = useCoOwnerManagement({ service: pipelineApiService, setAlertDetails, @@ -487,10 +486,8 @@ function Pipelines({ type }) { resourceType="Pipeline" allUsers={coOwnerAllUsers} coOwners={coOwnerData.coOwners} - createdBy={coOwnerData.createdBy} loading={coOwnerLoading} - onAddCoOwner={onAddCoOwner} - onRemoveCoOwner={onRemoveCoOwner} + onApplyCoOwners={onApplyCoOwners} /> )} diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css index c7698eea62..75eb050515 100644 --- a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css +++ b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css @@ -3,10 +3,6 @@ margin-bottom: 16px; } -.co-owner-creator-tag { - margin-left: 8px; -} - .co-owner-modal .shared-user-avatar { background-color: #00a6ed; margin-right: 15px; diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx index 9ca010329b..280f8cc030 100644 --- a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx +++ b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx @@ -19,82 +19,90 @@ function CoOwnerManagement({ allUsers, coOwners, loading, - onAddCoOwner, - onRemoveCoOwner, + onApplyCoOwners, }) { - const [pendingAdds, setPendingAdds] = useState([]); - const [removingUserId, setRemovingUserId] = useState(null); + // Staged roster. Adds and removals both edit this list only — nothing reaches + // the API until Apply, the same contract as the share modal. + const [selectedOwners, setSelectedOwners] = useState([]); const [applying, setApplying] = useState(false); - const ownersList = coOwners || []; - const totalOwners = ownersList.length; - - // Exclude both existing co-owners and pending adds from dropdown - const availableUsers = useMemo(() => { - const coOwnerIds = new Set((coOwners || []).map((u) => u?.id?.toString())); - const pendingIds = new Set(pendingAdds.map((u) => u?.id?.toString())); - return (allUsers || []).filter( - (user) => - !coOwnerIds.has(user?.id?.toString()) && - !pendingIds.has(user?.id?.toString()), - ); - }, [allUsers, coOwners, pendingAdds]); + const ownersList = useMemo(() => coOwners || [], [coOwners]); + + // Re-seed whenever the server roster changes: on open, on resource switch, and + // after an Apply. Doubles as the reset — most hosts leave this modal mounted, + // and the hook can close it without ``handleCancel`` (404 / fetch-error), so + // staged edits must not leak into the next resource. + // + // Done during render, not in an effect: an effect commits after the one that + // reveals the new roster, so the first frame of a new resource would render + // the previous resource's staged list (and enable Apply on that diff). + const [seededFrom, setSeededFrom] = useState(null); + if (seededFrom !== ownersList) { + setSeededFrom(ownersList); + setSelectedOwners(ownersList); + } + + const selectedIds = useMemo( + () => new Set(selectedOwners.map((u) => u?.id?.toString())), + [selectedOwners], + ); + + const availableUsers = useMemo( + () => (allUsers || []).filter((u) => !selectedIds.has(u?.id?.toString())), + [allUsers, selectedIds], + ); + + const { addUsers, removeUsers } = useMemo(() => { + const ownerIds = new Set(ownersList.map((u) => u?.id?.toString())); + return { + addUsers: selectedOwners.filter((u) => !ownerIds.has(u?.id?.toString())), + removeUsers: ownersList.filter( + (u) => !selectedIds.has(u?.id?.toString()), + ), + }; + }, [ownersList, selectedOwners, selectedIds]); + + const hasChanges = addUsers.length > 0 || removeUsers.length > 0; const handleSelect = (userId) => { const user = (allUsers || []).find( (u) => u?.id?.toString() === userId?.toString(), ); if (user) { - setPendingAdds((prev) => [...prev, user]); + setSelectedOwners((prev) => [...prev, user]); } }; - const handleRemovePending = (userId) => { - setPendingAdds((prev) => + const handleRemove = (userId) => { + setSelectedOwners((prev) => prev.filter((u) => u?.id?.toString() !== userId?.toString()), ); }; - const handleRemoveExisting = async (userId) => { - setRemovingUserId(userId); - try { - await onRemoveCoOwner(resourceId, userId); - } finally { - setRemovingUserId(null); - } - }; - const handleApply = async () => { - if (pendingAdds.length === 0) return; - const usersToAdd = [...pendingAdds]; + if (!hasChanges) { + return; + } setApplying(true); try { - const userIds = usersToAdd.map((user) => user.id); - await onAddCoOwner(resourceId, userIds); + // Close only on a clean apply. A partial failure keeps the modal open on + // the refreshed server roster, with the alert naming who was rejected. + if (await onApplyCoOwners(resourceId, { addUsers, removeUsers })) { + setOpen(false); + } } finally { - setPendingAdds([]); setApplying(false); } }; const handleCancel = () => { - setPendingAdds([]); + setSelectedOwners(ownersList); setOpen(false); }; const filterOption = (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase()); - const combinedList = [ - ...ownersList, - ...pendingAdds.filter( - (pending) => - !ownersList.some( - (owner) => owner?.id?.toString() === pending?.id?.toString(), - ), - ), - ]; - return ( Co-Owners - {combinedList.length > 0 ? ( + {selectedOwners.length > 0 ? ( { - const isPending = pendingAdds.some( - (u) => u?.id?.toString() === item?.id?.toString(), - ); - return ( - } - onClick={() => handleRemovePending(item?.id)} - aria-label={`Remove pending co-owner ${item?.email}`} + dataSource={selectedOwners} + renderItem={(item) => ( + 1 && ( +
event.stopPropagation()} + role="none" + > + } + onConfirm={() => handleRemove(item?.id)} + > +
+ ) + } + > + + } /> - ) : ( - totalOwners > 1 && ( -
event.stopPropagation()} - role="none" - > - } - onConfirm={() => handleRemoveExisting(item?.id)} - > -
- ) - ) + + {item.email} + + } - > - - } - /> - - {item.email} - - - } - /> -
- ); - }} + /> +
+ )} /> ) : ( No co-owners yet @@ -209,13 +196,12 @@ function CoOwnerManagement({ CoOwnerManagement.propTypes = { open: PropTypes.bool.isRequired, setOpen: PropTypes.func.isRequired, - resourceId: PropTypes.string.isRequired, + resourceId: PropTypes.string, resourceType: PropTypes.string.isRequired, allUsers: PropTypes.array, coOwners: PropTypes.array, loading: PropTypes.bool, - onAddCoOwner: PropTypes.func.isRequired, - onRemoveCoOwner: PropTypes.func.isRequired, + onApplyCoOwners: PropTypes.func.isRequired, }; export { CoOwnerManagement }; diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx b/frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx index e66be15223..ccadfd0b82 100644 --- a/frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx +++ b/frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx @@ -21,10 +21,8 @@ function CoOwnerModal({ coOwner, resourceType }) { resourceType={resourceType} allUsers={coOwner.coOwnerAllUsers} coOwners={coOwner.coOwnerData.coOwners} - createdBy={coOwner.coOwnerData.createdBy} loading={coOwner.coOwnerLoading} - onAddCoOwner={coOwner.onAddCoOwner} - onRemoveCoOwner={coOwner.onRemoveCoOwner} + onApplyCoOwners={coOwner.onApplyCoOwners} /> ); } diff --git a/frontend/src/hooks/useCoOwnerManagement.jsx b/frontend/src/hooks/useCoOwnerManagement.jsx index 5a3b7e5ef1..def7ec4d5e 100644 --- a/frontend/src/hooks/useCoOwnerManagement.jsx +++ b/frontend/src/hooks/useCoOwnerManagement.jsx @@ -2,14 +2,88 @@ import { useCallback, useRef, useState } from "react"; import { useExceptionHandler } from "./useExceptionHandler"; +/** + * Summarize one Apply into a single alert. + * + * Failures carry the user object rather than the id, so an owner who has since + * left the org — and is therefore missing from the org member list — is still + * named by email. + */ +function buildApplyAlert( + addUsers, + removeUsers, + failed, + lastError, + handleException, +) { + const total = addUsers.length + removeUsers.length; + if (failed.length === total) { + return handleException(lastError, "Unable to update co-owners"); + } + const failedIds = new Set(failed.map((user) => String(user?.id))); + const done = (users) => + users.filter((user) => !failedIds.has(String(user?.id))).length; + const parts = []; + if (done(addUsers)) { + parts.push(`${done(addUsers)} added`); + } + if (done(removeUsers)) { + parts.push(`${done(removeUsers)} removed`); + } + const summary = `Co-owners updated: ${parts.join(", ")}`; + if (failed.length === 0) { + return { type: "success", content: summary }; + } + const failedNames = failed.map((user) => user?.email || user?.id).join(", "); + // "Not applied" rather than "Failed": this list also carries removals that + // were deliberately skipped because an addition failed first. + return { + type: "warning", + content: `${summary}. Not applied for: ${failedNames}`, + }; +} + +/** + * Run one Apply's add/remove calls. Attempts every user independently -- + * one rejection must not drop the rest or leave the modal contradicting the + * server. + */ +async function applyCoOwnerMutations( + service, + resourceId, + addUsers, + removeUsers, +) { + const failed = []; + let lastError = null; + const run = async (users, call) => { + for (const user of users) { + try { + await call(user.id); + } catch (err) { + failed.push(user); + lastError = err; + } + } + }; + // Adds first: the backend rejects removing the last owner, so a one-shot + // owner swap has to grow the roster before it shrinks it. + await run(addUsers, (id) => service.addCoOwner(resourceId, id)); + if (failed.length) { + // The roster never grew, so removing now can strip the very owner the + // swap was meant to replace. Report them rather than attempt them. + failed.push(...removeUsers); + } else { + await run(removeUsers, (id) => service.removeCoOwner(resourceId, id)); + } + return { failed, lastError }; +} + function useCoOwnerManagement({ service, setAlertDetails, onListRefresh }) { const handleException = useExceptionHandler(); const [coOwnerOpen, setCoOwnerOpen] = useState(false); - const [coOwnerData, setCoOwnerData] = useState({ - coOwners: [], - createdBy: null, - }); + const [coOwnerData, setCoOwnerData] = useState({ coOwners: [] }); const [coOwnerLoading, setCoOwnerLoading] = useState(false); const [coOwnerAllUsers, setCoOwnerAllUsers] = useState([]); const [coOwnerResourceId, setCoOwnerResourceId] = useState(null); @@ -21,16 +95,22 @@ function useCoOwnerManagement({ service, setAlertDetails, onListRefresh }) { // branch) after the user has moved to a different resource. Mutation // callers pass the token captured BEFORE their POSTs so a modal switch // during the mutation itself is caught too, not just one mid-refresh. + // Returns the verdict rather than leaving each caller to re-derive it: + // "stale" (a later modal superseded this one), "gone" (404), "error" + // (refresh failed; roster unverified), "ok". "gone" and "error" have both + // already raised their own alert. async (resourceId, requestId = latestRequestRef.current) => { try { const res = await service.getSharedUsers(resourceId); - if (latestRequestRef.current !== requestId) return; - setCoOwnerData({ - coOwners: res.data?.co_owners || [], - createdBy: res.data?.created_by || null, - }); + if (latestRequestRef.current !== requestId) { + return "stale"; + } + setCoOwnerData({ coOwners: res.data?.co_owners || [] }); + return "ok"; } catch (err) { - if (latestRequestRef.current !== requestId) return; + if (latestRequestRef.current !== requestId) { + return "stale"; + } if (err?.response?.status === 404) { setCoOwnerOpen(false); onListRefresh?.(); @@ -39,11 +119,12 @@ function useCoOwnerManagement({ service, setAlertDetails, onListRefresh }) { content: "This resource is no longer accessible. It may have been removed or your access has been revoked.", }); - return; + return "gone"; } setAlertDetails( handleException(err, "Unable to refresh co-owner data"), ); + return "error"; } }, [service, onListRefresh, setAlertDetails, handleException], @@ -63,7 +144,9 @@ function useCoOwnerManagement({ service, setAlertDetails, onListRefresh }) { service.getSharedUsers(resourceId), ]); - if (latestRequestRef.current !== requestId) return; + if (latestRequestRef.current !== requestId) { + return; + } const userList = usersResponse?.data?.members?.map((member) => ({ @@ -74,10 +157,11 @@ function useCoOwnerManagement({ service, setAlertDetails, onListRefresh }) { setCoOwnerAllUsers(userList); setCoOwnerData({ coOwners: sharedUsersResponse.data?.co_owners || [], - createdBy: sharedUsersResponse.data?.created_by || null, }); } catch (err) { - if (latestRequestRef.current !== requestId) return; + if (latestRequestRef.current !== requestId) { + return; + } setAlertDetails( handleException(err, "Unable to fetch co-owner information"), ); @@ -91,73 +175,37 @@ function useCoOwnerManagement({ service, setAlertDetails, onListRefresh }) { [service, setAlertDetails, handleException], ); - const onAddCoOwner = useCallback( - async (resourceId, userIdOrIds) => { + const onApplyCoOwners = useCallback( + async (resourceId, { addUsers = [], removeUsers = [] }) => { const requestId = latestRequestRef.current; - const isBatch = Array.isArray(userIdOrIds); - const userIds = isBatch ? userIdOrIds : [userIdOrIds]; - // Attempt every id independently — a mid-batch failure must not drop the - // remaining ids or contradict the refreshed modal state. - const failedIds = []; - let lastError = null; - for (const userId of userIds) { - try { - await service.addCoOwner(resourceId, userId); - } catch (err) { - failedIds.push(userId); - lastError = err; - } + const { failed, lastError } = await applyCoOwnerMutations( + service, + resourceId, + addUsers, + removeUsers, + ); + // Reconverge on true server state regardless of partial outcome. + const outcome = await refreshCoOwnerData(resourceId, requestId); + if (outcome !== "gone") { + onListRefresh?.(); // page-scoped list; "gone" already refreshed it } - // Reconverge the modal on true server state regardless of partial outcome. - await refreshCoOwnerData(resourceId, requestId); - onListRefresh?.(); - - const succeeded = userIds.length - failedIds.length; - if (failedIds.length === 0) { - setAlertDetails({ - type: "success", - content: isBatch - ? "Co-owners added successfully" - : "Co-owner added successfully", - }); - } else if (succeeded === 0) { - setAlertDetails(handleException(lastError, "Unable to add co-owner")); - } else { - const failedEmails = coOwnerAllUsers - .filter((user) => failedIds.includes(user.id)) - .map((user) => user.email); - setAlertDetails({ - type: "warning", - content: `Added ${succeeded} of ${userIds.length} co-owners. Failed for: ${ - failedEmails.join(", ") || failedIds.join(", ") - }`, - }); + if (outcome === "stale") { + return false; // another resource is open now, not our modal to alert } - }, - [ - service, - refreshCoOwnerData, - onListRefresh, - setAlertDetails, - handleException, - coOwnerAllUsers, - ], - ); - - const onRemoveCoOwner = useCallback( - async (resourceId, userId) => { - const requestId = latestRequestRef.current; - try { - await service.removeCoOwner(resourceId, userId); - setAlertDetails({ - type: "success", - content: "Co-owner removed successfully", - }); - await refreshCoOwnerData(resourceId, requestId); - onListRefresh?.(); - } catch (err) { - setAlertDetails(handleException(err, "Unable to remove co-owner")); + if (outcome === "gone") { + return true; // refresh already closed the modal and alerted } + // "ok" and "error" both fall through: the mutations landed either way. + setAlertDetails( + buildApplyAlert( + addUsers, + removeUsers, + failed, + lastError, + handleException, + ), + ); + return failed.length === 0; }, [ service, @@ -176,8 +224,7 @@ function useCoOwnerManagement({ service, setAlertDetails, onListRefresh }) { coOwnerAllUsers, coOwnerResourceId, handleCoOwner, - onAddCoOwner, - onRemoveCoOwner, + onApplyCoOwners, }; } diff --git a/workers/notification/tasks.py b/workers/notification/tasks.py index 10c908b119..380e4f5dcb 100644 --- a/workers/notification/tasks.py +++ b/workers/notification/tasks.py @@ -6,6 +6,7 @@ """ import os +import time from typing import Any import httpx @@ -506,6 +507,206 @@ def priority_notification(notification_type: str, **kwargs: Any) -> dict[str, An return process_notification(notification_type, priority=True, **kwargs) +# Retries for a transient backend problem (restart, 5xx). Kept inside the task +# so a brief blip is absorbed here rather than costing a full lease-expiry +# redelivery (minutes) plus one of the consumer's bounded attempts. +_GROUP_NOTIFICATION_ATTEMPTS = 3 +_GROUP_NOTIFICATION_RETRY_DELAY = 2.0 +# Per-phase, because httpx has NO whole-request timeout: a scalar one is applied +# to connect, write and read separately. The loop below is the only retry -- +# transport-level retries would stack their own timeouts underneath these. +# Worst case per attempt is connect+write+read+pool = 50s. Across +# _GROUP_NOTIFICATION_ATTEMPTS attempts plus the sleep between each retry, +# that's a NOMINAL budget of 3*50 + 2*_GROUP_NOTIFICATION_RETRY_DELAY = 154s -- +# not a hard bound, since ``read`` times out per socket read, not on the +# total: a response trickling in under 30s per chunk runs past this +# regardless. Sized to stay under WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS +# (the heartbeat is frozen for the task's duration) and VT_SECONDS in the +# common case. Excludes DNS, which connect does not cover. +_GROUP_NOTIFICATION_TIMEOUT = httpx.Timeout(connect=5.0, write=10.0, read=30.0, pool=5.0) + + +def _build_group_notification_request( + endpoint: str, organization_id: str +) -> tuple[str, dict[str, str]]: + """URL and headers for one group-notification POST.""" + base_url = os.getenv("INTERNAL_API_BASE_URL") + api_key = os.getenv("INTERNAL_SERVICE_API_KEY") + if not base_url or not api_key: + raise RuntimeError( + "INTERNAL_API_BASE_URL / INTERNAL_SERVICE_API_KEY not set; " + "cannot send group notification" + ) + url = f"{base_url.rstrip('/')}/v1/group-notification/{endpoint}/" + headers = { + "Authorization": f"Bearer {api_key}", + # The backend resolves the tenant from this header; without it every + # org-scoped query comes back empty. + "X-Organization-ID": organization_id, + } + return url, headers + + +def _post_group_notification_once( + url: str, headers: dict[str, str], payload: dict +) -> tuple[bool, bool, str]: + """One POST attempt. Returns ``(succeeded, retryable, error)``. + + A response lost after the backend already received it is treated like a + sub-500 (not retryable): the backend does not stop when we disconnect and + mails group by group with no checkpoint, so re-posting would re-mail every + group that already succeeded. + """ + try: + with httpx.Client() as client: + response = client.post( + url, headers=headers, json=payload, timeout=_GROUP_NOTIFICATION_TIMEOUT + ) + except ( + httpx.ReadTimeout, + httpx.WriteTimeout, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, + ) as e: + return False, False, f"timeout_after_send={e!r}" + except Exception as e: # noqa: BLE001 + return False, True, f"exception={e!r}" + if response.status_code == 200: + return True, False, "" + error = f"http_{response.status_code} body={response.text[:200]}" + return False, response.status_code >= 500, error + + +def _fail_group_notification(endpoint: str, organization_id: str, error: str) -> None: + """Log and raise once a retryable failure exhausts its in-process attempts. + + The raise leaves the message on the queue for redelivery -- correct here + because the failure is transient (5xx / connection-level), so a later + attempt has a real chance of succeeding. + """ + logger.error( + "metric=group_notification_post_failed_total endpoint=%s org_id=%s error=%s", + endpoint, + organization_id, + error, + ) + raise RuntimeError(f"Group notification {endpoint} failed: {error}") + + +def _drop_group_notification( + endpoint: str, organization_id: str, error: str, payload: dict +) -> None: + """Log a permanent failure without raising. + + A non-retryable failure (a definitive 4xx, or a response lost after the + backend already sent the group's emails) will not succeed on redelivery -- + and since one send call mails a whole group with no per-recipient + checkpoint, redelivering it re-mails everyone who already got it. Raising + here would trade a dropped notification for a duplicated one. + + The message is acked and deleted once this returns -- nothing else records + what was lost, so the payload goes in the log line (a dropped *revoke* is + compliance-visible, not just an inconvenience). + """ + logger.error( + "metric=group_notification_dropped_total endpoint=%s org_id=%s error=%s " + "payload=%s", + endpoint, + organization_id, + error, + payload, + ) + + +def _post_group_notification(endpoint: str, organization_id: str, payload: dict) -> None: + """POST a group-notification job to the backend and insist it succeeded. + + Raises only on a retryable failure -- nothing tracks an unsent group + email, so a swallowed transient error would be a silent drop, and the + queue's own redelivery is the backstop for that. A non-retryable failure + is dropped instead of raised: see :func:`_drop_group_notification`. + """ + url, headers = _build_group_notification_request(endpoint, organization_id) + last_error = "" + # Seeded False: a zero-iteration loop (only possible if + # _GROUP_NOTIFICATION_ATTEMPTS were ever misconfigured to <= 0) means + # nothing was ever attempted, so drop rather than raise -- raising here + # would redeliver forever with no attempt ever being made. + retryable = False + for attempt in range(1, _GROUP_NOTIFICATION_ATTEMPTS + 1): + succeeded, retryable, last_error = _post_group_notification_once( + url, headers, payload + ) + if succeeded: + return + if not retryable: + break + if attempt < _GROUP_NOTIFICATION_ATTEMPTS: + logger.warning( + "Group notification %s attempt %d/%d failed (%s); retrying", + endpoint, + attempt, + _GROUP_NOTIFICATION_ATTEMPTS, + last_error, + ) + time.sleep(_GROUP_NOTIFICATION_RETRY_DELAY) + if retryable: + _fail_group_notification(endpoint, organization_id, last_error) + else: + _drop_group_notification(endpoint, organization_id, last_error, payload) + + +@worker_task(name="notify_resource_shared_with_group") +def notify_resource_shared_with_group( + group_ids: list[int], + actor_id: int, + resource_kind: str, + resource_id: str, + organization_id: str, + share_action: str, + revoked_at: str | None = None, +) -> None: + """Email every current member of the groups whose access just changed. + + ``revoked_at`` is set on a revoke only; the backend uses it to skip members + who joined the group after the access was taken away. + """ + _post_group_notification( + "resource-shared", + organization_id, + { + "group_ids": group_ids, + "actor_id": actor_id, + "resource_kind": resource_kind, + "resource_id": resource_id, + "share_action": share_action, + "revoked_at": revoked_at, + }, + ) + + +@worker_task(name="notify_group_membership_changed") +def notify_group_membership_changed( + group_id: int, + actor_id: int, + membership_action: str, + user_ids: list[int], + organization_id: str, +) -> None: + """Email the users whose membership of a group just changed.""" + _post_group_notification( + "membership-changed", + organization_id, + { + "group_id": group_id, + "actor_id": actor_id, + "membership_action": membership_action, + "user_ids": user_ids, + }, + ) + + @worker_task(name="notification_health_check") def notification_health_check() -> dict[str, Any]: """Health check task for notification worker.""" diff --git a/workers/tests/test_group_notification_post.py b/workers/tests/test_group_notification_post.py new file mode 100644 index 0000000000..4c04365b71 --- /dev/null +++ b/workers/tests/test_group_notification_post.py @@ -0,0 +1,278 @@ +"""The group-notification worker leg is entirely failure-path logic. + +``_post_group_notification`` is the only thing standing between a transient +backend blip and a permanently unsent email, and every branch in it encodes a +different judgement about whether re-posting is safe. Two of those judgements +are easy to get backwards: + +* A response the backend never sent (connect refused, DNS gone) is safe to + re-post -- nothing happened. +* A response lost *after* the request arrived is not. The backend does not stop + when the client disconnects, and the send path mails group by group with no + checkpoint, so a re-post re-mails everyone who already received it. + +These pin which exception lands on which side, plus the attempt cap, the +sub-500 break, and the fact that the timeout is per-phase rather than scalar -- +httpx applies a scalar timeout to connect, write and read separately, so a +scalar one silently triples the task's worst-case wall time and can push it +past the queue's visibility timeout. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from notification.tasks import ( + _GROUP_NOTIFICATION_ATTEMPTS, + _GROUP_NOTIFICATION_RETRY_DELAY, + _GROUP_NOTIFICATION_TIMEOUT, + _post_group_notification, + notify_group_membership_changed, + notify_resource_shared_with_group, +) + +_ENV = { + "INTERNAL_API_BASE_URL": "http://backend/internal", + "INTERNAL_SERVICE_API_KEY": "k", +} + + +def _response(status: int) -> MagicMock: + return MagicMock(status_code=status, text="body") + + +class _Client: + """Stand-in for ``httpx.Client`` that records posts and replays outcomes.""" + + def __init__(self, outcomes): + self.outcomes = list(outcomes) + self.calls = [] + + def __call__(self, *a, **kw): + return self + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + outcome = self.outcomes.pop(0) if self.outcomes else _response(200) + if isinstance(outcome, Exception): + raise outcome + return outcome + + +def _run(outcomes, endpoint="resource-shared", payload=None): + """Drive one ``_post_group_notification`` with ``outcomes`` per attempt.""" + client = _Client(outcomes) + with ( + patch.dict("os.environ", _ENV, clear=False), + patch("notification.tasks.httpx.Client", client), + patch("notification.tasks.time.sleep") as sleep, + ): + raised = None + try: + _post_group_notification(endpoint, "org-a", payload or {"x": 1}) + except Exception as e: # noqa: BLE001 + raised = e + return client, raised, sleep + + +class TestRetryClassification: + def test_success_posts_once_and_returns(self): + client, raised, _ = _run([_response(200)]) + assert raised is None + assert len(client.calls) == 1 + + def test_server_error_exhausts_the_attempt_cap_then_raises(self): + client, raised, sleep = _run([_response(503)] * _GROUP_NOTIFICATION_ATTEMPTS) + assert isinstance(raised, RuntimeError) + assert len(client.calls) == _GROUP_NOTIFICATION_ATTEMPTS + assert sleep.call_count == _GROUP_NOTIFICATION_ATTEMPTS - 1 + + def test_client_error_is_dropped_without_raising(self): + # A rejected payload will be rejected again; retrying only burns + # budget, and raising would trigger queue redelivery of a message + # that can never succeed. + client, raised, sleep = _run([_response(400)]) + assert raised is None + assert len(client.calls) == 1 + assert sleep.call_count == 0 + + @pytest.mark.parametrize( + "exc", + [ + httpx.ReadTimeout("read"), + httpx.WriteTimeout("write"), + httpx.ReadError("read err"), + httpx.WriteError("write err"), + httpx.RemoteProtocolError("server disconnected"), + ], + ) + def test_request_sent_outcome_unknown_is_never_re_posted(self, exc): + """Each of these means the backend already has the request. + + Re-posting would re-mail every group that already succeeded, so the + in-process attempts must end after one post -- and the task must not + raise either, or queue redelivery does the exact re-post this guards + against. + """ + client, raised, sleep = _run([exc]) + assert raised is None + assert len(client.calls) == 1, f"{type(exc).__name__} was re-posted" + assert sleep.call_count == 0 + + @pytest.mark.parametrize( + "exc", + [ + httpx.ConnectTimeout("connect"), + httpx.ConnectError("refused"), + httpx.PoolTimeout("pool"), + ], + ) + def test_request_never_left_is_retried(self, exc): + """Nothing reached the backend, so the full attempt cap is correct.""" + client, raised, _ = _run([exc] * _GROUP_NOTIFICATION_ATTEMPTS) + assert isinstance(raised, RuntimeError) + assert len(client.calls) == _GROUP_NOTIFICATION_ATTEMPTS + + def test_recovers_when_a_later_attempt_succeeds(self): + client, raised, _ = _run([_response(502), _response(200)]) + assert raised is None + assert len(client.calls) == 2 + + def test_retryable_then_terminal_stops_and_drops_not_raises(self): + # A transient 503 retries once, then a 400 on that retry is terminal -- + # the loop must stop there (not spend the 3rd attempt) and the final + # classification (400 -> not retryable) decides drop-without-raising, + # not the classification of the earlier, already-superseded attempt. + client, raised, sleep = _run([_response(503), _response(400)]) + assert raised is None + assert len(client.calls) == 2 + assert sleep.call_count == 1 + + +class TestRequestShape: + def test_missing_credentials_raise_before_any_post(self): + client = _Client([]) + with ( + patch.dict( + "os.environ", + {"INTERNAL_API_BASE_URL": "", "INTERNAL_SERVICE_API_KEY": ""}, + ), + patch("notification.tasks.httpx.Client", client), + ): + with pytest.raises(RuntimeError): + _post_group_notification("resource-shared", "org-a", {}) + assert client.calls == [] + + def test_url_auth_and_tenant_header(self): + client, _, _ = _run([_response(200)], endpoint="membership-changed") + url, kwargs = client.calls[0] + assert url == "http://backend/internal/v1/group-notification/membership-changed/" + assert kwargs["headers"]["Authorization"] == "Bearer k" + # Without this the backend resolves no tenant and every org-scoped + # query comes back empty -- a silent no-op rather than an error. + assert kwargs["headers"]["X-Organization-ID"] == "org-a" + + def test_timeout_is_per_phase_not_scalar(self): + """A scalar timeout is applied to connect, write AND read separately. + + Passing one would let a single post spend it three times over, which is + what pushed this task past the consumer's visibility timeout. + """ + client, _, _ = _run([_response(200)]) + timeout = client.calls[0][1]["timeout"] + assert isinstance(timeout, httpx.Timeout) + assert timeout is _GROUP_NOTIFICATION_TIMEOUT + assert timeout.connect < timeout.read + + def test_timeout_constants_sum_under_the_visibility_and_health_stale_ceilings(self): + """Consistency check on the constants, not a measurement of a real POST. + + VT is 300s for this worker in both deployments; the heartbeat is frozen + for the task's duration, so health-stale (360s) is the other ceiling. + Includes the sleep between retries -- a per-post-only sum undercounts + the real wall time by (attempts - 1) * retry delay. Nothing here times + an actual request -- this only proves the constants are still + consistent with each other, not that a real POST stays under budget. + """ + t = _GROUP_NOTIFICATION_TIMEOUT + per_post = t.connect + t.write + t.read + t.pool + sleeps = (_GROUP_NOTIFICATION_ATTEMPTS - 1) * _GROUP_NOTIFICATION_RETRY_DELAY + worst = per_post * _GROUP_NOTIFICATION_ATTEMPTS + sleeps + assert worst < 300, f"worst case {worst}s exceeds the 300s visibility timeout" + assert worst < 360, f"worst case {worst}s exceeds the 360s health-stale ceiling" + + +class TestTaskPayloads: + def test_resource_shared_task_sends_every_field_the_endpoint_requires(self): + client = _Client([_response(200)]) + with ( + patch.dict("os.environ", _ENV, clear=False), + patch("notification.tasks.httpx.Client", client), + ): + notify_resource_shared_with_group( + group_ids=[2, 5], + actor_id=7, + resource_kind="workflow", + resource_id="wf-1", + organization_id="org-a", + share_action="revoked", + revoked_at="2026-01-01T00:00:00+00:00", + ) + url, kwargs = client.calls[0] + assert url.endswith("/resource-shared/") + assert kwargs["json"] == { + "group_ids": [2, 5], + "actor_id": 7, + "resource_kind": "workflow", + "resource_id": "wf-1", + "share_action": "revoked", + "revoked_at": "2026-01-01T00:00:00+00:00", + } + + def test_share_direction_sends_a_null_cutoff_rather_than_omitting_it(self): + # The endpoint requires the key on both directions; omitting it is a 400. + client = _Client([_response(200)]) + with ( + patch.dict("os.environ", _ENV, clear=False), + patch("notification.tasks.httpx.Client", client), + ): + notify_resource_shared_with_group( + group_ids=[2], + actor_id=7, + resource_kind="workflow", + resource_id="wf-1", + organization_id="org-a", + share_action="shared", + revoked_at=None, + ) + assert client.calls[0][1]["json"]["revoked_at"] is None + + def test_membership_task_payload(self): + client = _Client([_response(200)]) + with ( + patch.dict("os.environ", _ENV, clear=False), + patch("notification.tasks.httpx.Client", client), + ): + notify_group_membership_changed( + group_id=3, + actor_id=7, + membership_action="removed", + user_ids=[11, 12], + organization_id="org-a", + ) + url, kwargs = client.calls[0] + assert url.endswith("/membership-changed/") + assert kwargs["json"] == { + "group_id": 3, + "actor_id": 7, + "membership_action": "removed", + "user_ids": [11, 12], + }