diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index 3755bb2714..4d589f0c8f 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -41,6 +41,11 @@ def for_user(self, user: User) -> QuerySet[Any]: if OrganizationMemberService.is_user_organization_admin(user): return self.get_queryset() + from tenant_account_v2.sharing_helpers import resources_visible_via_groups + + user_group_ids = user.group_memberships.values_list("group_id", flat=True) + group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + return ( self.get_queryset() .filter( @@ -48,6 +53,7 @@ def for_user(self, user: User) -> QuerySet[Any]: | models.Q(shared_users=user) | models.Q(shared_to_org=True) | models.Q(is_friction_less=True) + | models.Q(pk__in=group_shared_ids) ) .distinct("id") ) @@ -140,6 +146,15 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel): shared_users = models.ManyToManyField(User, related_name="shared_adapters_instance") description = models.TextField(blank=True, null=True, default=None) + # ``shared_groups`` is stored polymorphically in + # ``tenant_account_v2.ResourceGroupShare``; the property preserves the + # ergonomic read surface for DRF / existing callers. + @property + def shared_groups(self): + from tenant_account_v2.sharing_helpers import get_resource_share_groups + + return get_resource_share_groups(self) + objects = AdapterInstanceModelManager() class Meta: diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 627731ed84..0676a543cd 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -6,6 +6,7 @@ from django.conf import settings from rest_framework import serializers from rest_framework.serializers import ModelSerializer +from tenant_account_v2.sharing_helpers import serialize_group_refs from utils.input_sanitizer import validate_name_field, validate_no_html_tags from adapter_processor_v2.adapter_processor import AdapterProcessor @@ -25,9 +26,18 @@ class TestAdapterSerializer(serializers.Serializer): class BaseAdapterSerializer(AuditSerializer): + # ``shared_groups`` is no longer an M2M on AdapterInstance — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. Share + # mutations go through ``POST /adapter/{id}/share/`` (UN-2977 plan §B). + shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + class Meta: model = AdapterInstance fields = "__all__" + extra_kwargs = { + "shared_users": {"read_only": True}, + "shared_to_org": {"read_only": True}, + } def validate(self, data): data = super().validate(data) @@ -205,6 +215,7 @@ class SharedUserListSerializer(BaseAdapterSerializer): """ shared_users = serializers.SerializerMethodField() + shared_groups = serializers.SerializerMethodField() created_by = UserSerializer() class Meta(BaseAdapterSerializer.Meta): @@ -217,6 +228,7 @@ class Meta(BaseAdapterSerializer.Meta): "created_by", "shared_users", "shared_to_org", + "shared_groups", ) # type: ignore def get_shared_users(self, obj): @@ -224,6 +236,9 @@ def get_shared_users(self, obj): obj.shared_users.filter(is_service_account=False), many=True ).data + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + class UserDefaultAdapterSerializer(ModelSerializer): class Meta: diff --git a/backend/adapter_processor_v2/urls.py b/backend/adapter_processor_v2/urls.py index 8e12fb7200..9217169e6f 100644 --- a/backend/adapter_processor_v2/urls.py +++ b/backend/adapter_processor_v2/urls.py @@ -25,6 +25,8 @@ adapter_users = AdapterInstanceViewSet.as_view({"get": "list_of_shared_users"}) adapter_info = AdapterInstanceViewSet.as_view({"get": "adapter_info"}) +adapter_share = AdapterInstanceViewSet.as_view({"post": "share"}) +adapter_effective_members = AdapterInstanceViewSet.as_view({"get": "effective_members"}) urlpatterns = format_suffix_patterns( [ path("adapter_schema/", adapter_schema, name="get_adapter_schema"), @@ -39,5 +41,11 @@ adapter_users, name="adapter-users", ), + path("adapter//share/", adapter_share, name="adapter-share"), + path( + "adapter//effective-members/", + adapter_effective_members, + name="adapter-effective-members", + ), ] ) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index a7e6284e75..6b6c082b04 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -12,6 +12,7 @@ IsOwner, IsOwnerOrSharedUserOrSharedToOrg, ) +from permissions.resource_share_views import ResourceShareManagementMixin from plugins import get_plugin from rest_framework import status from rest_framework.decorators import action @@ -135,21 +136,25 @@ def test(self, request: Request) -> Response: ) -class AdapterInstanceViewSet(ModelViewSet): +class AdapterInstanceViewSet(ResourceShareManagementMixin, ModelViewSet): serializer_class = AdapterInstanceSerializer def get_permissions(self) -> list[Any]: - if self.action in ["update", "retrieve"]: + # Frictionless adapter quirk preserved: blocks update + retrieve so + # frictionless adapters stay hidden from non-owners, and lets any + # org member delete them. Non-frictionless adapters fall through to + # the standard owner / shared-user gating. + if self.action in ["update", "partial_update", "retrieve"]: return [IsFrictionLessAdapter()] - - elif self.action == "destroy": + if self.action == "destroy": return [IsFrictionLessAdapterDelete()] - - elif self.action in ["list_of_shared_users", "adapter_info"]: + if self.action in [ + "list_of_shared_users", + "adapter_info", + "share", + "effective_members", + ]: return [IsOwnerOrSharedUserOrSharedToOrg()] - - # Hack for friction-less onboarding - # User cant view/update metadata but can delete/share etc return [IsOwner()] def get_queryset(self) -> QuerySet | None: @@ -292,91 +297,116 @@ def destroy( def partial_update( self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any] ) -> Response: - # Store current shared users before update (for email notifications) adapter = self.get_object() - current_shared_users = set(adapter.shared_users.all()) + before = self.snapshot_share_axes(adapter) - if AdapterKeys.SHARED_USERS in request.data: - # find the deleted users - shared_users = { - int(user_id) for user_id in request.data.get("shared_users", {}) - } - current_users = {user.id for user in adapter.shared_users.all()} - removed_users = current_users.difference(shared_users) + 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 - # if removed user use this adapter as default - # Remove the same from his default - for user_id in removed_users: - try: - organization_member = OrganizationMemberService.get_user_by_id( - id=user_id - ) - user_default_adapter: UserDefaultAdapter = ( - UserDefaultAdapter.objects.get( - organization_member=organization_member - ) - ) + @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 + who lost access. - adapter_fields = [ - "default_llm_adapter", - "default_embedding_adapter", - "default_vector_db_adapter", - "default_x2text_adapter", - ] - - updated = False - for field in adapter_fields: - if getattr(user_default_adapter, field) == adapter: - setattr(user_default_adapter, field, None) - updated = True - - if updated: - user_default_adapter.save() - except UserDefaultAdapter.DoesNotExist: - logger.debug( - "User id : %s doesnt have default adapters configured", - user_id, - ) - continue + ``shared_users`` is read-only on the serializer, so unsharing happens + only here (not via ``partial_update``). Diff the M2M before/after the + commit so cleanup keys off who actually lost access. + """ + adapter = self.get_object() + before_user_ids = set(adapter.shared_users.values_list("id", flat=True)) + response = super().share(request, pk) + if response.status_code == status.HTTP_200_OK: + adapter.refresh_from_db() + after_user_ids = set(adapter.shared_users.values_list("id", flat=True)) + self._clear_default_adapter_for_removed_users( + adapter, before_user_ids - after_user_ids + ) + return response - # Perform the update - response = super().partial_update(request, *args, **kwargs) + 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) - # Send email notifications to newly shared users - if response.status_code == 200 and AdapterKeys.SHARED_USERS in request.data: - try: - adapter.refresh_from_db() - new_shared_users = set(adapter.shared_users.all()) - newly_shared_users = new_shared_users - current_shared_users - - if newly_shared_users: - # Map adapter type to specific resource type - 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 - ) + def _clear_default_adapter_for_removed_users( + self, + adapter: AdapterInstance, + removed_user_ids: set[int], + ) -> None: + """Null out ``UserDefaultAdapter`` rows pointing at ``adapter`` for + users who just lost access via the ``share`` action. + """ + adapter_fields = ( + "default_llm_adapter", + "default_embedding_adapter", + "default_vector_db_adapter", + "default_x2text_adapter", + ) - # Get notification service from plugin - 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=request.user, - shared_to=list(newly_shared_users), - resource_instance=adapter, + for user_id in removed_user_ids: + try: + organization_member = OrganizationMemberService.get_user_by_id(id=user_id) + user_default_adapter = UserDefaultAdapter.objects.get( + organization_member=organization_member + ) + except UserDefaultAdapter.DoesNotExist: + logger.debug( + "User id : %s doesnt have default adapters configured", + user_id, + ) + continue + + updated = False + for field_name in adapter_fields: + if getattr(user_default_adapter, field_name) == adapter: + setattr(user_default_adapter, field_name, None) + updated = True + if updated: + # Best-effort: the share already committed, so log a cleanup + # failure instead of letting it surface as a 500 on a successful + # share or silently disappear. + try: + user_default_adapter.save() + except Exception: + logger.exception( + "Failed clearing default adapter for user_id=%s after " + "share on adapter=%s", + user_id, + adapter.id, ) - except Exception as e: - logger.exception(f"Failed to send sharing notification: {e}") - - return response @action(detail=True, methods=["get"]) def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response: diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 61fe0ae84d..b06394374c 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -6,6 +6,7 @@ from django.db.models import F, OuterRef, QuerySet, Subquery from django.http import HttpResponse from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg +from permissions.resource_share_views import ResourceShareManagementMixin from plugins import get_plugin from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry from rest_framework import serializers, status, views, viewsets @@ -228,7 +229,7 @@ def get( ) -class APIDeploymentViewSet(viewsets.ModelViewSet): +class APIDeploymentViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): pagination_class = CustomPagination def get_permissions(self) -> list[Any]: @@ -376,37 +377,37 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Override partial_update to handle sharing notifications.""" - # Get current instance and shared users instance = self.get_object() - current_shared_users = set(instance.shared_users.all()) + before = self.snapshot_share_axes(instance) - # Perform the update response = super().partial_update(request, *args, **kwargs) - - # If successful and shared_users changed, send notifications - if ( - response.status_code == 200 - and "shared_users" in request.data - and notification_plugin - ): - try: - instance.refresh_from_db() - new_shared_users = set(instance.shared_users.all()) - newly_shared_users = new_shared_users - current_shared_users - - if newly_shared_users: - # Get notification service from plugin - 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=request.user, - shared_to=list(newly_shared_users), - resource_instance=instance, - ) - except Exception as e: - logger.exception(f"Failed to send sharing notification: {e}") - + 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/api_v2/models.py b/backend/api_v2/models.py index b4bc67dabb..e4d52aeee5 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -32,6 +32,7 @@ def for_user(self, user): - API deployments created by the user - API deployments shared with the user - API deployments shared with the entire organization + - API deployments shared with any group the user is a member of - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): @@ -40,10 +41,15 @@ def for_user(self, user): if OrganizationMemberService.is_user_organization_admin(user): return self.all() + from tenant_account_v2.sharing_helpers import resources_visible_via_groups + + user_group_ids = user.group_memberships.values_list("group_id", flat=True) + group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) return self.filter( Q(created_by=user) # Owned by user | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization + | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() @@ -107,6 +113,15 @@ class APIDeployment(DefaultOrganizationMixin, BaseModel): default=False, db_comment="Whether this API deployment is shared with the entire organization", ) + # ``shared_groups`` is stored polymorphically in + # ``tenant_account_v2.ResourceGroupShare``; the property preserves the + # ergonomic read surface for DRF / existing callers. + + @property + def shared_groups(self): + from tenant_account_v2.sharing_helpers import get_resource_share_groups + + return get_resource_share_groups(self) # Manager objects = APIDeploymentModelManager() diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 7c9a5a7696..2c7c911476 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -8,6 +8,7 @@ from django.core.validators import RegexValidator from pipeline_v2.models import Pipeline from prompt_studio.prompt_profile_manager_v2.models import ProfileManager +from rest_framework import serializers from rest_framework.serializers import ( BooleanField, CharField, @@ -22,6 +23,7 @@ ValidationError, ) from tags.serializers import TagParamsSerializer +from tenant_account_v2.sharing_helpers import serialize_group_refs from utils.input_sanitizer import validate_name_field, validate_no_html_tags from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from workflow_manager.endpoint_v2.models import WorkflowEndpoint @@ -34,9 +36,18 @@ class APIDeploymentSerializer(IntegrityErrorMixin, AuditSerializer): + # ``shared_groups`` is no longer an M2M on APIDeployment — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. Share + # mutations go through ``POST /api//share/`` (UN-2977 plan §B). + shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + class Meta: model = APIDeployment fields = "__all__" + extra_kwargs = { + "shared_users": {"read_only": True}, + "shared_to_org": {"read_only": True}, + } unique_error_message_map: dict[str, dict[str, str]] = { "unique_api_name": { @@ -511,14 +522,22 @@ class APIExecutionResponseSerializer(Serializer): class SharedUserListSerializer(ModelSerializer): - """Serializer for returning API deployment with shared user details.""" + """Serializer for returning API deployment with shared user + group details.""" shared_users = SerializerMethodField() + shared_groups = SerializerMethodField() created_by = SerializerMethodField() class Meta: model = APIDeployment - fields = ["id", "display_name", "shared_users", "shared_to_org", "created_by"] + fields = [ + "id", + "display_name", + "shared_users", + "shared_to_org", + "shared_groups", + "created_by", + ] def get_shared_users(self, obj): """Return list of shared users with id and email.""" @@ -527,6 +546,9 @@ def get_shared_users(self, obj): for user in obj.shared_users.filter(is_service_account=False) ] + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + def get_created_by(self, obj): """Return creator details.""" if obj.created_by: diff --git a/backend/api_v2/urls.py b/backend/api_v2/urls.py index 1e275ef9ea..72c1472275 100644 --- a/backend/api_v2/urls.py +++ b/backend/api_v2/urls.py @@ -33,6 +33,8 @@ "get": APIDeploymentViewSet.list_of_shared_users.__name__, } ) +deployment_share = APIDeploymentViewSet.as_view({"post": "share"}) +deployment_effective_members = APIDeploymentViewSet.as_view({"get": "effective_members"}) execute = DeploymentExecution.as_view() @@ -63,6 +65,16 @@ list_shared_users, name="api_deployment_list_shared_users", ), + path( + "deployment//share/", + deployment_share, + name="api_deployment_share", + ), + path( + "deployment//effective-members/", + deployment_effective_members, + name="api_deployment_effective_members", + ), path( "deployment/by-prompt-studio-tool/", by_prompt_studio_tool, diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index a77b44adaf..479cb5476b 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -213,6 +213,10 @@ def get_required_setting(setting_key: str, default: str | None = None) -> str | # Maximum number of times a file can be executed in a workflow MAX_FILE_EXECUTION_COUNT = int(os.environ.get("MAX_FILE_EXECUTION_COUNT", 3)) +# Org-scoped group sharing (UN-2977 / mfbt UNS-612) +MAX_GROUPS_PER_ORG = int(os.environ.get("MAX_GROUPS_PER_ORG", 200)) +MAX_MEMBERS_PER_GROUP = int(os.environ.get("MAX_MEMBERS_PER_GROUP", 500)) + CELERY_RESULT_CHORD_RETRY_INTERVAL = float( os.environ.get("CELERY_RESULT_CHORD_RETRY_INTERVAL", "3") ) diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index bca5d6ff10..946bab005b 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -34,12 +34,18 @@ def for_user(self, user: User) -> models.QuerySet: if OrganizationMemberService.is_user_organization_admin(user): return self.all() + from tenant_account_v2.sharing_helpers import resources_visible_via_groups + + user_group_ids = user.group_memberships.values_list("group_id", flat=True) + group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + return ( self.get_queryset() .filter( models.Q(created_by=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) + | models.Q(pk__in=group_shared_ids) ) .distinct("id") ) @@ -105,6 +111,15 @@ class ConnectorMode(models.TextChoices): User, related_name="shared_connectors", blank=True ) + # ``shared_groups`` is stored polymorphically in + # ``tenant_account_v2.ResourceGroupShare``; the property preserves the + # ergonomic read surface for DRF / existing callers. + @property + def shared_groups(self): + from tenant_account_v2.sharing_helpers import get_resource_share_groups + + return get_resource_share_groups(self) + objects = ConnectorInstanceModelManager() @staticmethod diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index 4654bcaf35..8782199e74 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -8,6 +8,7 @@ from connector_processor.connector_processor import ConnectorProcessor from connector_processor.constants import ConnectorKeys from connector_processor.exceptions import InvalidConnectorID, OAuthTimeOut +from rest_framework import serializers from rest_framework.serializers import CharField, SerializerMethodField, ValidationError from utils.fields import EncryptedBinaryFieldSerializer from utils.input_sanitizer import validate_name_field @@ -25,16 +26,22 @@ class ConnectorInstanceSerializer(AuditSerializer): connector_metadata = EncryptedBinaryFieldSerializer(required=False, allow_null=True) icon = SerializerMethodField() created_by_email = CharField(source="created_by.email", read_only=True) + # ``shared_groups`` is no longer an M2M on ConnectorInstance — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. Share + # mutations go through ``POST /connector/{id}/share/`` (UN-2977 plan §B). + shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: model = ConnectorInstance fields = "__all__" - # connector_mode is overridden in to_representation from the catalog, - # so any client-supplied value is silently discarded — mark it read_only - # to make that explicit (and to keep DRF OPTIONS schema honest). extra_kwargs = { "connector_name": {"required": False}, + # connector_mode is overridden in to_representation from the catalog, + # so any client-supplied value is silently discarded — mark it read_only + # to make that explicit (and to keep DRF OPTIONS schema honest). "connector_mode": {"read_only": True}, + "shared_users": {"read_only": True}, + "shared_to_org": {"read_only": True}, } def validate_connector_name(self, value: str) -> str: diff --git a/backend/connector_v2/urls.py b/backend/connector_v2/urls.py index 4240335289..18f31d1a41 100644 --- a/backend/connector_v2/urls.py +++ b/backend/connector_v2/urls.py @@ -12,10 +12,18 @@ "delete": "destroy", } ) +connector_share = CIViewSet.as_view({"post": "share"}) +connector_effective_members = CIViewSet.as_view({"get": "effective_members"}) urlpatterns = format_suffix_patterns( [ path("connector/", connector_list, name="connector-list"), path("connector//", connector_detail, name="connector-detail"), + path("connector//share/", connector_share, name="connector-share"), + path( + "connector//effective-members/", + connector_effective_members, + name="connector-effective-members", + ), ] ) diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index 874ca5939c..f6803b047d 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -9,6 +9,7 @@ from django.db import IntegrityError from django.db.models import ProtectedError, QuerySet from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg +from permissions.resource_share_views import ResourceShareManagementMixin from plugins import get_plugin from rest_framework import status, viewsets from rest_framework.request import Request @@ -33,7 +34,7 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceViewSet(viewsets.ModelViewSet): +class ConnectorInstanceViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): versioning_class = URLPathVersioning serializer_class = ConnectorInstanceSerializer @@ -205,40 +206,41 @@ def perform_destroy(self, instance: ConnectorInstance) -> None: def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Override to handle sharing notifications.""" instance = self.get_object() - current_shared_users = set(instance.shared_users.all()) + before = self.snapshot_share_axes(instance) response = super().partial_update(request, *args, **kwargs) - - if ( - response.status_code == 200 - and "shared_users" in request.data - and bool(notification_plugin) - ): - try: - instance.refresh_from_db() - new_shared_users = set(instance.shared_users.all()) - newly_shared_users = new_shared_users - current_shared_users - - if newly_shared_users: - # Only send notifications if there are newly shared users - SharingNotificationService().send_sharing_notification( - resource_type=ResourceType.CONNECTOR.value, - resource_name=instance.connector_name, - resource_id=str(instance.id), - shared_by=request.user, - shared_to=list(newly_shared_users), - resource_instance=instance, - ) - - logger.info( - f"Sent sharing notifications for connector " - f"to {len(newly_shared_users)} users" - ) - - except Exception as e: - # Log error but don't fail the update operation - logger.exception( - f"Failed to send sharing notification, continuing update though: {str(e)}" - ) - + 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/permission.py b/backend/permissions/permission.py index dd9dae8fd5..0289408a0f 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -24,6 +24,26 @@ def _is_service_account(request: Request) -> bool: return getattr(request.user, "is_service_account", False) +def has_group_access(user: Any, obj: Any) -> bool: + """Check if a user has access to a resource via group membership. + + Reads from the polymorphic ``ResourceGroupShare`` table rather than a + per-resource ``shared_groups`` M2M (see UN-2977). Callers can OR this + in safely for any resource — non-shareable objects yield no rows. + """ + # Lazy import — ``permissions`` is imported by `account_v2`/`api_v2` + # before `tenant_account_v2` finishes loading. + from django.contrib.contenttypes.models import ContentType + from tenant_account_v2.models import ResourceGroupShare + + user_group_ids = user.group_memberships.values_list("group_id", flat=True) + return ResourceGroupShare.objects.filter( + content_type=ContentType.objects.get_for_model(type(obj)), + object_id=str(obj.pk), + group_id__in=user_group_ids, + ).exists() + + def _is_organization_admin(request: Request) -> bool: """Return True if the requesting user has the org-admin role. @@ -71,13 +91,12 @@ class IsOwnerOrSharedUser(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if _is_service_account(request): return True - if obj.created_by == request.user: - return True - if obj.shared_users.filter(pk=request.user.pk).exists(): - return True - if _is_organization_admin(request): - return True - return False + return ( + obj.created_by == request.user + or obj.shared_users.filter(pk=request.user.pk).exists() + or has_group_access(request.user, obj) + or _is_organization_admin(request) + ) class IsOwnerOrSharedUserOrSharedToOrg(permissions.BasePermission): @@ -86,15 +105,13 @@ class IsOwnerOrSharedUserOrSharedToOrg(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if _is_service_account(request): return True - if obj.created_by == request.user: - return True - if obj.shared_users.filter(pk=request.user.pk).exists(): - return True - if obj.shared_to_org: - return True - if _is_organization_admin(request): - return True - return False + return ( + obj.created_by == request.user + or obj.shared_users.filter(pk=request.user.pk).exists() + or obj.shared_to_org + or has_group_access(request.user, obj) + or _is_organization_admin(request) + ) class IsFrictionLessAdapter(permissions.BasePermission): diff --git a/backend/permissions/resource_share_views.py b/backend/permissions/resource_share_views.py new file mode 100644 index 0000000000..b4ed73bf52 --- /dev/null +++ b/backend/permissions/resource_share_views.py @@ -0,0 +1,163 @@ +"""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. +""" + +from dataclasses import dataclass, field +from typing import Any, ClassVar + +from django.db.models import Model +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 + +_SUPPORTED_SHARE_AXES = ("shared_users", "shared_groups", "shared_to_org") + + +def _extract_desired_share_state(payload: Any) -> dict[str, Any]: + """Normalize a POST /share/ body into the dispatcher's keyword shape. + + Accepts only the three known axes; unknown keys are rejected so client + bugs surface loudly. Empty payload is allowed (no-op) for symmetry with + "clear my share state" requests. + """ + if not isinstance(payload, dict): + raise ValidationError({"detail": "Request body must be a JSON object."}) + unknown = set(payload) - set(_SUPPORTED_SHARE_AXES) + if unknown: + raise ValidationError({"detail": f"Unsupported share axes: {sorted(unknown)}."}) + desired: dict[str, Any] = {} + for axis in ("shared_users", "shared_groups"): + if axis in payload: + desired[axis] = _coerce_id_list(axis, payload[axis]) + if "shared_to_org" in payload: + desired["shared_to_org"] = bool(payload["shared_to_org"]) + return desired + + +def _coerce_id_list(axis: str, value: Any) -> list[int]: + if value is None: + return [] + if not isinstance(value, (list, tuple)): + raise ValidationError({axis: "Must be a list of integer IDs."}) + coerced: list[int] = [] + for raw in value: + try: + coerced.append(int(raw)) + except (TypeError, ValueError) as exc: + raise ValidationError({axis: f"Invalid ID: {raw!r}"}) from exc + return coerced + + +@dataclass +class AxisDiff: + """Pre/post snapshot for a single share axis (M2M field).""" + + before: set[Any] = field(default_factory=set) + after: set[Any] = field(default_factory=set) + + @property + def added(self) -> set[Any]: + return self.after - self.before + + @property + def removed(self) -> set[Any]: + return self.before - self.after + + +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``. + """ + + share_axes: ClassVar[tuple[str, ...]] = ("shared_users", "shared_groups") + + @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. + + HTTP entry gate is the host viewset's ``get_permissions`` (currently + ``IsOwnerOrSharedUserOrSharedToOrg`` on all 7 resources — see + UN-2977 plan §B). Per-axis authorization (owner / org admin / + shared user / group member) and scope checks (org-membership for + users, group-membership for groups) live in + ``ShareAuthorizationService``. + """ + from tenant_account_v2.sharing_helpers import ShareAuthorizationService + + resource = self.get_object() # type: ignore[attr-defined] + desired = _extract_desired_share_state(request.data) + ShareAuthorizationService.authorize_and_commit( + actor=request.user, resource=resource, desired=desired + ) + return Response(status=status.HTTP_200_OK) + + @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.""" + # Lazy import — ``tenant_account_v2`` is the canonical home of the + # helper; importing at module load would pull a circular dep through + # the permissions package. + from tenant_account_v2.group_serializers import EffectiveMemberSerializer + from tenant_account_v2.sharing_helpers import compute_effective_members + + # ``get_object`` is provided by the DRF ``GenericAPIView`` host class. + 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. + + ``shared_groups`` is stored polymorphically in + ``ResourceGroupShare`` rather than as an M2M on the resource model + — route reads through the helper. Other axes still live as M2M + fields on the resource and use ``getattr`` access. + """ + if axis == "shared_groups": + # Lazy import — ``tenant_account_v2`` depends on the permissions + # package being importable during Django app loading. + from tenant_account_v2.sharing_helpers import ( + get_resource_share_groups, + ) + + return set(get_resource_share_groups(instance)) + return set(getattr(instance, axis).all()) diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index d7266496c2..7aea106521 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -25,6 +25,7 @@ def for_user(self, user): - Pipelines created by the user - Pipelines shared with the user - Pipelines shared with the entire organization + - Pipelines shared with any group the user is a member of - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): @@ -33,10 +34,17 @@ def for_user(self, user): if OrganizationMemberService.is_user_organization_admin(user): return self.all() + # Lazy import — avoids a circular at app load. + from tenant_account_v2.sharing_helpers import resources_visible_via_groups + + user_group_ids = user.group_memberships.values_list("group_id", flat=True) + group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + return self.filter( Q(created_by=user) # Owned by user | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization + | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() @@ -121,6 +129,17 @@ class PipelineStatus(models.TextChoices): default=False, db_comment="Whether this pipeline is shared with the entire organization", ) + # ``shared_groups`` is no longer an M2M; group shares are stored + # polymorphically in ``tenant_account_v2.ResourceGroupShare``. The + # property below preserves the ergonomic ``instance.shared_groups`` + # read surface (queryset of ``OrganizationGroup``) so DRF and existing + # callers don't have to change. + + @property + def shared_groups(self): + from tenant_account_v2.sharing_helpers import get_resource_share_groups + + return get_resource_share_groups(self) # Manager objects = PipelineModelManager() diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index f887042b9a..da1c2981cf 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -30,10 +30,18 @@ class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): created_by_email = SerializerMethodField() last_5_run_statuses = SerializerMethodField() next_run_time = SerializerMethodField() + # ``shared_groups`` is no longer an M2M on Pipeline — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. Share + # mutations go through ``POST /pipeline/{id}/share/`` (UN-2977 plan §B). + shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: model = Pipeline fields = "__all__" + extra_kwargs = { + "shared_users": {"read_only": True}, + "shared_to_org": {"read_only": True}, + } unique_error_message_map: dict[str, dict[str, str]] = { "unique_pipeline_name": { diff --git a/backend/pipeline_v2/serializers/sharing.py b/backend/pipeline_v2/serializers/sharing.py index 0340379b50..43c172504b 100644 --- a/backend/pipeline_v2/serializers/sharing.py +++ b/backend/pipeline_v2/serializers/sharing.py @@ -4,12 +4,14 @@ from pipeline_v2.models import Pipeline from rest_framework import serializers from rest_framework.serializers import SerializerMethodField +from tenant_account_v2.sharing_helpers import serialize_group_refs class SharedUserListSerializer(serializers.ModelSerializer): - """Serializer for returning pipeline with shared user details.""" + """Serializer for returning pipeline with shared user + group details.""" shared_users = SerializerMethodField() + shared_groups = SerializerMethodField() created_by = SerializerMethodField() created_by_email = SerializerMethodField() @@ -20,6 +22,7 @@ class Meta: "pipeline_name", "shared_users", "shared_to_org", + "shared_groups", "created_by", "created_by_email", ] @@ -30,6 +33,9 @@ def get_shared_users(self, obj): obj.shared_users.filter(is_service_account=False), many=True ).data + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + def get_created_by(self, obj): """Get the creator's username.""" return obj.created_by.username if obj.created_by else None diff --git a/backend/pipeline_v2/urls.py b/backend/pipeline_v2/urls.py index 6dde72433b..513edd5389 100644 --- a/backend/pipeline_v2/urls.py +++ b/backend/pipeline_v2/urls.py @@ -38,6 +38,8 @@ ) pipeline_execute = PipelineViewSet.as_view({"post": "execute"}) +pipeline_share = PipelineViewSet.as_view({"post": "share"}) +pipeline_effective_members = PipelineViewSet.as_view({"get": "effective_members"}) urlpatterns = format_suffix_patterns( @@ -55,6 +57,16 @@ list_shared_users, name="pipeline-shared-users", ), + path( + "pipeline//share/", + pipeline_share, + name="pipeline-share", + ), + path( + "pipeline//effective-members/", + pipeline_effective_members, + name="pipeline-effective-members", + ), path( "pipeline/api/postman_collection//", download_postman_collection, diff --git a/backend/pipeline_v2/views.py b/backend/pipeline_v2/views.py index ef25f74d9e..9a0d789e75 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -10,6 +10,7 @@ from django.db.models import F, QuerySet from django.http import HttpResponse from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg +from permissions.resource_share_views import ResourceShareManagementMixin from plugins import get_plugin from rest_framework import serializers, status, viewsets from rest_framework.decorators import action @@ -42,7 +43,7 @@ logger = logging.getLogger(__name__) -class PipelineViewSet(viewsets.ModelViewSet): +class PipelineViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): versioning_class = URLPathVersioning queryset = Pipeline.objects.all() pagination_class = CustomPagination @@ -148,50 +149,56 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Override to handle sharing notifications.""" instance = self.get_object() - current_shared_users = set(instance.shared_users.all()) + 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 - if ( - response.status_code == 200 - and "shared_users" in request.data - and notification_plugin + 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, ): - try: - instance.refresh_from_db() - new_shared_users = set(instance.shared_users.all()) - newly_shared_users = new_shared_users - current_shared_users - - if ResourceType.ETL.value == instance.pipeline_type: - resource_type = ResourceType.ETL.value - elif ResourceType.TASK.value == instance.pipeline_type: - resource_type = ResourceType.TASK.value - - if newly_shared_users: - # Get notification service from plugin and send notification - service_class = notification_plugin["service_class"] - notification_service = service_class() - notification_service.send_sharing_notification( - resource_type=resource_type, - resource_name=instance.pipeline_name, - resource_id=str(instance.id), - shared_by=request.user, - shared_to=list(newly_shared_users), - resource_instance=instance, - ) - - logger.info( - f"Sent sharing notifications for {instance.pipeline_type} " - f"to {len(newly_shared_users)} users" - ) - - except Exception as e: - # Log error but don't fail the update operation - logger.exception( - f"Failed to send sharing notification, continuing update though: {str(e)}" - ) - - return response + 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( diff --git a/backend/prompt_studio/permission.py b/backend/prompt_studio/permission.py index 8d29970e07..d3e967e60a 100644 --- a/backend/prompt_studio/permission.py +++ b/backend/prompt_studio/permission.py @@ -1,5 +1,6 @@ from typing import Any +from permissions.permission import has_group_access from rest_framework import permissions from rest_framework.request import Request from rest_framework.views import APIView @@ -7,13 +8,22 @@ class PromptAcesssToUser(permissions.BasePermission): - """Is the crud to Prompt/Notes allowed to user.""" + """Is the crud to Prompt/Notes allowed to user. + + A user qualifies when they own the parent ``CustomTool``, are a direct + ``shared_users`` member, reach the project via group sharing + (``ResourceGroupShare`` on the parent tool), or are an org admin + (org-wide admin override, UN-3479). + """ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if getattr(request.user, "is_service_account", False): return True - if obj.tool_id.created_by == request.user: + tool = obj.tool_id + if tool.created_by == request.user: + return True + if tool.shared_users.filter(pk=request.user.pk).exists(): return True - if obj.tool_id.shared_users.filter(pk=request.user.pk).exists(): + if has_group_access(request.user, tool): return True return OrganizationMemberService.is_user_organization_admin(request.user) diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index 119b2d139f..7b11320098 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -30,12 +30,18 @@ def for_user(self, user: User) -> QuerySet[Any]: if OrganizationMemberService.is_user_organization_admin(user): return self.all() + from tenant_account_v2.sharing_helpers import resources_visible_via_groups + + user_group_ids = user.group_memberships.values_list("group_id", flat=True) + group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + return ( self.get_queryset() .filter( models.Q(created_by=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) + | models.Q(pk__in=group_shared_ids) ) .distinct("tool_id") ) @@ -164,6 +170,15 @@ class CustomTool(DefaultOrganizationMixin, BaseModel): default=False, db_comment="Flag to share this custom tool with all users in the organization", ) + # ``shared_groups`` is stored polymorphically in + # ``tenant_account_v2.ResourceGroupShare``; the property below preserves + # the ergonomic read surface for DRF / existing callers. + + @property + def shared_groups(self): + from tenant_account_v2.sharing_helpers import get_resource_share_groups + + return get_resource_share_groups(self) # NULL on pre-feature tools; populated on first successful export. # Drives staleness checks (e.g. lookup-change banner) without requiring diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index 4f10ee2aa1..f465d31488 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -1,12 +1,12 @@ import logging from typing import Any -from account_v2.models import User from account_v2.serializer import UserSerializer from adapter_processor_v2.models import AdapterInstance from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from rest_framework.exceptions import ValidationError +from tenant_account_v2.sharing_helpers import serialize_group_refs from utils.FileValidator import FileValidator from utils.input_sanitizer import validate_name_field, validate_no_html_tags from utils.serializer.integrity_error_mixin import IntegrityErrorMixin @@ -76,16 +76,17 @@ def get_prompt_count(self, instance): class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): - shared_users = serializers.PrimaryKeyRelatedField( - queryset=User.objects.filter(is_service_account=False), - required=False, - allow_null=True, - many=True, - ) + # Share mutations go through ``POST /prompt-studio/{id}/share/``; + # both axes are read-only on this serializer (UN-2977 plan §B). + shared_users = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: model = CustomTool fields = "__all__" + extra_kwargs = { + "shared_to_org": {"read_only": True}, + } unique_error_message_map: dict[str, dict[str, str]] = { "unique_tool_name": { @@ -224,10 +225,11 @@ class PromptStudioResponseSerializer(serializers.Serializer): class SharedUserListSerializer(serializers.ModelSerializer): - """Used for listing users of Custom tool.""" + """Used for listing users + groups of Custom tool.""" created_by = UserSerializer() shared_users = serializers.SerializerMethodField() + shared_groups = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -237,6 +239,7 @@ class Meta: "created_by", "shared_users", "shared_to_org", + "shared_groups", ) def get_shared_users(self, obj): @@ -244,6 +247,9 @@ def get_shared_users(self, obj): obj.shared_users.filter(is_service_account=False), many=True ).data + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + class FileInfoIdeSerializer(serializers.Serializer): document_id = serializers.CharField() diff --git a/backend/prompt_studio/prompt_studio_core_v2/urls.py b/backend/prompt_studio/prompt_studio_core_v2/urls.py index 9163e8736e..4a453fa309 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/urls.py +++ b/backend/prompt_studio/prompt_studio_core_v2/urls.py @@ -40,6 +40,10 @@ {"post": "single_pass_extraction"} ) prompt_studio_users = PromptStudioCoreView.as_view({"get": "list_of_shared_users"}) +prompt_studio_share = PromptStudioCoreView.as_view({"post": "share"}) +prompt_studio_effective_members = PromptStudioCoreView.as_view( + {"get": "effective_members"} +) prompt_studio_file = PromptStudioCoreView.as_view( @@ -134,6 +138,16 @@ prompt_studio_users, name="prompt-studio-users", ), + path( + "prompt-studio//share/", + prompt_studio_share, + name="prompt-studio-share", + ), + path( + "prompt-studio//effective-members/", + prompt_studio_effective_members, + name="prompt-studio-effective-members", + ), path( "prompt-studio/file/", prompt_studio_file, diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 30ea7045e2..001d63a838 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -18,6 +18,7 @@ from file_management.constants import FileInformationKey as FileKey from file_management.exceptions import FileNotFound from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg +from permissions.resource_share_views import ResourceShareManagementMixin from pipeline_v2.models import Pipeline from plugins import get_plugin from rest_framework import status, viewsets @@ -119,7 +120,7 @@ def _multi_var_lookup_block_response(custom_tool, prompt_ids=None): ) -class PromptStudioCoreView(viewsets.ModelViewSet): +class PromptStudioCoreView(ResourceShareManagementMixin, viewsets.ModelViewSet): """Viewset to handle all Custom tool related operations.""" versioning_class = URLPathVersioning @@ -295,48 +296,50 @@ def destroy( def partial_update( self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any] ) -> Response: - # Store current shared users before update for email notifications custom_tool = self.get_object() - current_shared_users = set(custom_tool.shared_users.all()) + before = self.snapshot_share_axes(custom_tool) - # Perform the update 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 - # Send email notifications to newly shared users - if response.status_code == 200 and "shared_users" in request.data: - from plugins import get_plugin - - notification_plugin = get_plugin("notification") - if notification_plugin: - from plugins.notification.constants import ResourceType - - # Refresh the object to get updated shared_users - custom_tool.refresh_from_db() - updated_shared_users = set(custom_tool.shared_users.all()) - - # Find newly added users (not previously shared) - newly_shared_users = updated_shared_users - current_shared_users - - if newly_shared_users: - service_class = notification_plugin["service_class"] - notification_service = service_class() - try: - 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=request.user, - shared_to=list(newly_shared_users), - resource_instance=custom_tool, - ) - except Exception as e: - # Log error but don't fail the request - logger.exception( - f"Failed to send sharing notification for " - f"custom tool {custom_tool.tool_id}: {str(e)}" - ) + from plugins.notification.constants import ResourceType - return response + 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: diff --git a/backend/sample.env b/backend/sample.env index e1a54b955a..377016fdec 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -231,6 +231,12 @@ FILE_EXECUTION_TRACKER_COMPLETED_TTL_IN_SECOND=600 # Default: 3 (file is permanently skipped after 3 execution attempts) MAX_FILE_EXECUTION_COUNT=3 +# Org-scoped group sharing (UN-2977 / mfbt UNS-612) +# Max OrganizationGroup rows allowed per Organization +MAX_GROUPS_PER_ORG=200 +# Max GroupMembership rows allowed per OrganizationGroup +MAX_MEMBERS_PER_GROUP=500 + # Runner polling timeout (3 hours) MAX_RUNNER_POLLING_WAIT_SECONDS=10800 # Runner polling interval (2 seconds) diff --git a/backend/tenant_account_v2/admin.py b/backend/tenant_account_v2/admin.py index 846f6b4061..76ad669cac 100644 --- a/backend/tenant_account_v2/admin.py +++ b/backend/tenant_account_v2/admin.py @@ -1 +1,11 @@ -# Register your models here. +from django.contrib import admin + +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, + OrganizationMember, +) + +admin.site.register(OrganizationMember) +admin.site.register(OrganizationGroup) +admin.site.register(GroupMembership) diff --git a/backend/tenant_account_v2/apps.py b/backend/tenant_account_v2/apps.py index cd128f028b..e2e3ed1150 100644 --- a/backend/tenant_account_v2/apps.py +++ b/backend/tenant_account_v2/apps.py @@ -4,3 +4,6 @@ class TenantAccountV2Config(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "tenant_account_v2" + + def ready(self): + from tenant_account_v2 import signals # noqa: F401 diff --git a/backend/tenant_account_v2/group_serializers.py b/backend/tenant_account_v2/group_serializers.py new file mode 100644 index 0000000000..520c38bae6 --- /dev/null +++ b/backend/tenant_account_v2/group_serializers.py @@ -0,0 +1,190 @@ +"""Serializers for org-scoped group sharing (UN-2977 / mfbt UNS-612).""" + +import logging +from typing import Any + +from django.conf import settings +from django.db.models import Count, IntegerField, OuterRef, Subquery +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, + OrganizationMember, +) + +logger = logging.getLogger(__name__) + + +class OrganizationGroupReadSerializer(serializers.ModelSerializer): + """Read-side serializer for org-scoped groups.""" + + member_count = serializers.SerializerMethodField() + + class Meta: + model = OrganizationGroup + fields = ( + "id", + "name", + "description", + "created_by", + "member_count", + "created_at", + "modified_at", + ) + read_only_fields = fields + + def get_member_count(self, obj: OrganizationGroup) -> int: + # ``memberships__count`` is annotated by the viewset's queryset when + # available; fall back to a count() so single-object serialization works. + annotated = getattr(obj, "memberships__count", None) + if annotated is not None: + return int(annotated) + return int(obj.memberships.count()) + + +class OrganizationGroupWriteSerializer(serializers.ModelSerializer): + """Write-side serializer for org-scoped groups.""" + + class Meta: + model = OrganizationGroup + fields = ("name", "description") + + def _organization(self) -> Any: + return self.context["organization"] + + def validate_name(self, value: str) -> str: + value = (value or "").strip() + if not value: + raise ValidationError("Group name must not be empty.") + return value + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + organization = self._organization() + + # Quota check applies on create only (rename within an existing row + # doesn't change the group count). + if self.instance is None: + current = OrganizationGroup.objects.filter(organization=organization).count() + if current >= settings.MAX_GROUPS_PER_ORG: + raise ValidationError( + { + "code": "MAX_GROUPS_PER_ORG_EXCEEDED", + "detail": ( + f"Organization already has {current} groups " + f"(limit: {settings.MAX_GROUPS_PER_ORG})." + ), + } + ) + return attrs + + +class GroupMemberSerializer(serializers.ModelSerializer): + """List-side representation of a single group member.""" + + user_id = serializers.IntegerField(source="user.id", read_only=True) + email = serializers.CharField(source="user.email", read_only=True) + display_name = serializers.SerializerMethodField() + joined_at = serializers.DateTimeField(source="created_at", read_only=True) + + class Meta: + model = GroupMembership + fields = ("user_id", "email", "display_name", "joined_at") + + def get_display_name(self, obj: GroupMembership) -> str: + user = obj.user + full_name = (getattr(user, "get_full_name", lambda: "")() or "").strip() + return full_name or user.email + + +class GroupMemberAddSerializer(serializers.Serializer): + """Validates a bulk-add payload of user ids against org membership + quota.""" + + user_ids = serializers.ListField(child=serializers.IntegerField(), allow_empty=False) + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + group: OrganizationGroup = self.context["group"] + user_ids = list(dict.fromkeys(attrs["user_ids"])) # dedupe, preserve order + + # All targets must be members of the same org. + org_user_ids = set( + OrganizationMember.objects.filter( + organization=group.organization, user_id__in=user_ids + ).values_list("user_id", flat=True) + ) + missing = [uid for uid in user_ids if uid not in org_user_ids] + if missing: + raise ValidationError( + { + "code": "USERS_NOT_IN_ORG", + "detail": "All users must be members of this organization.", + "missing_user_ids": missing, + } + ) + + # Quota: count after this add (excluding duplicates already in the group). + already_in_group = set( + group.memberships.filter(user_id__in=user_ids).values_list( + "user_id", flat=True + ) + ) + to_add = [uid for uid in user_ids if uid not in already_in_group] + projected = group.memberships.count() + len(to_add) + if projected > settings.MAX_MEMBERS_PER_GROUP: + raise ValidationError( + { + "code": "MAX_MEMBERS_PER_GROUP_EXCEEDED", + "detail": ( + f"Adding these users would bring the group to {projected} " + f"members (limit: {settings.MAX_MEMBERS_PER_GROUP})." + ), + } + ) + attrs["user_ids_to_add"] = to_add + return attrs + + +class EffectiveMemberSerializer(serializers.Serializer): + """Serializer for the ``effective-members/`` resource action. + + Output of the union-with-priority dedup (direct > group > org) on each + shareable resource viewset. + """ + + ACCESS_DIRECT = "direct" + ACCESS_GROUP = "group" + ACCESS_ORG = "org" + + user_id = serializers.IntegerField() + email = serializers.CharField() + display_name = serializers.CharField() + access_via = serializers.ChoiceField( + choices=[ACCESS_DIRECT, ACCESS_GROUP, ACCESS_ORG] + ) + group_id = serializers.IntegerField(required=False, allow_null=True) + group_name = serializers.CharField(required=False, allow_null=True) + + +def list_groups_with_member_counts(organization: Any, user: Any | None = None) -> Any: + """Helper: return OrganizationGroup queryset annotated with member_count. + + When ``user`` is provided, the result is restricted to groups the user + belongs to — used by the ``?member=me`` filter for non-admin callers. + """ + # Count via a decoupled subquery: an optional ``memberships__user`` filter + # below constrains the same relation, so a join-based Count would collapse + # to the filtered rows (member_count=1). The subquery counts independently. + member_count_sq = ( + GroupMembership.objects.filter(group=OuterRef("pk")) + .order_by() + .values("group") + .annotate(c=Count("pk")) + .values("c") + ) + qs = OrganizationGroup.objects.filter(organization=organization) + if user is not None: + qs = qs.filter(memberships__user=user) + return qs.annotate( + memberships__count=Subquery(member_count_sq, output_field=IntegerField()) + ).distinct() diff --git a/backend/tenant_account_v2/group_views.py b/backend/tenant_account_v2/group_views.py new file mode 100644 index 0000000000..d80df78703 --- /dev/null +++ b/backend/tenant_account_v2/group_views.py @@ -0,0 +1,227 @@ +"""ViewSet + permissions for org-scoped group sharing (UN-2977 / mfbt UNS-612).""" + +import logging +from typing import Any + +from account_v2.models import Organization +from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.permissions import BasePermission, IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import BaseSerializer +from utils.user_context import UserContext + +from tenant_account_v2.group_serializers import ( + GroupMemberAddSerializer, + GroupMemberSerializer, + OrganizationGroupReadSerializer, + OrganizationGroupWriteSerializer, + list_groups_with_member_counts, +) +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, +) + +logger = logging.getLogger(__name__) + + +def _current_organization() -> Organization: + organization = UserContext.get_organization() + if organization is None: + raise PermissionDenied("Organization context is required.") + return organization + + +def _is_org_admin(request: Request) -> bool: + """Resolve admin role for the current request user. + + Returns False on any lookup failure rather than raising — callers gate + individual writes; viewing is allowed for all org members. + """ + from tenant_account_v2.sharing_helpers import is_org_admin + + return is_org_admin(request.user) + + +class IsOrgAdminForWrite(BasePermission): + """Read for any authenticated org member; write for org admins only.""" + + message = "Only organization admins can manage groups." + + def has_permission(self, request: Request, view: Any) -> bool: + if not request.user or not request.user.is_authenticated: + return False + if request.method in ("GET", "HEAD", "OPTIONS"): + return True + return _is_org_admin(request) + + +class OrganizationGroupViewSet(viewsets.ModelViewSet): + """CRUD + member management for org-scoped sharing groups.""" + + permission_classes = [IsAuthenticated, IsOrgAdminForWrite] + lookup_field = "pk" + + def get_serializer_class(self) -> type[BaseSerializer]: + if self.action in ("list", "retrieve", "members"): + return OrganizationGroupReadSerializer + return OrganizationGroupWriteSerializer + + def get_serializer_context(self) -> dict[str, Any]: + ctx = super().get_serializer_context() + ctx["organization"] = _current_organization() + return ctx + + def get_queryset(self) -> QuerySet[OrganizationGroup]: + organization = _current_organization() + return list_groups_with_member_counts(organization=organization) + + # --- list / retrieve / create / destroy ---------------------------------- + + def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: + organization = _current_organization() + member_filter = request.query_params.get("member") + is_admin = _is_org_admin(request) + + if member_filter == "me": + qs = list_groups_with_member_counts( + organization=organization, user=request.user + ) + elif member_filter and member_filter != "me": + if not is_admin: + raise PermissionDenied( + "Only admins can query other users' group memberships." + ) + try: + member_id = int(member_filter) + except (TypeError, ValueError) as exc: + raise ValidationError({"member": "Must be a numeric user ID."}) from exc + qs = list_groups_with_member_counts(organization=organization).filter( + memberships__user_id=member_id + ) + else: + qs = self.get_queryset() + + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data) + + def perform_create(self, serializer: BaseSerializer) -> None: + organization = _current_organization() + serializer.save( + organization=organization, + created_by=self.request.user, + ) + + # --- members ------------------------------------------------------------- + + @action(detail=True, methods=["get", "post"], url_path="members") + def members(self, request: Request, pk: str | None = None) -> Response: + group = self._get_group_or_404(pk) + if request.method == "GET": + qs = group.memberships.select_related("user").order_by("created_at") + data = GroupMemberSerializer(qs, many=True).data + return Response(data) + + # POST → bulk add + if not _is_org_admin(request): + raise PermissionDenied(IsOrgAdminForWrite.message) + 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"] + GroupMembership.objects.bulk_create( + [GroupMembership(group=group, user_id=uid) for uid in user_ids_to_add], + ignore_conflicts=True, + ) + return Response( + {"added_user_ids": user_ids_to_add}, + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"members/(?P[^/.]+)", + ) + def remove_member( + self, request: Request, pk: str | None = None, user_id: str | None = None + ) -> Response: + if not _is_org_admin(request): + raise PermissionDenied(IsOrgAdminForWrite.message) + try: + user_id_int = int(user_id) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + raise ValidationError({"user_id": "Must be a numeric user ID."}) from exc + group = self._get_group_or_404(pk) + deleted, _ = group.memberships.filter(user_id=user_id_int).delete() + if not deleted: + raise NotFound("User is not a member of this group.") + return Response(status=status.HTTP_204_NO_CONTENT) + + # --- resources shared with this group ------------------------------------ + + @action(detail=True, methods=["get"], url_path="resources") + def resources(self, request: Request, pk: str | None = None) -> Response: + # Admin-only: this is the delete blast-radius view. Leaving it open to + # any org member would leak names/UUIDs of resources shared with groups + # they are not in (org admin has no implicit resource access). + if not _is_org_admin(request): + raise PermissionDenied(IsOrgAdminForWrite.message) + group = self._get_group_or_404(pk) + payload = _collect_resources_shared_with_group(group) + return Response(payload) + + # --- helpers ------------------------------------------------------------- + + def _get_group_or_404(self, pk: str | None) -> OrganizationGroup: + organization = _current_organization() + obj: OrganizationGroup = get_object_or_404( + OrganizationGroup, pk=pk, organization=organization + ) + return obj + + +def _collect_resources_shared_with_group( + group: OrganizationGroup, +) -> list[dict[str, Any]]: + """Aggregate the resources currently shared with ``group`` across types. + + Imports are deferred to avoid pulling resource models into the + ``tenant_account_v2`` import graph at startup. + """ + from adapter_processor_v2.models import AdapterInstance + from api_v2.models import APIDeployment + from connector_v2.models import ConnectorInstance + from pipeline_v2.models import Pipeline + from prompt_studio.prompt_studio_core_v2.models import CustomTool + from workflow_manager.workflow_v2.models.workflow import Workflow + + sources = ( + ("workflow", Workflow, "workflow_name", "id"), + ("pipeline", Pipeline, "pipeline_name", "id"), + ("api_deployment", APIDeployment, "display_name", "id"), + ("adapter_instance", AdapterInstance, "adapter_name", "id"), + ("connector_instance", ConnectorInstance, "connector_name", "id"), + ("custom_tool", CustomTool, "tool_name", "tool_id"), + ) + + from tenant_account_v2.sharing_helpers import list_resources_shared_with_group + + results: list[dict[str, Any]] = [] + for kind, model, name_field, id_field in sources: + qs = list_resources_shared_with_group(group, model).values_list( + id_field, name_field + ) + for resource_id, name in qs: + results.append( + { + "resource_type": kind, + "resource_id": str(resource_id), + "name": name, + } + ) + return results diff --git a/backend/tenant_account_v2/groups_urls.py b/backend/tenant_account_v2/groups_urls.py new file mode 100644 index 0000000000..9c3ad60c60 --- /dev/null +++ b/backend/tenant_account_v2/groups_urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from tenant_account_v2.group_views import OrganizationGroupViewSet + +router = DefaultRouter(trailing_slash=True) +router.register(r"groups", OrganizationGroupViewSet, basename="organization-group") + +urlpatterns = router.urls diff --git a/backend/tenant_account_v2/migrations/0002_organization_group_group_membership.py b/backend/tenant_account_v2/migrations/0002_organization_group_group_membership.py new file mode 100644 index 0000000000..eb28ac8f04 --- /dev/null +++ b/backend/tenant_account_v2/migrations/0002_organization_group_group_membership.py @@ -0,0 +1,112 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account_v2", "0004_user_is_service_account"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("tenant_account_v2", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationGroup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_groups", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="groups", + to="account_v2.organization", + ), + ), + ], + options={ + "verbose_name": "Organization Group", + "verbose_name_plural": "Organization Groups", + "db_table": "organization_group", + }, + ), + migrations.CreateModel( + name="GroupMembership", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="tenant_account_v2.organizationgroup", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="group_memberships", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Group Membership", + "verbose_name_plural": "Group Memberships", + "db_table": "organization_group_membership", + }, + ), + migrations.AddConstraint( + model_name="organizationgroup", + constraint=models.UniqueConstraint( + fields=("organization", "name"), name="unique_organization_group_name" + ), + ), + migrations.AddIndex( + model_name="groupmembership", + index=models.Index( + fields=["user", "group"], name="organizatio_user_id_ecae1a_idx" + ), + ), + migrations.AddConstraint( + model_name="groupmembership", + constraint=models.UniqueConstraint( + fields=("group", "user"), name="unique_group_membership" + ), + ), + ] diff --git a/backend/tenant_account_v2/migrations/0003_resource_group_share.py b/backend/tenant_account_v2/migrations/0003_resource_group_share.py new file mode 100644 index 0000000000..857849c808 --- /dev/null +++ b/backend/tenant_account_v2/migrations/0003_resource_group_share.py @@ -0,0 +1,97 @@ +"""Create the polymorphic ``ResourceGroupShare`` table (UN-2977). + +This is the single new migration for group-based resource sharing. PR +#1986's per-resource ``shared_groups`` M2M was scrapped in favor of one +polymorphic table covering all shareable resources, so there is no +per-resource ``AddField``/``RemoveField`` cycle and no data backfill — +nothing existed to migrate from. + +The migration depends on each in-scope resource app's latest +pre-shared_groups migration so it sits at the *top* of the dependency +graph for those apps (resource migrations are below this one). +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account_v2", "0001_initial"), + ("contenttypes", "0002_remove_content_type_name"), + ("tenant_account_v2", "0002_organization_group_group_membership"), + ("pipeline_v2", "0003_add_sharing_fields_to_pipeline"), + ("workflow_v2", "0019_remove_filehistory_trigram_index"), + ("api_v2", "0003_add_organization_rate_limit"), + ("adapter_processor_v2", "0003_mark_deprecated_adapters"), + ("connector_v2", "0005_fix_unintended_connector_sharing"), + ("prompt_studio_core_v2", "0007_customtool_last_exported_at"), + ] + + operations = [ + migrations.CreateModel( + name="ResourceGroupShare", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.CharField(max_length=255)), + ( + "content_type", + models.ForeignKey( + on_delete=models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "group", + models.ForeignKey( + on_delete=models.deletion.CASCADE, + related_name="resource_shares", + to="tenant_account_v2.organizationgroup", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=models.deletion.CASCADE, + related_name="resource_group_shares", + to="account_v2.organization", + ), + ), + ], + options={ + "verbose_name": "Resource Group Share", + "verbose_name_plural": "Resource Group Shares", + "db_table": "resource_group_share", + }, + ), + migrations.AddConstraint( + model_name="resourcegroupshare", + constraint=models.UniqueConstraint( + fields=("group", "content_type", "object_id"), + name="uniq_resource_group_share", + ), + ), + migrations.AddIndex( + model_name="resourcegroupshare", + index=models.Index( + fields=["content_type", "object_id"], + name="resource_gr_content_8c9a73_idx", + ), + ), + migrations.AddIndex( + model_name="resourcegroupshare", + index=models.Index( + fields=["organization", "group"], + name="resource_gr_organiz_d77c32_idx", + ), + ), + ] diff --git a/backend/tenant_account_v2/models.py b/backend/tenant_account_v2/models.py index e5fdacda66..1d90c91ba1 100644 --- a/backend/tenant_account_v2/models.py +++ b/backend/tenant_account_v2/models.py @@ -1,5 +1,7 @@ -from account_v2.models import User +from account_v2.models import Organization, User +from django.contrib.contenttypes.models import ContentType from django.db import models +from utils.models.base_model import BaseModel from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, DefaultOrganizationMixin, @@ -46,3 +48,114 @@ class Meta: name="unique_organization_member", ), ] + + +class OrganizationGroup(BaseModel): + """Org-scoped collection of users used as a sharing target. + + Org filtering is explicit on every query (no DefaultOrganizationMixin) + because group CRUD is admin-driven from a request context where + UserContext is reliably populated. + """ + + organization = models.ForeignKey( + Organization, on_delete=models.CASCADE, related_name="groups" + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_groups", + ) + + def __str__(self): # type: ignore + return f"OrganizationGroup({self.id}, {self.name})" + + class Meta: + db_table = "organization_group" + verbose_name = "Organization Group" + verbose_name_plural = "Organization Groups" + constraints = [ + models.UniqueConstraint( + fields=["organization", "name"], + name="unique_organization_group_name", + ), + ] + + +class ResourceGroupShare(BaseModel): + """Polymorphic group→resource share row. + + Replaces the per-resource ``shared_groups`` M2M join tables with a + single table covering every shareable resource. One row per + ``(group, resource)`` edge. Multi-tenancy is enforced by the explicit + ``organization`` FK plus viewset-level filtering on every read path. + """ + + group = models.ForeignKey( + OrganizationGroup, + on_delete=models.CASCADE, + related_name="resource_shares", + ) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + # ``object_id`` is the resource PK as text; every in-scope resource uses + # UUID primary keys but the column stays varchar to keep the schema open + # for future non-UUID resources. + object_id = models.CharField(max_length=255) + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="resource_group_shares", + ) + + class Meta: + db_table = "resource_group_share" + verbose_name = "Resource Group Share" + verbose_name_plural = "Resource Group Shares" + constraints = [ + models.UniqueConstraint( + fields=["group", "content_type", "object_id"], + name="uniq_resource_group_share", + ), + ] + indexes = [ + models.Index(fields=["content_type", "object_id"]), + models.Index(fields=["organization", "group"]), + ] + + +class GroupMembership(BaseModel): + """Explicit through model for OrganizationGroup membership. + + Explicit (instead of implicit M2M) so future fields like ``joined_at``, + ``role``, or ``invited_by`` can land without a destructive migration. + The ``(user, group)`` index serves the ``for_user()`` subquery on every + shareable resource manager. + """ + + group = models.ForeignKey( + OrganizationGroup, on_delete=models.CASCADE, related_name="memberships" + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="group_memberships" + ) + + def __str__(self): # type: ignore + return f"GroupMembership(group={self.group_id}, user={self.user_id})" + + class Meta: + db_table = "organization_group_membership" + verbose_name = "Group Membership" + verbose_name_plural = "Group Memberships" + constraints = [ + models.UniqueConstraint( + fields=["group", "user"], + name="unique_group_membership", + ), + ] + indexes = [ + models.Index(fields=["user", "group"]), + ] diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py new file mode 100644 index 0000000000..407313de08 --- /dev/null +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -0,0 +1,541 @@ +"""Shared helpers for group-based resource sharing. + +Centralizes the per-resource hooks so each shareable viewset and serializer +plugs into the same logic. Group shares are stored polymorphically in +:class:`tenant_account_v2.models.ResourceGroupShare` — these helpers are the +single layer that translates between the resource ergonomic surface (``obj``) +and the polymorphic table. + +Helpers exposed: + +* ``validate_shared_groups_in_org`` — serializer-level org scope check on + the ``shared_groups`` write payload. +* ``get_resource_share_groups`` / ``set_resource_share_groups`` — read/write + the set of groups currently shared with a resource. +* ``list_resources_shared_with_group`` — reverse lookup for the group-admin + view. +* ``resources_visible_via_groups`` — subquery feeding each resource + manager's ``for_user()`` Q-chain. +* ``compute_effective_members`` — union-with-priority dedup feeding the + ``effective-members/`` resource action. +* ``serialize_group_refs`` — small ``[{id, name}]`` listing for + the share modal's currently-shared listing. +""" + +from __future__ import annotations + +import logging +import uuid +from collections.abc import Iterable +from typing import Any + +from account_v2.models import Organization, User +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.db import models, transaction +from django.db.models import Model, QuerySet +from rest_framework.exceptions import ValidationError + +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, + OrganizationMember, + ResourceGroupShare, +) + +logger = logging.getLogger(__name__) + + +def validate_shared_groups_in_org( + groups: Iterable[OrganizationGroup], organization: Organization +) -> list[OrganizationGroup]: + """Reject any group not belonging to ``organization``. + + DRF's ``PrimaryKeyRelatedField`` resolves IDs to instances against the + full table, so cross-org IDs must be filtered here. + """ + groups = list(groups) + foreign = [g for g in groups if g.organization_id != organization.id] + if foreign: + raise ValidationError( + {"shared_groups": ("All shared groups must belong to your organization.")} + ) + return groups + + +def get_resource_share_groups(resource_obj: Any) -> QuerySet[OrganizationGroup]: + """Return the groups currently shared with ``resource_obj``.""" + return OrganizationGroup.objects.filter( + resource_shares__content_type=ContentType.objects.get_for_model( + type(resource_obj) + ), + resource_shares__object_id=str(resource_obj.pk), + ) + + +@transaction.atomic +def set_resource_share_groups(resource_obj: Any, group_ids: Iterable[int]) -> None: + """Replace the set of groups shared with ``resource_obj``. + + Mirrors Django M2M ``.set()`` semantics for the polymorphic table — + additions, removals, and no-ops are all handled. Caller is responsible + for having already validated the IDs against the resource's + organization via :func:`validate_shared_groups_in_org`. + """ + content_type = ContentType.objects.get_for_model(type(resource_obj)) + object_id = str(resource_obj.pk) + organization_id = getattr(resource_obj, "organization_id", None) + if organization_id is None: + raise ValueError( + "set_resource_share_groups requires an org-scoped resource; " + f"{type(resource_obj).__name__}({resource_obj.pk}) has no " + "organization_id." + ) + + requested = set(group_ids) + current_qs = ResourceGroupShare.objects.filter( + content_type=content_type, object_id=object_id + ) + current_ids = set(current_qs.values_list("group_id", flat=True)) + + to_remove = current_ids - requested + to_add = requested - current_ids + + if to_remove: + current_qs.filter(group_id__in=to_remove).delete() + + if to_add: + ResourceGroupShare.objects.bulk_create( + [ + ResourceGroupShare( + group_id=group_id, + content_type=content_type, + object_id=object_id, + organization_id=organization_id, + ) + for group_id in to_add + ], + ignore_conflicts=True, + ) + + +def _safe_uuids(raw_ids: Iterable[Any]) -> list[uuid.UUID]: + """Cast varchar ``object_id`` values to UUID, skipping malformed ones. + + ``ResourceGroupShare.object_id`` is intentionally varchar, so one corrupt + value must not 500 the entire resource list for every member of the group. + Malformed ids are skipped and logged rather than raised. + """ + result: list[uuid.UUID] = [] + for s in raw_ids: + if not s: + continue + try: + result.append(uuid.UUID(s)) + except (ValueError, AttributeError, TypeError): + logger.warning("Skipping malformed ResourceGroupShare.object_id=%r", s) + return result + + +def list_resources_shared_with_group( + group: OrganizationGroup, model: type[Model] +) -> QuerySet: + """Resources of ``model`` shared with ``group`` (replaces + ``model.objects.filter(shared_groups=group)``). + + Materialises IDs to Python so UUID-keyed PKs can be cast before the + ``pk__in`` lookup — Postgres refuses the implicit ``uuid = character + varying`` comparison when the varchar ``object_id`` subquery is fed in + directly (same constraint as :func:`resources_visible_via_groups`). + """ + raw_ids = ResourceGroupShare.objects.filter( + group=group, + content_type=ContentType.objects.get_for_model(model), + ).values_list("object_id", flat=True) + if isinstance(model._meta.pk, models.UUIDField): + pks: list[Any] = _safe_uuids(raw_ids) + else: + pks = list(raw_ids) + return model.objects.filter(pk__in=pks) + + +def resources_visible_via_groups( + model: type[Model], user_group_ids: Iterable[int] +) -> list[Any]: + """IDs of ``model`` rows shared with any group the user belongs to. + + Returns a Python list (not a subquery) so each value can be coerced to + the resource PK type. ``ResourceGroupShare.object_id`` is a varchar; for + UUID-keyed resources (all 7 in-scope today) Postgres refuses the + implicit ``uuid = character varying`` comparison when this is fed into + ``Q(pk__in=...)``, so we cast in Python instead. The result set is + bounded by ``(groups user belongs to) × (shared rows of that model)``. + """ + raw_ids = ResourceGroupShare.objects.filter( + content_type=ContentType.objects.get_for_model(model), + group_id__in=user_group_ids, + ).values_list("object_id", flat=True) + if isinstance(model._meta.pk, models.UUIDField): + return _safe_uuids(raw_ids) + return list(raw_ids) + + +def serialize_group_refs(resource_obj: Any) -> list[dict[str, Any]]: + """Return a compact ``[{id, name}]`` listing for share modals.""" + return list(get_resource_share_groups(resource_obj).values("id", "name")) + + +def compute_effective_members(resource_obj: Any) -> list[dict[str, Any]]: + """Compute effective members of a shareable resource. + + Priority order: direct > group > org. A user listed via direct share + suppresses any group/org entries for the same user; a group entry + suppresses an org entry. + + Returns a list of dicts shaped for ``EffectiveMemberSerializer``. + """ + seen: dict[int, dict[str, Any]] = {} + + # Direct shares + direct_users = list( + resource_obj.shared_users.filter(is_service_account=False).values( + "id", "email", "first_name", "last_name" + ) + ) + for u in direct_users: + seen[u["id"]] = { + "user_id": u["id"], + "email": u["email"], + "display_name": _display_name(u), + "access_via": "direct", + "group_id": None, + "group_name": None, + } + + # Group shares — via the polymorphic resource_group_share table + group_memberships = GroupMembership.objects.filter( + group__in=get_resource_share_groups(resource_obj), + ).select_related("group", "user") + for membership in group_memberships: + user = membership.user + if getattr(user, "is_service_account", False): + continue + if user.id in seen: + continue + seen[user.id] = { + "user_id": user.id, + "email": user.email, + "display_name": _user_display_name(user), + "access_via": "group", + "group_id": membership.group_id, + "group_name": membership.group.name, + } + + # Org-wide share + _add_org_members(seen, resource_obj) + + return list(seen.values()) + + +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): + return + organization = getattr(resource_obj, "organization", None) + if organization is None: + return + org_members = ( + OrganizationMember.objects.filter(organization=organization) + .exclude(user__is_service_account=True) + .select_related("user") + ) + for member in org_members: + user = member.user + if user.id in seen: + continue + seen[user.id] = { + "user_id": user.id, + "email": user.email, + "display_name": _user_display_name(user), + "access_via": "org", + "group_id": None, + "group_name": None, + } + + +def _display_name(user_dict: dict[str, Any]) -> str: + parts = [ + (user_dict.get("first_name") or "").strip(), + (user_dict.get("last_name") or "").strip(), + ] + full = " ".join(p for p in parts if p) + return full or user_dict.get("email") or "" + + +def _user_display_name(user: User) -> str: + full = (user.get_full_name() or "").strip() + return full or user.email + + +# --------------------------------------------------------------------------- +# Share authorization (UN-2977 plan §A) +# --------------------------------------------------------------------------- + + +def is_org_admin(user: Any) -> bool: + """Resolve admin role for ``user`` in their current organization. + + Returns ``False`` on any lookup failure or for service accounts. The + role lookup goes through ``AuthenticationController`` so it honors the + same admin definition the group viewset uses. Accepts ``Any`` to match + DRF's ``request.user`` typing (``AbstractBaseUser | AnonymousUser``). + """ + if not getattr(user, "is_authenticated", False): + return False + if getattr(user, "is_service_account", False): + return False + try: + # Lazy import to keep ``tenant_account_v2`` boot light and to avoid + # circular import with ``account_v2.authentication_controller``. + from account_v2.authentication_controller import AuthenticationController + + controller = AuthenticationController() + member = controller.get_organization_members_by_user(user=user) + return controller.is_admin_by_role(member.role) + except (ObjectDoesNotExist, LookupError): + # Not a member of any org (onboarding / just removed) is a normal + # state, not an error — treat as non-admin without logging noise. + return False + except Exception: + logger.exception("Error resolving admin role for user_id=%s", user.pk) + return False + + +class ShareAuthorizationService: + """Authorize and commit a desired share-state for a resource. + + Encapsulates the matrix from the UN-2977 plan: owner has full control; + org admin can add or remove; direct shared users and group members can + add (with scope limits) but not remove, and cannot toggle + ``shared_to_org``. Service accounts bypass authorization — they already + bypass other access controls. + """ + + USERS_AXIS = "shared_users" + GROUPS_AXIS = "shared_groups" + ORG_AXIS = "shared_to_org" + + AXIS_LABELS = { + USERS_AXIS: "shared users", + GROUPS_AXIS: "shared groups", + ORG_AXIS: "organization sharing", + } + + @classmethod + def authorize_and_commit( + cls, + actor: Any, + resource: Any, + desired: dict[str, Any], + ) -> None: + """Validate, authorize, then apply the requested share state. + + ``desired`` may include any subset of ``shared_users``, + ``shared_groups`` (lists of int IDs), and ``shared_to_org`` (bool). + Axes absent from ``desired`` are not touched. ``actor`` is typed + ``Any`` to match DRF's ``request.user``. + """ + if getattr(actor, "is_service_account", False): + cls._commit(resource, desired) + return + + is_owner = resource.created_by_id == actor.pk + is_admin = is_org_admin(actor) + cls._authorize(actor, resource, desired, is_owner, is_admin) + cls._commit(resource, desired) + + # ------------------------------------------------------------------ auth + + @classmethod + def _authorize( + cls, + actor: Any, + resource: Any, + desired: dict[str, Any], + is_owner: bool, + is_admin: bool, + ) -> None: + if cls.USERS_AXIS in desired: + cls._check_users_axis(resource, desired[cls.USERS_AXIS], is_owner, is_admin) + if cls.GROUPS_AXIS in desired: + cls._check_groups_axis( + actor, resource, desired[cls.GROUPS_AXIS], is_owner, is_admin + ) + if cls.ORG_AXIS in desired: + cls._check_org_toggle(resource, desired[cls.ORG_AXIS], is_owner, is_admin) + + @classmethod + def _check_users_axis( + cls, + resource: Any, + desired_ids: Iterable[int], + is_owner: bool, + is_admin: bool, + ) -> None: + before, after = cls._diff_id_axis(resource, cls.USERS_AXIS, desired_ids) + cls._reject_removal_if_unprivileged( + axis=cls.USERS_AXIS, + removed=before - after, + is_owner=is_owner, + is_admin=is_admin, + ) + cls._validate_users_in_org(resource, after - before) + + @classmethod + def _check_groups_axis( + cls, + actor: Any, + resource: Any, + desired_ids: Iterable[int], + is_owner: bool, + is_admin: bool, + ) -> None: + before, after = cls._diff_id_axis(resource, cls.GROUPS_AXIS, desired_ids) + cls._reject_removal_if_unprivileged( + axis=cls.GROUPS_AXIS, + removed=before - after, + is_owner=is_owner, + is_admin=is_admin, + ) + added = after - before + if added: + cls._validate_groups_in_org(resource, added) + if not (is_owner or is_admin): + cls._reject_groups_actor_not_member(actor, added) + + @classmethod + def _check_org_toggle( + cls, resource: Any, desired: bool, is_owner: bool, is_admin: bool + ) -> None: + if bool(desired) == bool(getattr(resource, cls.ORG_AXIS)): + return + if not (is_owner or is_admin): + cls._raise_permission_denied( + "Only the resource owner or an organization admin can toggle " + "'shared_to_org'." + ) + + # ----------------------------------------------------------------- check + + @staticmethod + def _reject_removal_if_unprivileged( + axis: str, removed: set[int], is_owner: bool, is_admin: bool + ) -> None: + if not removed: + return + if is_owner or is_admin: + return + label = ShareAuthorizationService.AXIS_LABELS.get(axis, axis) + ShareAuthorizationService._raise_permission_denied( + f"Only the resource owner or an organization admin can remove {label}." + ) + + @staticmethod + def _validate_users_in_org(resource: Any, added_user_ids: set[int]) -> None: + if not added_user_ids: + return + member_user_ids = set( + OrganizationMember.objects.filter( + organization_id=resource.organization_id, user_id__in=added_user_ids + ).values_list("user_id", flat=True) + ) + missing = added_user_ids - member_user_ids + if missing: + raise ValidationError( + { + ShareAuthorizationService.USERS_AXIS: ( + "One or more users are not members of this organization." + ) + } + ) + + @staticmethod + def _validate_groups_in_org(resource: Any, added_group_ids: set[int]) -> None: + org_group_ids = set( + OrganizationGroup.objects.filter( + organization_id=resource.organization_id, id__in=added_group_ids + ).values_list("id", flat=True) + ) + missing = added_group_ids - org_group_ids + if missing: + raise ValidationError( + { + ShareAuthorizationService.GROUPS_AXIS: ( + "One or more groups don't belong to this organization." + ) + } + ) + + @staticmethod + def _reject_groups_actor_not_member(actor: Any, added_group_ids: set[int]) -> None: + member_group_ids = set( + GroupMembership.objects.filter( + user=actor, group_id__in=added_group_ids + ).values_list("group_id", flat=True) + ) + unauthorized = added_group_ids - member_group_ids + if unauthorized: + names = list( + OrganizationGroup.objects.filter(id__in=unauthorized) + .order_by("name") + .values_list("name", flat=True) + ) + label = ( + ", ".join(names) + if names + else ", ".join(str(i) for i in sorted(unauthorized)) + ) + ShareAuthorizationService._raise_permission_denied( + f"Cannot share with groups you are not a member of: {label}." + ) + + # ----------------------------------------------------------- diff helpers + + @staticmethod + def _diff_id_axis( + resource: Any, axis: str, desired_ids: Iterable[int] + ) -> tuple[set[int], set[int]]: + before = ShareAuthorizationService._current_ids(resource, axis) + after = {int(pk) for pk in desired_ids or ()} + return before, after + + @staticmethod + def _current_ids(resource: Any, axis: str) -> set[int]: + if axis == ShareAuthorizationService.GROUPS_AXIS: + return set(get_resource_share_groups(resource).values_list("id", flat=True)) + return set(getattr(resource, axis).values_list("pk", flat=True)) + + # ----------------------------------------------------------------- write + + @classmethod + @transaction.atomic + def _commit(cls, resource: Any, desired: dict[str, Any]) -> None: + if cls.USERS_AXIS in desired: + getattr(resource, cls.USERS_AXIS).set(desired[cls.USERS_AXIS] or []) + if cls.GROUPS_AXIS in desired: + set_resource_share_groups(resource, desired[cls.GROUPS_AXIS] or []) + if cls.ORG_AXIS in desired: + new_value = bool(desired[cls.ORG_AXIS]) + if bool(getattr(resource, cls.ORG_AXIS)) != new_value: + setattr(resource, cls.ORG_AXIS, new_value) + resource.save(update_fields=[cls.ORG_AXIS, "modified_at"]) + + # ------------------------------------------------------------ exceptions + + @staticmethod + def _raise_permission_denied(detail: str) -> None: + # Lazy import — DRF's exceptions module is light but the local import + # keeps the public surface of this helper module unchanged. + from rest_framework.exceptions import PermissionDenied + + raise PermissionDenied(detail) diff --git a/backend/tenant_account_v2/signals.py b/backend/tenant_account_v2/signals.py new file mode 100644 index 0000000000..e7a270f48d --- /dev/null +++ b/backend/tenant_account_v2/signals.py @@ -0,0 +1,136 @@ +import logging + +from django.apps import apps +from django.db import transaction +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from tenant_account_v2.models import GroupMembership, OrganizationMember + +logger = logging.getLogger(__name__) + +# (app_label, model_name) for every shareable resource. Lazy-loaded via +# ``apps.get_model`` so signals can fire before cross-app imports resolve, and +# so OSS-only deployments without the cloud agentic app skip cleanly. +_SHAREABLE_MODELS: tuple[tuple[str, str], ...] = ( + ("workflow_v2", "Workflow"), + ("pipeline_v2", "Pipeline"), + ("api_v2", "APIDeployment"), + ("connector_v2", "ConnectorInstance"), + ("adapter_processor_v2", "AdapterInstance"), + ("prompt_studio_core_v2", "CustomTool"), + ("agentic_studio_v1", "AgenticProject"), # cloud-only +) + + +@receiver(post_delete, sender=OrganizationMember) +def cleanup_user_org_access( + sender: type, instance: OrganizationMember, **kwargs: object +) -> None: + """Revoke a user's org-scoped access on org membership removal. + + Two cleanups: + 1. Group memberships for that org (group-derived access goes away live + via ``for_user()``). + 2. Direct ``shared_users`` M2M entries on every shareable resource of + that org — closes the rejoin backdoor where a re-invited user would + silently regain direct access. + + Uses a signal (not DB CASCADE) so notification / audit hooks can attach + here later without a schema change. The whole purge runs in one + transaction so a mid-loop failure rolls back rather than leaving the user + partially purged (which would silently re-open the rejoin backdoor). + """ + with transaction.atomic(): + deleted_count, _ = GroupMembership.objects.filter( + group__organization=instance.organization, + user=instance.user, + ).delete() + if deleted_count: + logger.info( + "Removed %s group memberships for user=%s org=%s after OrganizationMember delete", + deleted_count, + instance.user_id, + instance.organization_id, + ) + + for app_label, model_name in _SHAREABLE_MODELS: + try: + model = apps.get_model(app_label, model_name) + except LookupError: + # App not installed in this deployment (e.g. cloud-only + # agentic_studio_v1 in pure OSS). Skip cleanly. + continue + try: + resources = model.objects.filter( + organization=instance.organization, + shared_users=instance.user, + ) + removed = 0 + for resource in resources: + resource.shared_users.remove(instance.user) + removed += 1 + except Exception: + logger.exception( + "Failed purging shared_users for user=%s on %s.%s org=%s; " + "rolling back the whole purge", + instance.user_id, + app_label, + model_name, + instance.organization_id, + ) + raise + if removed: + logger.info( + "Removed user=%s from shared_users on %s %s.%s rows in org=%s", + instance.user_id, + removed, + app_label, + model_name, + instance.organization_id, + ) + + +def cleanup_resource_group_shares( + sender: type, instance: object, **kwargs: object +) -> None: + """Purge ``ResourceGroupShare`` rows when a shareable resource is deleted. + + ``object_id`` is a plain varchar (no FK/CASCADE), so group-share rows would + otherwise dangle indefinitely after the resource is gone. + """ + from django.contrib.contenttypes.models import ContentType + + from tenant_account_v2.models import ResourceGroupShare + + deleted, _ = ResourceGroupShare.objects.filter( + content_type=ContentType.objects.get_for_model(sender), + object_id=str(instance.pk), + ).delete() + if deleted: + logger.info( + "Removed %s ResourceGroupShare rows after %s(%s) delete", + deleted, + sender.__name__, + instance.pk, + ) + + +def _connect_resource_group_share_cleanup() -> None: + """Wire :func:`cleanup_resource_group_shares` to each installed shareable + model. Lazy per-model connect so OSS deployments without the cloud agentic + app skip it cleanly; ``dispatch_uid`` keeps the connect idempotent. + """ + for app_label, model_name in _SHAREABLE_MODELS: + try: + model = apps.get_model(app_label, model_name) + except LookupError: + continue + post_delete.connect( + cleanup_resource_group_shares, + sender=model, + dispatch_uid=f"cleanup_resource_group_shares_{app_label}_{model_name}", + ) + + +_connect_resource_group_share_cleanup() diff --git a/backend/tenant_account_v2/urls.py b/backend/tenant_account_v2/urls.py index b964aff517..91831b8c11 100644 --- a/backend/tenant_account_v2/urls.py +++ b/backend/tenant_account_v2/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from tenant_account_v2 import invitation_urls, users_urls +from tenant_account_v2 import groups_urls, invitation_urls, users_urls from tenant_account_v2.views import get_organization, get_roles, reset_password urlpatterns = [ @@ -9,4 +9,5 @@ path("invitation/", include(invitation_urls)), path("organization", get_organization, name="get_organization"), path("reset_password", reset_password, name="reset_password"), + path("", include(groups_urls)), ] diff --git a/backend/tenant_account_v2/views.py b/backend/tenant_account_v2/views.py index 208967c18c..838e6adff7 100644 --- a/backend/tenant_account_v2/views.py +++ b/backend/tenant_account_v2/views.py @@ -70,7 +70,7 @@ def get_organization(request: Request) -> Response: ) except Exception as error: - logger.error(f"Error while get User : {error}") + logger.error("Error while get User : %s", error) return Response( status=status.HTTP_500_INTERNAL_SERVER_ERROR, data={"message": "Internal Error"}, diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index 58aa8e5ea6..1b2891937e 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -23,6 +23,7 @@ def for_user(self, user): - Workflows created by the user - Workflows shared with the user - Workflows shared with the entire organization + - Workflows shared with any group the user is a member of - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): @@ -31,10 +32,15 @@ def for_user(self, user): if OrganizationMemberService.is_user_organization_admin(user): return self.all() + from tenant_account_v2.sharing_helpers import resources_visible_via_groups + + user_group_ids = user.group_memberships.values_list("group_id", flat=True) + group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) return self.filter( Q(created_by=user) # Owned by user | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization + | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() @@ -106,6 +112,15 @@ class ExecutionAction(models.TextChoices): default=False, db_comment="Whether this workflow is shared with the entire organization", ) + # ``shared_groups`` is stored polymorphically in + # ``tenant_account_v2.ResourceGroupShare``; the property preserves the + # ergonomic read surface for DRF / existing callers. + + @property + def shared_groups(self): + from tenant_account_v2.sharing_helpers import get_resource_share_groups + + return get_resource_share_groups(self) # Manager objects = WorkflowModelManager() diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index ed34592958..97a72d389b 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -2,6 +2,7 @@ from typing import Any from django.conf import settings +from rest_framework import serializers from rest_framework.serializers import ( CharField, ChoiceField, @@ -12,6 +13,7 @@ UUIDField, ValidationError, ) +from tenant_account_v2.sharing_helpers import serialize_group_refs from tool_instance_v2.serializers import ToolInstanceSerializer from tool_instance_v2.tool_instance_helper import ToolInstanceHelper from utils.input_sanitizer import validate_name_field, validate_no_html_tags @@ -30,6 +32,11 @@ class WorkflowSerializer(IntegrityErrorMixin, AuditSerializer): tool_instances = ToolInstanceSerializer(many=True, read_only=True) + # ``shared_groups`` is no longer an M2M on Workflow — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. Share + # mutations go through ``POST /workflow/{id}/share/``; the field is + # read-only on this serializer (UN-2977 plan §B). + shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: model = Workflow @@ -38,6 +45,8 @@ class Meta: WorkflowKey.LLM_RESPONSE: { "required": False, }, + "shared_users": {"read_only": True}, + "shared_to_org": {"read_only": True}, } unique_error_message_map: dict[str, dict[str, str]] = { @@ -171,14 +180,22 @@ def get_has_exceeded_limit(self, obj: FileHistory) -> bool: class SharedUserListSerializer(ModelSerializer): - """Serializer for returning workflow with shared user details.""" + """Serializer for returning workflow with shared user + group details.""" shared_users = SerializerMethodField() + shared_groups = SerializerMethodField() created_by = SerializerMethodField() class Meta: model = Workflow - fields = ["id", "workflow_name", "shared_users", "shared_to_org", "created_by"] + fields = [ + "id", + "workflow_name", + "shared_users", + "shared_to_org", + "shared_groups", + "created_by", + ] def get_shared_users(self, obj): """Return list of shared users with id and email.""" @@ -187,6 +204,9 @@ def get_shared_users(self, obj): for user in obj.shared_users.filter(is_service_account=False) ] + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + def get_created_by(self, obj): """Return creator details.""" if obj.created_by: diff --git a/backend/workflow_manager/workflow_v2/urls/workflow.py b/backend/workflow_manager/workflow_v2/urls/workflow.py index f83fcb8b45..1225a4a32c 100644 --- a/backend/workflow_manager/workflow_v2/urls/workflow.py +++ b/backend/workflow_manager/workflow_v2/urls/workflow.py @@ -26,6 +26,8 @@ workflow_schema = WorkflowViewSet.as_view({"get": "get_schema"}) can_update = WorkflowViewSet.as_view({"get": "can_update"}) list_shared_users = WorkflowViewSet.as_view({"get": "list_of_shared_users"}) +workflow_share = WorkflowViewSet.as_view({"post": "share"}) +workflow_effective_members = WorkflowViewSet.as_view({"get": "effective_members"}) # File History views file_history_list = FileHistoryViewSet.as_view({"get": "list"}) @@ -51,6 +53,12 @@ list_shared_users, name="list-shared-users", ), + path("/share/", workflow_share, name="workflow-share"), + path( + "/effective-members/", + workflow_effective_members, + name="workflow-effective-members", + ), path("execute/", workflow_execute, name="execute-workflow"), path( "active//", diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index 82c02fc4e5..e0822e966f 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -8,6 +8,7 @@ from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg +from permissions.resource_share_views import ResourceShareManagementMixin from pipeline_v2.models import Pipeline from pipeline_v2.pipeline_processor import PipelineProcessor from plugins import get_plugin @@ -68,7 +69,7 @@ def make_execution_response(response: ExecutionResponse) -> Any: return ExecuteWorkflowResponseSerializer(response).data -class WorkflowViewSet(viewsets.ModelViewSet): +class WorkflowViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): versioning_class = URLPathVersioning def get_permissions(self) -> list[Any]: @@ -139,55 +140,49 @@ def perform_create(self, serializer: WorkflowSerializer) -> Workflow: def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Override partial_update to handle sharing notifications.""" - # Get the workflow instance before update workflow = self.get_object() + before = self.snapshot_share_axes(workflow) - # Store current shared users for comparison - current_shared_users = set(workflow.shared_users.all()) - - # Perform the standard partial update response = super().partial_update(request, *args, **kwargs) - - # If update was successful and shared_users field was modified - if ( - response.status_code == 200 - and "shared_users" in request.data - and bool(notification_plugin) - ): - try: - # Get updated workflow to compare shared users - workflow.refresh_from_db() - new_shared_users = set(workflow.shared_users.all()) - - # Find newly added users - newly_shared_users = new_shared_users - current_shared_users - - if newly_shared_users: - # Get notification service from plugin and send notification - 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=request.user, - shared_to=list(newly_shared_users), - resource_instance=workflow, - ) - - logger.info( - f"Sent sharing notifications for workflow {workflow.id} " - f"to {len(newly_shared_users)} users" - ) - - except Exception as e: - # Log error but don't fail the update operation - logger.exception( - f"Failed to send sharing notification, continuing update though: {str(e)}" - ) - + 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/package-lock.json b/frontend/package-lock.json index 8eaf46bd8e..3f046af228 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "antd": "^5.5.1", - "axios": "^1.4.0", + "axios": "1.13.5", "cron-validator": "^1.3.1", "cronstrue": "^2.48.0", "date-fns": "^4.1.0", @@ -3395,6 +3395,8 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -3408,11 +3410,13 @@ } }, "node_modules/axios": { - "version": "1.4.0", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -3629,6 +3633,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "license": "MIT", @@ -3830,6 +3847,8 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4262,6 +4281,8 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4338,6 +4359,20 @@ "tslib": "^2.0.3" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.325", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", @@ -4431,6 +4466,24 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-get-iterator": { "version": "1.1.3", "license": "MIT", @@ -4456,6 +4509,33 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -4618,7 +4698,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.2", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4643,11 +4725,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -4750,8 +4836,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/functions-have-names": { "version": "1.2.3", @@ -4791,18 +4882,42 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -4835,10 +4950,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4949,18 +5066,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { - "version": "1.0.3", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4970,10 +5079,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -4989,6 +5100,18 @@ "license": "ISC", "optional": true }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-parse-selector": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", @@ -6023,6 +6146,15 @@ "react": ">= 0.14.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-definitions": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", @@ -6760,6 +6892,8 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6767,6 +6901,8 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" diff --git a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx index 86ab15ab16..5017e2b285 100644 --- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx +++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; +import { groupsService } from "../../groups/groups-service.js"; import { CustomButton } from "../../widgets/custom-button/CustomButton"; import { AddCustomToolFormModal } from "../add-custom-tool-form-modal/AddCustomToolFormModal"; import { ViewTools } from "../view-tools/ViewTools"; @@ -59,6 +60,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const { setAlertDetails } = useAlertStore(); const axiosPrivate = useAxiosPrivate(); const handleException = useExceptionHandler(); + const groupsApi = groupsService(); const [listOfTools, setListOfTools] = useState([]); const [filteredListOfTools, setFilteredListOfTools] = useState([]); @@ -69,6 +71,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const [isPermissionEdit, setIsPermissionEdit] = useState(false); const [isShareLoading, setIsShareLoading] = useState(false); const [allUserList, setAllUserList] = useState([]); + const [allGroupList, setAllGroupList] = useState([]); useEffect(() => { getListOfTools(); @@ -280,6 +283,13 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { }; setIsShareLoading(true); getAllUsers(); + groupsApi + .listGroups() + .then((res) => { + const items = Array.isArray(res?.data) ? res.data : []; + setAllGroupList(items.map((g) => ({ id: g.id, name: g.name }))); + }) + .catch(() => setAllGroupList([])); axiosPrivate(requestOptions) .then((res) => { setOpenSharePermissionModal(true); @@ -319,20 +329,23 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { }); }; - const onShare = (userIds, adapter, shareWithEveryone) => { + const onShare = (userIds, adapter, shareWithEveryone, groupIds = []) => { const requestOptions = { - method: "PATCH", - url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${adapter?.tool_id}/`, + method: "POST", + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${adapter?.tool_id}/share/`, headers: { "X-CSRFToken": sessionDetails?.csrfToken, }, data: { shared_users: userIds, shared_to_org: shareWithEveryone || false, + shared_groups: groupIds, }, }; axiosPrivate(requestOptions) - .then((response) => { + .then(() => { + // Close only on success; keep the modal open on failure so the user + // can see the rejected entries and retry. setOpenSharePermissionModal(false); }) .catch((err) => { @@ -408,6 +421,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { permissionEdit={isPermissionEdit} loading={isShareLoading} allUsers={allUserList} + allGroups={allGroupList} onApply={onShare} isSharableToOrg={true} /> diff --git a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx index 5bde2402f7..50ae379e89 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx @@ -18,6 +18,7 @@ import { usePromptStudioStore } from "../../../store/prompt-studio-store"; import { useSessionStore } from "../../../store/session-store"; import { usePromptStudioService } from "../../api/prompt-studio-service"; import { PromptStudioModal } from "../../common/PromptStudioModal"; +import { groupsService } from "../../groups/groups-service.js"; import { LogsModal } from "../../pipelines-or-deployments/log-modal/LogsModal.jsx"; import { NotificationModal } from "../../pipelines-or-deployments/notification-modal/NotificationModal.jsx"; import { SharePermission } from "../../widgets/share-permission/SharePermission"; @@ -89,15 +90,18 @@ function ApiDeployment() { setAlertDetails, }); + const groupsApi = groupsService(); const { openShareModal, setOpenShareModal, allUsers, + allGroups, isLoadingShare, handleShare, onShare, } = useShareModal({ apiService: apiDeploymentsApiService, + groupsApi, setSelectedItem: setSelectedRow, setAlertDetails, handleException, @@ -355,6 +359,7 @@ function ApiDeployment() { permissionEdit={true} loading={isLoadingShare} allUsers={allUsers} + allGroups={Array.isArray(allGroups) ? allGroups : []} onApply={onShare} isSharableToOrg={true} /> diff --git a/frontend/src/components/deployments/api-deployment/api-deployments-service.js b/frontend/src/components/deployments/api-deployment/api-deployments-service.js index 4990178b4f..485bc5d853 100644 --- a/frontend/src/components/deployments/api-deployment/api-deployments-service.js +++ b/frontend/src/components/deployments/api-deployment/api-deployments-service.js @@ -111,14 +111,15 @@ function apiDeploymentsService() { }; return axiosPrivate(options); }, - updateSharing: (id, sharedUsers, shareWithEveryone) => { + updateSharing: (id, sharedUsers, shareWithEveryone, sharedGroups = []) => { options = { - method: "PATCH", - url: `${path}/api/deployment/${id}/`, + method: "POST", + url: `${path}/api/deployment/${id}/share/`, headers: requestHeaders, data: { shared_users: sharedUsers, shared_to_org: shareWithEveryone, + shared_groups: sharedGroups, }, }; return axiosPrivate(options); diff --git a/frontend/src/components/groups/GroupCreateEditModal.jsx b/frontend/src/components/groups/GroupCreateEditModal.jsx new file mode 100644 index 0000000000..bcbe769626 --- /dev/null +++ b/frontend/src/components/groups/GroupCreateEditModal.jsx @@ -0,0 +1,97 @@ +import { Form, Input, Modal } from "antd"; +import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; + +import { useExceptionHandler } from "../../hooks/useExceptionHandler.jsx"; +import { useAlertStore } from "../../store/alert-store"; + +import { groupsService } from "./groups-service.js"; + +function GroupCreateEditModal({ open, mode, group, onClose, onSaved }) { + const service = groupsService(); + const handleException = useExceptionHandler(); + const { setAlertDetails } = useAlertStore(); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + form.setFieldsValue({ + name: mode === "edit" ? group?.name : "", + description: mode === "edit" ? group?.description : "", + }); + } + }, [open, mode, group, form]); + + const handleOk = async () => { + // Ant Design surfaces validation errors inline; bail out quietly on failure. + const values = await form.validateFields().catch(() => null); + if (!values) { + return; + } + if (mode === "edit" && !group?.id) { + setAlertDetails({ + type: "error", + content: "Missing group context for edit.", + }); + return; + } + setSubmitting(true); + const call = + mode === "edit" + ? service.updateGroup(group.id, values) + : service.createGroup(values); + call + .then(() => { + setAlertDetails({ + type: "success", + content: mode === "edit" ? "Group updated" : "Group created", + }); + form.resetFields(); + onSaved?.(); + }) + .catch((err) => + setAlertDetails(handleException(err, "Failed to save group")), + ) + .finally(() => setSubmitting(false)); + }; + + return ( + { + form.resetFields(); + onClose?.(); + }} + centered + okText={mode === "edit" ? "Save" : "Create"} + maskClosable={false} + > +
+ + + + + + +
+
+ ); +} + +GroupCreateEditModal.propTypes = { + open: PropTypes.bool.isRequired, + mode: PropTypes.oneOf(["create", "edit"]).isRequired, + group: PropTypes.object, + onClose: PropTypes.func, + onSaved: PropTypes.func, +}; + +export { GroupCreateEditModal }; diff --git a/frontend/src/components/groups/GroupMemberManager.jsx b/frontend/src/components/groups/GroupMemberManager.jsx new file mode 100644 index 0000000000..ae83c87607 --- /dev/null +++ b/frontend/src/components/groups/GroupMemberManager.jsx @@ -0,0 +1,161 @@ +import { DeleteOutlined, QuestionCircleOutlined } from "@ant-design/icons"; +import { Avatar, List, Modal, Popconfirm, Select } from "antd"; +import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; + +import { useExceptionHandler } from "../../hooks/useExceptionHandler.jsx"; +import { useAlertStore } from "../../store/alert-store"; +import { SpinnerLoader } from "../widgets/spinner-loader/SpinnerLoader.jsx"; + +import { groupsService } from "./groups-service.js"; + +function GroupMemberManager({ open, group, onClose }) { + const service = groupsService(); + const handleException = useExceptionHandler(); + const { setAlertDetails } = useAlertStore(); + + const [members, setMembers] = useState([]); + const [orgUsers, setOrgUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [pendingAddIds, setPendingAddIds] = useState([]); + + const loadMembers = () => { + if (!group?.id) { + return; + } + setLoading(true); + Promise.all([service.listGroupMembers(group.id), service.getAllOrgUsers()]) + .then(([memberRes, usersRes]) => { + setMembers(memberRes?.data || []); + const all = usersRes?.data?.members || []; + setOrgUsers( + all.map((m) => ({ + id: m.id, + email: m.email, + })), + ); + }) + .catch((err) => setAlertDetails(handleException(err, "Failed to load"))) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + if (open) { + loadMembers(); + setPendingAddIds([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, group?.id]); + + const memberIds = new Set(members.map((m) => m.user_id)); + const candidateUsers = orgUsers.filter((u) => !memberIds.has(u.id)); + + const handleAdd = () => { + if (!pendingAddIds.length) { + return; + } + setLoading(true); + service + .addGroupMembers(group.id, pendingAddIds) + .then(() => { + setAlertDetails({ type: "success", content: "Members added" }); + setPendingAddIds([]); + loadMembers(); + }) + .catch((err) => + setAlertDetails(handleException(err, "Failed to add members")), + ) + .finally(() => setLoading(false)); + }; + + const handleRemove = (userId) => { + setLoading(true); + service + .removeGroupMember(group.id, userId) + .then(() => { + setAlertDetails({ type: "success", content: "Member removed" }); + loadMembers(); + }) + .catch((err) => + setAlertDetails(handleException(err, "Failed to remove member")), + ) + .finally(() => setLoading(false)); + }; + + return ( + + {loading ? ( + + ) : ( + <> + { - const isValueSelected = selectedUsers.includes(selectedValue); - if (!isValueSelected) { - // Update the state only if the selected value is not already present - setSelectedUsers([...selectedUsers, selectedValue]); - } - }} - options={filteredUsers.map((user) => ({ - label: user.email, - value: user.id, - }))} - > - {filteredUsers.map((user) => { - return ( - - {user?.email} - - ); - })} - + <> +
+ Add users + { + if (!selectedGroupIds.includes(groupId)) { + setSelectedGroupIds([...selectedGroupIds, groupId]); + } + }} + options={groupCandidateOptions} + /> +
+ )} + )} - Shared with - {sharedWithContent} +
+ Currently shared with + {sharedWithContent} +
)}
@@ -216,6 +269,7 @@ SharePermission.propTypes = { permissionEdit: PropTypes.bool, loading: PropTypes.bool, allUsers: PropTypes.array, + allGroups: PropTypes.array, onApply: PropTypes.func, isSharableToOrg: PropTypes.bool, }; diff --git a/frontend/src/components/widgets/top-bar/TopBar.jsx b/frontend/src/components/widgets/top-bar/TopBar.jsx index bc754c1367..1f5049a2ad 100644 --- a/frontend/src/components/widgets/top-bar/TopBar.jsx +++ b/frontend/src/components/widgets/top-bar/TopBar.jsx @@ -10,6 +10,8 @@ function TopBar({ enableSearch, searchData, setFilteredUserList, + searchKey = "email", + searchPlaceholder = "Search Users", children, }) { const navigate = useNavigate(); @@ -23,10 +25,10 @@ function TopBar({ return; } - const filteredList = [...searchData].filter((user) => { - const username = user?.email?.toLowerCase(); - const searchTextLowerCase = searchText.toLowerCase(); - return username.includes(searchTextLowerCase); + const searchTextLowerCase = searchText.toLowerCase(); + const filteredList = [...searchData].filter((item) => { + const value = item?.[searchKey]?.toLowerCase() ?? ""; + return value.includes(searchTextLowerCase); }); setFilteredUserList(filteredList); }; @@ -39,7 +41,10 @@ function TopBar({
{enableSearch && ( - + )} {children}
@@ -53,6 +58,8 @@ TopBar.propTypes = { enableSearch: PropTypes.bool.isRequired, searchData: PropTypes.array, setFilteredUserList: PropTypes.func, + searchKey: PropTypes.string, + searchPlaceholder: PropTypes.string, children: PropTypes.element, }; diff --git a/frontend/src/components/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx index 8716c88772..1b11b8b12e 100644 --- a/frontend/src/components/workflows/workflow/Workflows.jsx +++ b/frontend/src/components/workflows/workflow/Workflows.jsx @@ -24,6 +24,7 @@ import { import { usePromptStudioService } from "../../api/prompt-studio-service"; import { PromptStudioModal } from "../../common/PromptStudioModal"; import { ViewTools } from "../../custom-tools/view-tools/ViewTools.jsx"; +import { groupsService } from "../../groups/groups-service.js"; import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar.jsx"; import { workflowService } from "./workflow-service"; @@ -33,6 +34,7 @@ function Workflows() { const navigate = useNavigate(); const location = useLocation(); const projectApiService = workflowService(); + const groupsApi = groupsService(); const handleException = useExceptionHandler(); const { setPostHogCustomEvent } = usePostHogEvents(); const { count, isLoading, fetchCount } = usePromptStudioStore(); @@ -54,6 +56,7 @@ function Workflows() { const [sharePermissionEdit, setSharePermissionEdit] = useState(false); const [shareLoading, setShareLoading] = useState(false); const [allUsers, setAllUsers] = useState([]); + const [allGroups, setAllGroups] = useState([]); const { setAlertDetails } = useAlertStore(); const sessionDetails = useSessionStore((state) => state?.sessionDetails); @@ -230,10 +233,12 @@ function Workflows() { setShareLoading(true); try { - const [usersResponse, sharedUsersResponse] = await Promise.all([ - projectApiService.getAllUsers(), - projectApiService.getSharedUsers(workflow.id), - ]); + const [usersResponse, sharedUsersResponse, groupsResponse] = + await Promise.all([ + projectApiService.getAllUsers(), + projectApiService.getSharedUsers(workflow.id), + groupsApi.listGroups(), + ]); const userList = usersResponse?.data?.members?.map((member) => ({ @@ -243,6 +248,14 @@ function Workflows() { // Pass the complete user list - SharePermission component will handle filtering setAllUsers(userList); + setAllGroups( + Array.isArray(groupsResponse?.data) + ? groupsResponse.data.map((g) => ({ + id: g.id, + name: g.name, + })) + : [], + ); setSelectedWorkflow(sharedUsersResponse.data); setShareOpen(true); } catch (err) { @@ -254,20 +267,28 @@ function Workflows() { } }; - const onShare = async (selectedUsers, workflow, shareWithEveryone) => { + const onShare = async ( + selectedUsers, + workflow, + shareWithEveryone, + selectedGroups = [], + ) => { setShareLoading(true); try { await projectApiService.updateSharing( workflow.id, selectedUsers, shareWithEveryone, + selectedGroups, ); - setShareOpen(false); setAlertDetails({ type: "success", content: "Workflow sharing updated successfully", }); - getProjectList(); // Refresh the list + getProjectList(); + // Close only on success; keep the modal open on failure so the user + // can see the rejected entries and retry. + setShareOpen(false); } catch (error) { setAlertDetails( handleException(error, "Unable to update workflow sharing"), @@ -376,6 +397,7 @@ function Workflows() { permissionEdit={sharePermissionEdit} loading={shareLoading} allUsers={allUsers} + allGroups={allGroups} onApply={onShare} isSharableToOrg={true} /> diff --git a/frontend/src/components/workflows/workflow/workflow-service.js b/frontend/src/components/workflows/workflow/workflow-service.js index 5dfd0a76d7..c76ddac106 100644 --- a/frontend/src/components/workflows/workflow/workflow-service.js +++ b/frontend/src/components/workflows/workflow/workflow-service.js @@ -124,16 +124,17 @@ function workflowService() { }; return axiosPrivate(options); }, - updateSharing: (id, sharedUsers, shareWithEveryone) => { + updateSharing: (id, sharedUsers, shareWithEveryone, sharedGroups = []) => { options = { - url: `${path}/workflow/${id}/`, - method: "PATCH", + url: `${path}/workflow/${id}/share/`, + method: "POST", headers: { "X-CSRFToken": csrfToken, }, data: { shared_users: sharedUsers, shared_to_org: shareWithEveryone, + shared_groups: sharedGroups, }, }; return axiosPrivate(options); diff --git a/frontend/src/hooks/useShareModal.js b/frontend/src/hooks/useShareModal.js index 82d915474a..3c380383f6 100644 --- a/frontend/src/hooks/useShareModal.js +++ b/frontend/src/hooks/useShareModal.js @@ -2,10 +2,14 @@ import { useRef, useState } from "react"; /** * Shared hook for share modal state and logic. - * Handles fetching users, opening the modal, and applying share updates. + * Handles fetching users + groups, opening the modal, and applying share updates. + * + * ``apiService.updateSharing`` may accept an optional fourth ``sharedGroups`` + * argument — services that don't support groups yet are unaffected. * * @param {Object} options * @param {Object} options.apiService - service with getAllUsers, getSharedUsers, updateSharing + * @param {Object} [options.groupsApi] - optional service exposing listGroups(); when present, group sharing is enabled * @param {Function} options.setSelectedItem - setter for the parent's selected item state * @param {Function} options.setAlertDetails - from alert store * @param {Function} options.handleException - exception handler @@ -14,6 +18,7 @@ import { useRef, useState } from "react"; */ function useShareModal({ apiService, + groupsApi, setSelectedItem, setAlertDetails, handleException, @@ -21,6 +26,7 @@ function useShareModal({ }) { const [openShareModal, setOpenShareModal] = useState(false); const [allUsers, setAllUsers] = useState([]); + const [allGroups, setAllGroups] = useState([]); const [isLoadingShare, setIsLoadingShare] = useState(false); const shareItemIdRef = useRef(null); @@ -31,10 +37,15 @@ function useShareModal({ shareItemIdRef.current = item.id; setIsLoadingShare(true); try { - const [usersResponse, sharedUsersResponse] = await Promise.all([ + const calls = [ apiService.getAllUsers(), apiService.getSharedUsers(item.id), - ]); + ]; + if (groupsApi?.listGroups) { + calls.push(groupsApi.listGroups()); + } + const [usersResponse, sharedUsersResponse, groupsResponse] = + await Promise.all(calls); let userList = []; const responseData = usersResponse?.data; @@ -56,34 +67,53 @@ function useShareModal({ } const sharedUsersList = sharedUsersResponse.data?.shared_users || []; + const sharedGroupsList = sharedUsersResponse.data?.shared_groups || []; setSelectedItem({ ...item, shared_users: Array.isArray(sharedUsersList) ? sharedUsersList : [], + shared_groups: Array.isArray(sharedGroupsList) ? sharedGroupsList : [], }); setAllUsers(userList); + const groupItems = Array.isArray(groupsResponse?.data) + ? groupsResponse.data + : []; + setAllGroups( + groupItems.map((g) => ({ + id: g.id, + name: g.name, + })), + ); setOpenShareModal(true); } catch (err) { setAlertDetails( handleException(err, "Unable to fetch sharing information"), ); setAllUsers([]); + setAllGroups([]); } finally { setIsLoadingShare(false); } }; - const onShare = (sharedUsers, _, shareWithEveryone) => { + const onShare = (sharedUsers, _, shareWithEveryone, sharedGroups) => { setIsLoadingShare(true); apiService - .updateSharing(shareItemIdRef.current, sharedUsers, shareWithEveryone) + .updateSharing( + shareItemIdRef.current, + sharedUsers, + shareWithEveryone, + sharedGroups || [], + ) .then(() => { setAlertDetails({ type: "success", content: "Sharing permissions updated successfully", }); - setOpenShareModal(false); refreshRef.current?.(); + // Close only on success; keep the modal open on failure so the user + // can see the rejected entries and retry. + setOpenShareModal(false); }) .catch((err) => { setAlertDetails(handleException(err)); @@ -97,6 +127,7 @@ function useShareModal({ openShareModal, setOpenShareModal, allUsers, + allGroups, isLoadingShare, handleShare, onShare, diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 142db6a7a4..982ecdb17d 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1,13 +1,12 @@ import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; -import React from "react"; import ReactDOM from "react-dom/client"; import { GenericLoader } from "./components/generic-loader/GenericLoader"; import { LazyLoader } from "./components/widgets/lazy-loader/LazyLoader.jsx"; +import config from "./config.js"; import { SocketProvider } from "./helpers/SocketContext.js"; import "./index.css"; -import config from "./config.js"; const enablePosthog = import.meta.env.VITE_ENABLE_POSTHOG; if (enablePosthog !== "false") { @@ -39,15 +38,13 @@ setFavicon(config.favicon); const root = ReactDOM.createRoot(document.getElementById("root")); root.render( - - - - } - component={() => import("./App.jsx")} - componentName="App" - /> - - - , + + + } + component={() => import("./App.jsx")} + componentName="App" + /> + + , ); diff --git a/frontend/src/pages/ConnectorsPage.jsx b/frontend/src/pages/ConnectorsPage.jsx index 0174111077..dfbaec53ad 100644 --- a/frontend/src/pages/ConnectorsPage.jsx +++ b/frontend/src/pages/ConnectorsPage.jsx @@ -10,6 +10,7 @@ import { useAlertStore } from "../store/alert-store"; import { useSessionStore } from "../store/session-store"; import "./ConnectorsPage.css"; import { ViewTools } from "../components/custom-tools/view-tools/ViewTools"; +import { groupsService } from "../components/groups/groups-service.js"; import { AddSourceModal } from "../components/input-output/add-source-modal/AddSourceModal"; import { ToolNavBar } from "../components/navigations/tool-nav-bar/ToolNavBar"; import { SharePermission } from "../components/widgets/share-permission/SharePermission"; @@ -21,8 +22,10 @@ function ConnectorsPage() { const [shareModalVisible, setShareModalVisible] = useState(false); const [sharingConnector, setSharingConnector] = useState(null); const [userList, setUserList] = useState([]); + const [groupList, setGroupList] = useState([]); const [isPermissionEdit, setIsPermissionEdit] = useState(false); const [isShareLoading, setIsShareLoading] = useState(false); + const groupsApi = groupsService(); const axiosPrivate = useAxiosPrivate(); const { sessionDetails } = useSessionStore(); @@ -97,18 +100,31 @@ function ConnectorsPage() { setSharingConnector(connector); setIsPermissionEdit(isEdit); setShareModalVisible(true); + groupsApi + .listGroups() + .then((res) => { + const items = Array.isArray(res?.data) ? res.data : []; + setGroupList(items.map((g) => ({ id: g.id, name: g.name }))); + }) + .catch(() => setGroupList([])); }; - const handleShareSave = async (userIds, connector, shareWithEveryone) => { + const handleShareSave = async ( + userIds, + connector, + shareWithEveryone, + groupIds = [], + ) => { setIsShareLoading(true); try { const updateData = { shared_users: userIds, shared_to_org: shareWithEveryone || false, + shared_groups: groupIds, }; - await axiosPrivate.patch( - getUrl(`connector/${connector.id}/`), + await axiosPrivate.post( + getUrl(`connector/${connector.id}/share/`), updateData, { headers: { @@ -193,6 +209,7 @@ function ConnectorsPage() { setOpen={setShareModalVisible} adapter={sharingConnector} allUsers={userList} + allGroups={groupList} onApply={handleShareSave} permissionEdit={isPermissionEdit} loading={isShareLoading} diff --git a/frontend/src/pages/GroupsPage.jsx b/frontend/src/pages/GroupsPage.jsx new file mode 100644 index 0000000000..06f87b83a0 --- /dev/null +++ b/frontend/src/pages/GroupsPage.jsx @@ -0,0 +1,7 @@ +import { Groups } from "../components/groups/Groups.jsx"; + +function GroupsPage() { + return ; +} + +export { GroupsPage }; diff --git a/frontend/src/routes/useMainAppRoutes.js b/frontend/src/routes/useMainAppRoutes.js index 6a8769c455..53c9695b87 100644 --- a/frontend/src/routes/useMainAppRoutes.js +++ b/frontend/src/routes/useMainAppRoutes.js @@ -11,6 +11,7 @@ import { AgencyPage } from "../pages/AgencyPage.jsx"; import ConnectorsPage from "../pages/ConnectorsPage.jsx"; import { CustomTools } from "../pages/CustomTools.jsx"; import { DeploymentsPage } from "../pages/DeploymentsPage.jsx"; +import { GroupsPage } from "../pages/GroupsPage.jsx"; import { InviteEditUserPage } from "../pages/InviteEditUserPage.jsx"; import { LogsPage } from "../pages/LogsPage.jsx"; import { MetricsDashboardPage } from "../pages/MetricsDashboardPage.jsx"; @@ -253,6 +254,7 @@ function useMainAppRoutes() { } /> } /> } /> + } /> }