From fb44e74b9ec2d1979ce0ed3f42e61d99f760fee9 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Fri, 22 May 2026 18:08:12 +0530 Subject: [PATCH 01/14] init changes. User's Group's --- .../0005_organization_idp_group_allowlist.py | 21 ++ backend/account_v2/models.py | 8 + .../migrations/0004_add_shared_groups.py | 22 ++ backend/adapter_processor_v2/models.py | 7 + backend/adapter_processor_v2/serializers.py | 16 + backend/adapter_processor_v2/views.py | 11 + backend/api_v2/api_deployment_views.py | 11 + .../migrations/0004_add_shared_groups.py | 22 ++ backend/api_v2/models.py | 8 + backend/api_v2/serializers.py | 26 +- backend/backend/settings/base.py | 5 + .../migrations/0006_add_shared_groups.py | 22 ++ backend/connector_v2/models.py | 7 + backend/connector_v2/serializers.py | 8 + backend/connector_v2/views.py | 12 + backend/permissions/permission.py | 33 +- .../migrations/0004_add_shared_groups.py | 22 ++ backend/pipeline_v2/models.py | 8 + backend/pipeline_v2/serializers/crud.py | 8 + backend/pipeline_v2/serializers/sharing.py | 8 +- backend/pipeline_v2/views.py | 14 + .../migrations/0008_add_shared_groups.py | 22 ++ .../prompt_studio_core_v2/models.py | 7 + .../prompt_studio_core_v2/serializers.py | 18 +- .../prompt_studio_core_v2/views.py | 11 + backend/sample.env | 8 + backend/tenant_account_v2/admin.py | 12 +- backend/tenant_account_v2/apps.py | 3 + .../tenant_account_v2/group_serializers.py | 220 +++++++++++++ backend/tenant_account_v2/group_views.py | 288 ++++++++++++++++++ backend/tenant_account_v2/groups_urls.py | 8 + ...002_organization_group_group_membership.py | 127 ++++++++ backend/tenant_account_v2/models.py | 88 +++++- backend/tenant_account_v2/sharing_helpers.py | 142 +++++++++ backend/tenant_account_v2/signals.py | 31 ++ backend/tenant_account_v2/urls.py | 3 +- backend/tenant_account_v2/views.py | 72 ++++- .../migrations/0020_add_shared_groups.py | 22 ++ .../workflow_v2/models/workflow.py | 8 + .../workflow_v2/serializers.py | 26 +- backend/workflow_manager/workflow_v2/views.py | 14 + .../list-of-tools/ListOfTools.jsx | 16 +- .../api-deployment/ApiDeployment.jsx | 5 + .../api-deployment/api-deployments-service.js | 3 +- .../groups/GroupCreateEditModal.jsx | 90 ++++++ .../components/groups/GroupMemberManager.jsx | 174 +++++++++++ frontend/src/components/groups/Groups.css | 11 + frontend/src/components/groups/Groups.jsx | 247 +++++++++++++++ .../src/components/groups/groups-service.js | 91 ++++++ .../navigations/side-nav-bar/SideNavBar.jsx | 32 ++ .../pipeline-service.js | 8 +- .../pipelines/Pipelines.jsx | 5 + .../tool-settings/ToolSettings.jsx | 16 +- .../share-permission/SharePermission.jsx | 152 ++++++--- .../workflows/workflow/Workflows.jsx | 31 +- .../workflows/workflow/workflow-service.js | 3 +- frontend/src/hooks/useShareModal.js | 40 ++- frontend/src/pages/ConnectorsPage.jsx | 21 +- frontend/src/pages/GroupsPage.jsx | 7 + frontend/src/pages/IdpGroupImportPage.jsx | 22 ++ frontend/src/routes/useMainAppRoutes.js | 4 + 61 files changed, 2320 insertions(+), 87 deletions(-) create mode 100644 backend/account_v2/migrations/0005_organization_idp_group_allowlist.py create mode 100644 backend/adapter_processor_v2/migrations/0004_add_shared_groups.py create mode 100644 backend/api_v2/migrations/0004_add_shared_groups.py create mode 100644 backend/connector_v2/migrations/0006_add_shared_groups.py create mode 100644 backend/pipeline_v2/migrations/0004_add_shared_groups.py create mode 100644 backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py create mode 100644 backend/tenant_account_v2/group_serializers.py create mode 100644 backend/tenant_account_v2/group_views.py create mode 100644 backend/tenant_account_v2/groups_urls.py create mode 100644 backend/tenant_account_v2/migrations/0002_organization_group_group_membership.py create mode 100644 backend/tenant_account_v2/sharing_helpers.py create mode 100644 backend/tenant_account_v2/signals.py create mode 100644 backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py create mode 100644 frontend/src/components/groups/GroupCreateEditModal.jsx create mode 100644 frontend/src/components/groups/GroupMemberManager.jsx create mode 100644 frontend/src/components/groups/Groups.css create mode 100644 frontend/src/components/groups/Groups.jsx create mode 100644 frontend/src/components/groups/groups-service.js create mode 100644 frontend/src/pages/GroupsPage.jsx create mode 100644 frontend/src/pages/IdpGroupImportPage.jsx diff --git a/backend/account_v2/migrations/0005_organization_idp_group_allowlist.py b/backend/account_v2/migrations/0005_organization_idp_group_allowlist.py new file mode 100644 index 0000000000..5f84dddd37 --- /dev/null +++ b/backend/account_v2/migrations/0005_organization_idp_group_allowlist.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account_v2", "0004_user_is_service_account"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="idp_group_allowlist", + field=models.JSONField( + blank=True, + default=list, + help_text="Allowed external_id values or fnmatch glob patterns for IdP group import. Matching is case-insensitive.", + ), + ), + ] diff --git a/backend/account_v2/models.py b/backend/account_v2/models.py index 5a3250bdd8..6c38d47073 100644 --- a/backend/account_v2/models.py +++ b/backend/account_v2/models.py @@ -39,6 +39,14 @@ class Organization(models.Model): default=-1, db_comment="token limit set in case of frition less onbaoarded org", ) + idp_group_allowlist = models.JSONField( + default=list, + blank=True, + help_text=( + "Allowed external_id values or fnmatch glob patterns for IdP " + "group import. Matching is case-insensitive." + ), + ) class Meta: verbose_name = "Organization" diff --git a/backend/adapter_processor_v2/migrations/0004_add_shared_groups.py b/backend/adapter_processor_v2/migrations/0004_add_shared_groups.py new file mode 100644 index 0000000000..545343a29a --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0004_add_shared_groups.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenant_account_v2", "0002_organization_group_group_membership"), + ("adapter_processor_v2", "0003_mark_deprecated_adapters"), + ] + + operations = [ + migrations.AddField( + model_name="adapterinstance", + name="shared_groups", + field=models.ManyToManyField( + blank=True, + related_name="shared_adapter_instances", + to="tenant_account_v2.organizationgroup", + ), + ), + ] diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index a6fab0c1f9..60d087625c 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -37,6 +37,7 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.all() + user_groups = user.group_memberships.values_list("group_id", flat=True) return ( self.get_queryset() .filter( @@ -44,6 +45,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(shared_groups__in=user_groups) ) .distinct("id") ) @@ -134,6 +136,11 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel): # Introduced field to establish M2M relation between users and adapters. # This will introduce intermediary table which relates both the models. shared_users = models.ManyToManyField(User, related_name="shared_adapters_instance") + shared_groups = models.ManyToManyField( + "tenant_account_v2.OrganizationGroup", + related_name="shared_adapter_instances", + blank=True, + ) description = models.TextField(blank=True, null=True, default=None) objects = AdapterInstanceModelManager() diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 627731ed84..1f5e0470c9 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -6,7 +6,12 @@ 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, + validate_shared_groups_in_org, +) from utils.input_sanitizer import validate_name_field, validate_no_html_tags +from utils.user_context import UserContext from adapter_processor_v2.adapter_processor import AdapterProcessor from adapter_processor_v2.constants import AdapterKeys @@ -43,6 +48,12 @@ def validate(self, data): ) return data + def validate_shared_groups(self, value): + organization = UserContext.get_organization() + if organization is None: + return value + return validate_shared_groups_in_org(value, organization) + class DefaultAdapterSerializer(serializers.Serializer): llm_default = serializers.CharField(max_length=FLC.UUID_LENGTH, required=False) @@ -205,6 +216,7 @@ class SharedUserListSerializer(BaseAdapterSerializer): """ shared_users = serializers.SerializerMethodField() + shared_groups = serializers.SerializerMethodField() created_by = UserSerializer() class Meta(BaseAdapterSerializer.Meta): @@ -217,6 +229,7 @@ class Meta(BaseAdapterSerializer.Meta): "created_by", "shared_users", "shared_to_org", + "shared_groups", ) # type: ignore def get_shared_users(self, obj): @@ -224,6 +237,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/views.py b/backend/adapter_processor_v2/views.py index 3e59d12594..272feba2d8 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -344,6 +344,7 @@ def partial_update( # Perform the update response = super().partial_update(request, *args, **kwargs) + # TODO: notify group members when shared_groups changes (Phase 2) # Send email notifications to newly shared users if response.status_code == 200 and AdapterKeys.SHARED_USERS in request.data: try: @@ -388,6 +389,16 @@ def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response return Response(serialized_instances) + @action(detail=True, methods=["get"], url_path="effective-members") + def effective_members(self, request: HttpRequest, pk: Any = None) -> Response: + """Return all users with access (direct/group/org), priority-deduped.""" + from tenant_account_v2.group_serializers import EffectiveMemberSerializer + from tenant_account_v2.sharing_helpers import compute_effective_members + + adapter = self.get_object() + members = compute_effective_members(adapter) + return Response(EffectiveMemberSerializer(members, many=True).data) + def update( self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any] ) -> Response: diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index e636ca01ec..02ed54de6a 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -369,6 +369,16 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo serializer = SharedUserListSerializer(instance) return Response(serializer.data) + @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.""" + from tenant_account_v2.group_serializers import EffectiveMemberSerializer + from tenant_account_v2.sharing_helpers import compute_effective_members + + instance = self.get_object() + members = compute_effective_members(instance) + return Response(EffectiveMemberSerializer(members, many=True).data) + def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Override partial_update to handle sharing notifications.""" # Get current instance and shared users @@ -378,6 +388,7 @@ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Respons # Perform the update response = super().partial_update(request, *args, **kwargs) + # TODO: notify group members when shared_groups changes (Phase 2) # If successful and shared_users changed, send notifications if ( response.status_code == 200 diff --git a/backend/api_v2/migrations/0004_add_shared_groups.py b/backend/api_v2/migrations/0004_add_shared_groups.py new file mode 100644 index 0000000000..4337a8a691 --- /dev/null +++ b/backend/api_v2/migrations/0004_add_shared_groups.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenant_account_v2", "0002_organization_group_group_membership"), + ("api_v2", "0003_add_organization_rate_limit"), + ] + + operations = [ + migrations.AddField( + model_name="apideployment", + name="shared_groups", + field=models.ManyToManyField( + blank=True, + related_name="shared_api_deployments", + to="tenant_account_v2.organizationgroup", + ), + ), + ] diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index cc19902bde..e8bde4d663 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -30,6 +30,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 see all org resources """ if getattr(user, "is_service_account", False): @@ -37,10 +38,12 @@ def for_user(self, user): from django.db.models import Q + user_groups = user.group_memberships.values_list("group_id", flat=True) 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(shared_groups__in=user_groups) # Shared via group membership ).distinct() @@ -104,6 +107,11 @@ class APIDeployment(DefaultOrganizationMixin, BaseModel): default=False, db_comment="Whether this API deployment is shared with the entire organization", ) + shared_groups = models.ManyToManyField( + "tenant_account_v2.OrganizationGroup", + related_name="shared_api_deployments", + blank=True, + ) # Manager objects = APIDeploymentModelManager() diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 7c9a5a7696..f7887592be 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -22,8 +22,13 @@ ValidationError, ) from tags.serializers import TagParamsSerializer +from tenant_account_v2.sharing_helpers import ( + serialize_group_refs, + validate_shared_groups_in_org, +) from utils.input_sanitizer import validate_name_field, validate_no_html_tags from utils.serializer.integrity_error_mixin import IntegrityErrorMixin +from utils.user_context import UserContext from workflow_manager.endpoint_v2.models import WorkflowEndpoint from workflow_manager.workflow_v2.exceptions import ExecutionDoesNotExistError from workflow_manager.workflow_v2.models.execution import WorkflowExecution @@ -71,6 +76,12 @@ def validate_description(self, value: str) -> str: return value return validate_no_html_tags(value, field_name="Description") + def validate_shared_groups(self, value): + organization = UserContext.get_organization() + if organization is None: + return value + return validate_shared_groups_in_org(value, organization) + def validate_workflow(self, workflow): """Validate that the workflow has properly configured source and destination endpoints.""" # Get all endpoints for this workflow with related data @@ -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/backend/settings/base.py b/backend/backend/settings/base.py index a77b44adaf..9f10abe2cf 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -213,6 +213,11 @@ 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)) +IDP_GROUP_SYNC_INTERVAL_MIN = int(os.environ.get("IDP_GROUP_SYNC_INTERVAL_MIN", 30)) + CELERY_RESULT_CHORD_RETRY_INTERVAL = float( os.environ.get("CELERY_RESULT_CHORD_RETRY_INTERVAL", "3") ) diff --git a/backend/connector_v2/migrations/0006_add_shared_groups.py b/backend/connector_v2/migrations/0006_add_shared_groups.py new file mode 100644 index 0000000000..4c9d51117f --- /dev/null +++ b/backend/connector_v2/migrations/0006_add_shared_groups.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenant_account_v2", "0002_organization_group_group_membership"), + ("connector_v2", "0005_fix_unintended_connector_sharing"), + ] + + operations = [ + migrations.AddField( + model_name="connectorinstance", + name="shared_groups", + field=models.ManyToManyField( + blank=True, + related_name="shared_connector_instances", + to="tenant_account_v2.organizationgroup", + ), + ), + ] diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index 73ea38b57c..15d205efcb 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -30,12 +30,14 @@ def for_user(self, user: User) -> models.QuerySet: if getattr(user, "is_service_account", False): return self.all() + user_groups = user.group_memberships.values_list("group_id", flat=True) return ( self.get_queryset() .filter( models.Q(created_by=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) + | models.Q(shared_groups__in=user_groups) ) .distinct("id") ) @@ -100,6 +102,11 @@ class ConnectorMode(models.TextChoices): shared_users = models.ManyToManyField( User, related_name="shared_connectors", blank=True ) + shared_groups = models.ManyToManyField( + "tenant_account_v2.OrganizationGroup", + related_name="shared_connector_instances", + blank=True, + ) objects = ConnectorInstanceModelManager() diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index d639c923a1..4eb93d78a5 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -9,8 +9,10 @@ from connector_processor.constants import ConnectorKeys from connector_processor.exceptions import InvalidConnectorID, OAuthTimeOut from rest_framework.serializers import CharField, SerializerMethodField, ValidationError +from tenant_account_v2.sharing_helpers import validate_shared_groups_in_org from utils.fields import EncryptedBinaryFieldSerializer from utils.input_sanitizer import validate_name_field +from utils.user_context import UserContext from backend.serializers import AuditSerializer from connector_v2.constants import ConnectorInstanceKey as CIKey @@ -34,6 +36,12 @@ class Meta: def validate_connector_name(self, value: str) -> str: return validate_name_field(value, field_name="Connector name") + def validate_shared_groups(self, value): + organization = UserContext.get_organization() + if organization is None: + return value + return validate_shared_groups_in_org(value, organization) + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: """Backfill ``connector_name`` from the JSON schema default when absent. diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index 37525e4d5a..8d6dcc10f2 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -11,6 +11,7 @@ from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from plugins import get_plugin from rest_framework import status, viewsets +from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning @@ -208,6 +209,7 @@ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Respons response = super().partial_update(request, *args, **kwargs) + # TODO: notify group members when shared_groups changes (Phase 2) if ( response.status_code == 200 and "shared_users" in request.data @@ -241,3 +243,13 @@ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Respons ) return response + + @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.""" + from tenant_account_v2.group_serializers import EffectiveMemberSerializer + from tenant_account_v2.sharing_helpers import compute_effective_members + + connector = self.get_object() + members = compute_effective_members(connector) + return Response(EffectiveMemberSerializer(members, many=True).data) diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 19388e6c30..157e2ba909 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -21,6 +21,19 @@ 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. + + Returns False for objects that don't carry a ``shared_groups`` field + (e.g. resources whose model hasn't been extended yet), so callers can + OR this in safely without per-model guards. + """ + if not hasattr(obj, "shared_groups"): + return False + user_groups = user.group_memberships.values_list("group_id", flat=True) + return bool(obj.shared_groups.filter(id__in=user_groups).exists()) + + class IsOwner(permissions.BasePermission): """Custom permission to only allow owners of an object.""" @@ -45,12 +58,9 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - True - if ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() - ) - else False + obj.created_by == request.user + or obj.shared_users.filter(pk=request.user.pk).exists() + or _has_group_access(request.user, obj) ) @@ -63,13 +73,10 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - True - if ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() - or obj.shared_to_org - ) - else False + 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) ) diff --git a/backend/pipeline_v2/migrations/0004_add_shared_groups.py b/backend/pipeline_v2/migrations/0004_add_shared_groups.py new file mode 100644 index 0000000000..33fef912bd --- /dev/null +++ b/backend/pipeline_v2/migrations/0004_add_shared_groups.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenant_account_v2", "0002_organization_group_group_membership"), + ("pipeline_v2", "0003_add_sharing_fields_to_pipeline"), + ] + + operations = [ + migrations.AddField( + model_name="pipeline", + name="shared_groups", + field=models.ManyToManyField( + blank=True, + related_name="shared_pipelines", + to="tenant_account_v2.organizationgroup", + ), + ), + ] diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 65fb5257a8..0fb4263593 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -24,15 +24,18 @@ 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 see all org resources """ if getattr(user, "is_service_account", False): return self.all() + user_groups = user.group_memberships.values_list("group_id", flat=True) 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(shared_groups__in=user_groups) # Shared via group membership ).distinct() @@ -117,6 +120,11 @@ class PipelineStatus(models.TextChoices): default=False, db_comment="Whether this pipeline is shared with the entire organization", ) + shared_groups = models.ManyToManyField( + "tenant_account_v2.OrganizationGroup", + related_name="shared_pipelines", + blank=True, + ) # Manager objects = PipelineModelManager() diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index f887042b9a..9a50bc23fb 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -13,8 +13,10 @@ from rest_framework import serializers from rest_framework.serializers import SerializerMethodField from scheduler.helper import SchedulerHelper +from tenant_account_v2.sharing_helpers import validate_shared_groups_in_org from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from utils.serializer_utils import SerializerUtils +from utils.user_context import UserContext from workflow_manager.endpoint_v2.models import WorkflowEndpoint from workflow_manager.workflow_v2.models.execution import WorkflowExecution @@ -154,6 +156,12 @@ def _validate_minute_field(self, minute_field: str) -> None: elif "-" in minute_field: self._validate_range_pattern(minute_field) + def validate_shared_groups(self, value): + organization = UserContext.get_organization() + if organization is None: + return value + return validate_shared_groups_in_org(value, organization) + def validate_cron_string(self, value: str | None = None) -> str | None: """Validate the cron string provided in the serializer data. 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/views.py b/backend/pipeline_v2/views.py index 0d902a792c..3a88d1545a 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -140,6 +140,19 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo serializer = SharedUserListSerializer(pipeline) return Response(serializer.data, status=status.HTTP_200_OK) + @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.""" + from tenant_account_v2.group_serializers import EffectiveMemberSerializer + from tenant_account_v2.sharing_helpers import compute_effective_members + + pipeline = self.get_object() + members = compute_effective_members(pipeline) + return Response( + EffectiveMemberSerializer(members, many=True).data, + status=status.HTTP_200_OK, + ) + def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Override to handle sharing notifications.""" instance = self.get_object() @@ -147,6 +160,7 @@ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Respons response = super().partial_update(request, *args, **kwargs) + # TODO: notify group members when shared_groups changes (Phase 2) if ( response.status_code == 200 and "shared_users" in request.data diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py new file mode 100644 index 0000000000..7a73b700e8 --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenant_account_v2", "0002_organization_group_group_membership"), + ("prompt_studio_core_v2", "0007_customtool_last_exported_at"), + ] + + operations = [ + migrations.AddField( + model_name="customtool", + name="shared_groups", + field=models.ManyToManyField( + blank=True, + related_name="shared_custom_tools", + to="tenant_account_v2.organizationgroup", + ), + ), + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index cd2a12dac8..84f00dfaff 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -26,12 +26,14 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.all() + user_groups = user.group_memberships.values_list("group_id", flat=True) return ( self.get_queryset() .filter( models.Q(created_by=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) + | models.Q(shared_groups__in=user_groups) ) .distinct("tool_id") ) @@ -160,6 +162,11 @@ class CustomTool(DefaultOrganizationMixin, BaseModel): default=False, db_comment="Flag to share this custom tool with all users in the organization", ) + shared_groups = models.ManyToManyField( + "tenant_account_v2.OrganizationGroup", + related_name="shared_custom_tools", + blank=True, + ) # 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..5a389aacc1 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -7,9 +7,14 @@ 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, + validate_shared_groups_in_org, +) 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 +from utils.user_context import UserContext from backend.serializers import AuditSerializer from prompt_studio.prompt_profile_manager_v2.models import ProfileManager @@ -99,6 +104,12 @@ class Meta: def validate_tool_name(self, value: str) -> str: return validate_name_field(value, field_name="Tool name") + def validate_shared_groups(self, value): + organization = UserContext.get_organization() + if organization is None: + return value + return validate_shared_groups_in_org(value, organization) + def validate_description(self, value: str) -> str: if value is None: return value @@ -224,10 +235,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 +249,7 @@ class Meta: "created_by", "shared_users", "shared_to_org", + "shared_groups", ) def get_shared_users(self, obj): @@ -244,6 +257,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/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 30ea7045e2..2d828e32e2 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -302,6 +302,7 @@ def partial_update( # Perform the update response = super().partial_update(request, *args, **kwargs) + # TODO: notify group members when shared_groups changes (Phase 2) # Send email notifications to newly shared users if response.status_code == 200 and "shared_users" in request.data: from plugins import get_plugin @@ -890,6 +891,16 @@ def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response return Response(serialized_instances) + @action(detail=True, methods=["get"], url_path="effective-members") + def effective_members(self, request: HttpRequest, pk: Any = None) -> Response: + """Return all users with access (direct/group/org), priority-deduped.""" + from tenant_account_v2.group_serializers import EffectiveMemberSerializer + from tenant_account_v2.sharing_helpers import compute_effective_members + + custom_tool = self.get_object() + members = compute_effective_members(custom_tool) + return Response(EffectiveMemberSerializer(members, many=True).data) + @action(detail=True, methods=["post"]) def create_prompt(self, request: HttpRequest, pk: Any = None) -> Response: context = super().get_serializer_context() diff --git a/backend/sample.env b/backend/sample.env index e1a54b955a..004fc8ddde 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -231,6 +231,14 @@ 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 (LOCAL + IDP combined) +MAX_GROUPS_PER_ORG=200 +# Max GroupMembership rows allowed per OrganizationGroup +MAX_MEMBERS_PER_GROUP=500 +# IdP group sync reconcile cadence (Celery beat, cloud-only) +IDP_GROUP_SYNC_INTERVAL_MIN=30 + # 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..643842d1db --- /dev/null +++ b/backend/tenant_account_v2/group_serializers.py @@ -0,0 +1,220 @@ +"""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, Q +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied, ValidationError + +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, + OrganizationMember, +) + +logger = logging.getLogger(__name__) + + +class OrganizationGroupReadSerializer(serializers.ModelSerializer): + """Read-side serializer. ``source`` is read-only (LOCAL vs IDP indicator).""" + + member_count = serializers.SerializerMethodField() + + class Meta: + model = OrganizationGroup + fields = ( + "id", + "name", + "description", + "created_by", + "source", + "is_managed_externally", + "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. SSO fields are write-locked from the public API.""" + + 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() + name = attrs.get("name") + + # 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})." + ), + } + ) + + # Block LOCAL create/rename that would collide with an IDP-managed row + # (the symmetric direction — IDP-side refusal — is enforced by the + # sync service in the cloud PR). + if name is not None: + collision = OrganizationGroup.objects.filter( + organization=organization, + name__iexact=name, + source=OrganizationGroup.SOURCE_IDP, + ) + if self.instance is not None: + collision = collision.exclude(pk=self.instance.pk) + if collision.exists(): + raise ValidationError( + { + "code": "GROUP_NAME_COLLIDES_WITH_IDP", + "detail": ( + "An IdP-managed group with this name already exists. " + "Choose a different name or remove the IdP allowlist entry." + ), + } + ) + return attrs + + def update( + self, instance: OrganizationGroup, validated_data: dict[str, Any] + ) -> OrganizationGroup: + # Externally-managed groups are owned by IdP sync; reject public writes. + if instance.is_managed_externally: + raise PermissionDenied( + "Group is managed externally (IdP sync) and cannot be edited." + ) + return super().update(instance, validated_data) # type: ignore[no-any-return] + + +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"] + if group.is_managed_externally: + raise PermissionDenied( + "Group is managed externally (IdP sync) and cannot be edited." + ) + + 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. + """ + qs = OrganizationGroup.objects.filter(organization=organization) + if user is not None: + qs = qs.filter(memberships__user=user) + return qs.annotate( + memberships__count=Count("memberships", filter=Q(memberships__isnull=False)) + ).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..6f13849a6e --- /dev/null +++ b/backend/tenant_account_v2/group_views.py @@ -0,0 +1,288 @@ +"""ViewSet + permissions for org-scoped group sharing (UN-2977 / mfbt UNS-612).""" + +import logging +from typing import Any + +from account_v2.authentication_controller import AuthenticationController +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 +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(request: Request) -> 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. + """ + if getattr(request.user, "is_service_account", False): + return False + try: + auth_controller = AuthenticationController() + member = auth_controller.get_organization_members_by_user(user=request.user) + return auth_controller.is_admin_by_role(member.role) + except Exception: + logger.exception("Error checking admin role for user %s", request.user.id) + return False + + +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(self.request) + return ctx + + def get_queryset(self) -> QuerySet[OrganizationGroup]: + organization = _current_organization(self.request) + 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(request) + 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." + ) + qs = list_groups_with_member_counts(organization=organization).filter( + memberships__user_id=member_filter + ) + 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(self.request) + serializer.save( + organization=organization, + created_by=self.request.user, + source=OrganizationGroup.SOURCE_LOCAL, + is_managed_externally=False, + ) + + def perform_destroy(self, instance: OrganizationGroup) -> None: + if instance.is_managed_externally: + raise PermissionDenied( + "Group is managed externally (IdP sync) and cannot be deleted." + ) + super().perform_destroy(instance) + + # --- 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(self.permission_classes[1].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, + ) + # TODO: notify added users (Phase 2) + 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(self.permission_classes[1].message) + group = self._get_group_or_404(pk) + if group.is_managed_externally: + raise PermissionDenied( + "Group is managed externally (IdP sync) and cannot be edited." + ) + deleted, _ = group.memberships.filter(user_id=user_id).delete() + if not deleted: + raise NotFound("User is not a member of this group.") + # TODO: notify removed user (Phase 2) + 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: + group = self._get_group_or_404(pk) + payload = _collect_resources_shared_with_group(group) + return Response(payload) + + # --- IDP conflicts ------------------------------------------------------- + + @action(detail=False, methods=["get"], url_path="conflicts") + def conflicts(self, request: Request) -> Response: + if not _is_org_admin(request): + raise PermissionDenied(self.permission_classes[1].message) + organization = _current_organization(request) + # ``IDPGroupConflict`` lives in the cloud-only ``pluggable_apps.idp_sync`` + # app. OSS-only deployments don't install it, so the conflicts endpoint + # returns an empty list there. + try: + from pluggable_apps.idp_sync.models import IDPGroupConflict + except (ImportError, ModuleNotFoundError): + return Response([]) + rows = IDPGroupConflict.objects.filter(organization=organization) + return Response(_serialize_conflicts(organization, rows)) + + # --- helpers ------------------------------------------------------------- + + def _get_group_or_404(self, pk: str | None) -> OrganizationGroup: + organization = _current_organization(self.request) + 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"), + ) + + results: list[dict[str, Any]] = [] + for kind, model, name_field, id_field in sources: + qs = model.objects.filter(shared_groups=group).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 + + +def _serialize_conflicts(organization: Any, conflict_rows: Any) -> list[dict[str, Any]]: + """Join each ``IDPGroupConflict`` row with its blocking LOCAL group. + + Returns the response shape mfbt UNS-612 §5.7 specifies: ``idp_claim``, + ``external_id``, ``blocking_group_id``, ``blocking_group_name``, + ``blocking_group_member_count``, ``blocking_group_shared_resource_count``. + """ + blocking_lookup = { + g.name.lower(): g + for g in OrganizationGroup.objects.filter( + organization=organization, + source=OrganizationGroup.SOURCE_LOCAL, + ) + } + payload: list[dict[str, Any]] = [] + for conflict in conflict_rows: + blocker = blocking_lookup.get((conflict.idp_claim or "").lower()) + entry: dict[str, Any] = { + "idp_claim": conflict.idp_claim, + "external_id": conflict.external_id, + "detected_at": ( + conflict.detected_at.isoformat() if conflict.detected_at else None + ), + "blocking_group_id": None, + "blocking_group_name": None, + "blocking_group_member_count": 0, + "blocking_group_shared_resource_count": 0, + } + if blocker is not None: + entry["blocking_group_id"] = blocker.id + entry["blocking_group_name"] = blocker.name + entry["blocking_group_member_count"] = blocker.memberships.count() + entry["blocking_group_shared_resource_count"] = len( + _collect_resources_shared_with_group(blocker) + ) + payload.append(entry) + return payload 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..6e3ff77f8d --- /dev/null +++ b/backend/tenant_account_v2/migrations/0002_organization_group_group_membership.py @@ -0,0 +1,127 @@ +# 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)), + ( + "external_id", + models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), + ), + ( + "source", + models.CharField( + choices=[("LOCAL", "Local"), ("IDP", "IDP")], + default="LOCAL", + max_length=10, + ), + ), + ("is_managed_externally", models.BooleanField(default=False)), + ( + "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/models.py b/backend/tenant_account_v2/models.py index e5fdacda66..7f48d8f8ba 100644 --- a/backend/tenant_account_v2/models.py +++ b/backend/tenant_account_v2/models.py @@ -1,5 +1,6 @@ -from account_v2.models import User +from account_v2.models import Organization, User from django.db import models +from utils.models.base_model import BaseModel from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, DefaultOrganizationMixin, @@ -46,3 +47,88 @@ 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 — but services and signals that + touch this model from non-request contexts (e.g. IdP sync) cannot + depend on UserContext. + + SSO forward-compat fields (`external_id`, `source`, `is_managed_externally`) + are reserved for IdP sync (Phase 2) and write-locked from the public API. + """ + + SOURCE_LOCAL = "LOCAL" + SOURCE_IDP = "IDP" + SOURCE_CHOICES = [ + (SOURCE_LOCAL, "Local"), + (SOURCE_IDP, "IDP"), + ] + + 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", + ) + external_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) + source = models.CharField(max_length=10, choices=SOURCE_CHOICES, default=SOURCE_LOCAL) + is_managed_externally = models.BooleanField(default=False) + + def __str__(self): # type: ignore + return f"OrganizationGroup({self.id}, {self.name}, {self.source})" + + 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 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..7fb87256a0 --- /dev/null +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -0,0 +1,142 @@ +"""Shared helpers for group-based resource sharing. + +Centralizes the per-resource hooks so each shareable viewset and serializer +plugs into the same logic. + +* ``validate_shared_groups_in_org`` — serializer-level org scope check on + the ``shared_groups`` M2M payload. +* ``compute_effective_members`` — union-with-priority dedup feeding the + ``effective-members/`` resource action. +* ``serialize_group_refs`` — small ``[{id, name}]`` listing for the + ``users/`` sharing-info endpoints, so the share modal can render the + currently-shared groups. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from typing import Any + +from account_v2.models import Organization, User +from rest_framework.exceptions import ValidationError + +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, + OrganizationMember, +) + +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 " + f"(foreign group ids: {[g.id for g in foreign]})." + ) + } + ) + return groups + + +def serialize_group_refs(resource_obj: Any) -> list[dict[str, Any]]: + """Return a compact ``[{id, name, source}]`` listing for share modals.""" + return list(resource_obj.shared_groups.values("id", "name", "source")) + + +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 — collect via the resource's shared_groups M2M + group_memberships = GroupMembership.objects.filter( + group__in=resource_obj.shared_groups.all(), + ).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 + if getattr(resource_obj, "shared_to_org", False): + organization = getattr(resource_obj, "organization", None) + if organization is not None: + 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, + } + + return list(seen.values()) + + +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 diff --git a/backend/tenant_account_v2/signals.py b/backend/tenant_account_v2/signals.py new file mode 100644 index 0000000000..571a83be32 --- /dev/null +++ b/backend/tenant_account_v2/signals.py @@ -0,0 +1,31 @@ +import logging + +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__) + + +@receiver(post_delete, sender=OrganizationMember) +def remove_user_from_org_groups( + sender: type, instance: OrganizationMember, **kwargs: object +) -> None: + """Cascade group membership removal when a user leaves an organization. + + Uses a signal (not DB CASCADE) so notification / audit hooks can attach + here later without a schema change. + """ + 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, + ) + # TODO: notify affected resource owners of access change (Phase 2) 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..a445064325 100644 --- a/backend/tenant_account_v2/views.py +++ b/backend/tenant_account_v2/views.py @@ -52,8 +52,10 @@ def reset_password(request: Request) -> Response: ) -@api_view(["GET"]) +@api_view(["GET", "PATCH"]) def get_organization(request: Request) -> Response: + if request.method == "PATCH": + return _patch_organization(request) auth_controller = AuthenticationController() try: organization_id = UserSessionUtils.get_organization_id(request) @@ -64,19 +66,85 @@ def get_organization(request: Request) -> Response: data={"message": "Org Not Found"}, ) response = makeSignupResponse(org_data) + response["idp_group_allowlist"] = list( + getattr(org_data, "idp_group_allowlist", None) or [] + ) return Response( status=status.HTTP_201_CREATED, data={"message": "success", "organization": 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"}, ) +def _patch_organization(request: Request) -> Response: + """Org-admin-only updates to the current Organization row. + + Phase 1 only writes ``idp_group_allowlist``; other fields are out of scope. + """ + from platform_api.permissions import IsOrganizationAdmin + + if not IsOrganizationAdmin().has_permission(request, None): + return Response( + status=status.HTTP_403_FORBIDDEN, + data={"message": "Only organization admins can update this resource."}, + ) + allowlist = request.data.get("idp_group_allowlist") + if allowlist is None or not isinstance(allowlist, list): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"message": "idp_group_allowlist must be a list of strings."}, + ) + cleaned = _validate_allowlist(allowlist) + if isinstance(cleaned, Response): + return cleaned + + organization_id = UserSessionUtils.get_organization_id(request) + try: + organization = Organization.objects.get(organization_id=organization_id) + except Organization.DoesNotExist: + return Response( + status=status.HTTP_404_NOT_FOUND, + data={"message": "Org Not Found"}, + ) + organization.idp_group_allowlist = cleaned + organization.save(update_fields=["idp_group_allowlist", "modified_at"]) + return Response( + status=status.HTTP_200_OK, + data={"message": "success", "idp_group_allowlist": cleaned}, + ) + + +def _validate_allowlist(allowlist: list[Any]) -> list[str] | Response: + cleaned: list[str] = [] + for entry in allowlist: + if not isinstance(entry, str): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"message": "idp_group_allowlist entries must be strings."}, + ) + trimmed = entry.strip() + if not trimmed: + continue + if len(trimmed) > 256: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "message": ( + "idp_group_allowlist entries must be 256 characters or fewer." + ) + }, + ) + cleaned.append(trimmed) + # Dedupe while preserving order. + return list(dict.fromkeys(cleaned)) + + def makeSignupResponse( organization: Organization, ) -> Any: diff --git a/backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py b/backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py new file mode 100644 index 0000000000..4c22cc799f --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2026-05-22 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenant_account_v2", "0002_organization_group_group_membership"), + ("workflow_v2", "0019_remove_filehistory_trigram_index"), + ] + + operations = [ + migrations.AddField( + model_name="workflow", + name="shared_groups", + field=models.ManyToManyField( + blank=True, + related_name="shared_workflows", + to="tenant_account_v2.organizationgroup", + ), + ), + ] diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index 0029f95997..8a8311cb93 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -21,6 +21,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 see all org resources """ if getattr(user, "is_service_account", False): @@ -28,10 +29,12 @@ def for_user(self, user): from django.db.models import Q + user_groups = user.group_memberships.values_list("group_id", flat=True) 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(shared_groups__in=user_groups) # Shared via group membership ).distinct() @@ -103,6 +106,11 @@ class ExecutionAction(models.TextChoices): default=False, db_comment="Whether this workflow is shared with the entire organization", ) + shared_groups = models.ManyToManyField( + "tenant_account_v2.OrganizationGroup", + related_name="shared_workflows", + blank=True, + ) # Manager objects = WorkflowModelManager() diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index ed34592958..b6739e83f1 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -12,10 +12,15 @@ UUIDField, ValidationError, ) +from tenant_account_v2.sharing_helpers import ( + serialize_group_refs, + validate_shared_groups_in_org, +) 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 from utils.serializer.integrity_error_mixin import IntegrityErrorMixin +from utils.user_context import UserContext from backend.constants import RequestKey from backend.serializers import AuditSerializer @@ -55,6 +60,12 @@ def validate_description(self, value: str) -> str: return value return validate_no_html_tags(value, field_name="Description") + def validate_shared_groups(self, value): + organization = UserContext.get_organization() + if organization is None: + return value + return validate_shared_groups_in_org(value, organization) + def to_representation(self, instance: Workflow) -> dict[str, str]: representation: dict[str, str] = super().to_representation(instance) representation[WorkflowKey.WF_NAME] = instance.workflow_name @@ -171,14 +182,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 +206,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/views.py b/backend/workflow_manager/workflow_v2/views.py index 629b52bb22..5816010d4c 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -147,6 +147,7 @@ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Respons # Perform the standard partial update response = super().partial_update(request, *args, **kwargs) + # TODO: notify group members when shared_groups changes (Phase 2) # If update was successful and shared_users field was modified if ( response.status_code == 200 @@ -359,6 +360,19 @@ def list_of_shared_users(self, request: Request, pk: str) -> Response: serializer = SharedUserListSerializer(workflow) return Response(serializer.data, status=status.HTTP_200_OK) + @action(detail=True, methods=["get"], url_path="effective-members") + def effective_members(self, request: Request, pk: str) -> Response: + """Return all users with access (direct/group/org), priority-deduped.""" + from tenant_account_v2.group_serializers import EffectiveMemberSerializer + from tenant_account_v2.sharing_helpers import compute_effective_members + + workflow = self.get_object() + members = compute_effective_members(workflow) + return Response( + EffectiveMemberSerializer(members, many=True).data, + status=status.HTTP_200_OK, + ) + # ============================================================================= # INTERNAL API VIEWS - Used by Celery workers for service-to-service communication 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..cb6ea2e97a 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,15 @@ 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, source: g.source })), + ); + }) + .catch(() => setAllGroupList([])); axiosPrivate(requestOptions) .then((res) => { setOpenSharePermissionModal(true); @@ -319,7 +331,7 @@ 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}/`, @@ -329,6 +341,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { data: { shared_users: userIds, shared_to_org: shareWithEveryone || false, + shared_groups: groupIds, }, }; axiosPrivate(requestOptions) @@ -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..572139efae 100644 --- a/frontend/src/components/deployments/api-deployment/api-deployments-service.js +++ b/frontend/src/components/deployments/api-deployment/api-deployments-service.js @@ -111,7 +111,7 @@ function apiDeploymentsService() { }; return axiosPrivate(options); }, - updateSharing: (id, sharedUsers, shareWithEveryone) => { + updateSharing: (id, sharedUsers, shareWithEveryone, sharedGroups = []) => { options = { method: "PATCH", url: `${path}/api/deployment/${id}/`, @@ -119,6 +119,7 @@ function apiDeploymentsService() { 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..be7ba17a80 --- /dev/null +++ b/frontend/src/components/groups/GroupCreateEditModal.jsx @@ -0,0 +1,90 @@ +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 () => { + try { + const values = await form.validateFields(); + 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)); + } catch (_validationError) { + // form validation error — Ant Design surfaces it inline + } + }; + + 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..5e23cceb55 --- /dev/null +++ b/frontend/src/components/groups/GroupMemberManager.jsx @@ -0,0 +1,174 @@ +import { DeleteOutlined, QuestionCircleOutlined } from "@ant-design/icons"; +import { Avatar, List, Modal, Popconfirm, Select, Typography } 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 isExternallyManaged = !!group?.is_managed_externally; + + 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) && !pendingAddIds.includes(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 ? ( + + ) : ( + <> + {isExternallyManaged ? ( + + This group is managed externally (IdP sync). Membership cannot be + edited from the UI. + + ) : ( + { - 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} - - ); - })} - + <> + { + if (!selectedGroupIds.includes(groupId)) { + setSelectedGroupIds([...selectedGroupIds, groupId]); + } + }} + options={groupCandidateOptions} + /> + )} + )} Shared with {sharedWithContent} @@ -216,6 +273,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/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx index 8716c88772..a19956958c 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,15 @@ 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, + source: g.source, + })) + : [], + ); setSelectedWorkflow(sharedUsersResponse.data); setShareOpen(true); } catch (err) { @@ -254,13 +268,19 @@ 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({ @@ -376,6 +396,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..aa2ccb8852 100644 --- a/frontend/src/components/workflows/workflow/workflow-service.js +++ b/frontend/src/components/workflows/workflow/workflow-service.js @@ -124,7 +124,7 @@ function workflowService() { }; return axiosPrivate(options); }, - updateSharing: (id, sharedUsers, shareWithEveryone) => { + updateSharing: (id, sharedUsers, shareWithEveryone, sharedGroups = []) => { options = { url: `${path}/workflow/${id}/`, method: "PATCH", @@ -134,6 +134,7 @@ function workflowService() { 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..ec8c3b353f 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,27 +67,45 @@ 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, + source: g.source, + })), + ); 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", @@ -97,6 +126,7 @@ function useShareModal({ openShareModal, setOpenShareModal, allUsers, + allGroups, isLoadingShare, handleShare, onShare, diff --git a/frontend/src/pages/ConnectorsPage.jsx b/frontend/src/pages/ConnectorsPage.jsx index 0174111077..3ab8056504 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,14 +100,29 @@ 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, source: g.source })), + ); + }) + .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( @@ -193,6 +211,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/pages/IdpGroupImportPage.jsx b/frontend/src/pages/IdpGroupImportPage.jsx new file mode 100644 index 0000000000..23b79e6efe --- /dev/null +++ b/frontend/src/pages/IdpGroupImportPage.jsx @@ -0,0 +1,22 @@ +import { lazy, Suspense } from "react"; + +import { SpinnerLoader } from "../components/widgets/spinner-loader/SpinnerLoader.jsx"; + +// Cloud-only plugin. OSS-only deployments don't have the file; the route is +// also gated on a successful dynamic-import probe in SideNavBar.jsx so this +// component should never be reached without the cloud build in place. +const IdpGroupImport = lazy(() => + import("../plugins/idp-group-import/IdpGroupImport.jsx").then((mod) => ({ + default: mod.IdpGroupImport, + })), +); + +function IdpGroupImportPage() { + return ( + }> + + + ); +} + +export { IdpGroupImportPage }; diff --git a/frontend/src/routes/useMainAppRoutes.js b/frontend/src/routes/useMainAppRoutes.js index 6a8769c455..f02dba0e01 100644 --- a/frontend/src/routes/useMainAppRoutes.js +++ b/frontend/src/routes/useMainAppRoutes.js @@ -11,6 +11,8 @@ 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 { IdpGroupImportPage } from "../pages/IdpGroupImportPage.jsx"; import { InviteEditUserPage } from "../pages/InviteEditUserPage.jsx"; import { LogsPage } from "../pages/LogsPage.jsx"; import { MetricsDashboardPage } from "../pages/MetricsDashboardPage.jsx"; @@ -253,6 +255,8 @@ function useMainAppRoutes() { } /> } /> } /> + } /> + } /> } From e7e407427ff85c9be93bca75c83c29ae3154051b Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Sat, 23 May 2026 16:31:58 +0530 Subject: [PATCH 02/14] fix migration M2M. Agenctic Prompt Studio align with other resources --- .../migrations/0004_add_shared_groups.py | 22 -- backend/adapter_processor_v2/models.py | 21 +- backend/adapter_processor_v2/serializers.py | 12 +- backend/adapter_processor_v2/views.py | 177 ++++++++-------- backend/api_v2/api_deployment_views.py | 65 +++--- .../migrations/0004_add_shared_groups.py | 22 -- backend/api_v2/models.py | 20 +- backend/api_v2/serializers.py | 15 +- .../migrations/0006_add_shared_groups.py | 22 -- backend/connector_v2/models.py | 21 +- backend/connector_v2/serializers.py | 12 +- backend/connector_v2/views.py | 73 +++---- backend/permissions/permission.py | 21 +- backend/permissions/resource_share_views.py | 105 ++++++++++ .../migrations/0004_add_shared_groups.py | 22 -- backend/pipeline_v2/models.py | 24 ++- backend/pipeline_v2/serializers/crud.py | 13 +- backend/pipeline_v2/views.py | 94 ++++----- .../migrations/0008_add_shared_groups.py | 22 -- .../prompt_studio_core_v2/models.py | 21 +- .../prompt_studio_core_v2/serializers.py | 13 +- .../prompt_studio_core_v2/views.py | 83 ++++---- backend/tenant_account_v2/group_views.py | 6 +- .../migrations/0003_resource_group_share.py | 97 +++++++++ backend/tenant_account_v2/models.py | 42 ++++ .../share_serializer_mixin.py | 44 ++++ backend/tenant_account_v2/sharing_helpers.py | 111 +++++++++- .../migrations/0020_add_shared_groups.py | 22 -- .../workflow_v2/models/workflow.py | 20 +- .../workflow_v2/serializers.py | 14 +- backend/workflow_manager/workflow_v2/views.py | 86 +++----- frontend/package-lock.json | 194 +++++++++++++++--- .../components/groups/GroupMemberManager.jsx | 4 +- frontend/src/components/groups/Groups.jsx | 14 +- .../share-permission/SharePermission.css | 9 + .../share-permission/SharePermission.jsx | 64 +++--- 36 files changed, 1035 insertions(+), 592 deletions(-) delete mode 100644 backend/adapter_processor_v2/migrations/0004_add_shared_groups.py delete mode 100644 backend/api_v2/migrations/0004_add_shared_groups.py delete mode 100644 backend/connector_v2/migrations/0006_add_shared_groups.py create mode 100644 backend/permissions/resource_share_views.py delete mode 100644 backend/pipeline_v2/migrations/0004_add_shared_groups.py delete mode 100644 backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py create mode 100644 backend/tenant_account_v2/migrations/0003_resource_group_share.py create mode 100644 backend/tenant_account_v2/share_serializer_mixin.py delete mode 100644 backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py diff --git a/backend/adapter_processor_v2/migrations/0004_add_shared_groups.py b/backend/adapter_processor_v2/migrations/0004_add_shared_groups.py deleted file mode 100644 index 545343a29a..0000000000 --- a/backend/adapter_processor_v2/migrations/0004_add_shared_groups.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2026-05-22 07:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tenant_account_v2", "0002_organization_group_group_membership"), - ("adapter_processor_v2", "0003_mark_deprecated_adapters"), - ] - - operations = [ - migrations.AddField( - model_name="adapterinstance", - name="shared_groups", - field=models.ManyToManyField( - blank=True, - related_name="shared_adapter_instances", - to="tenant_account_v2.organizationgroup", - ), - ), - ] diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index 60d087625c..6778289c6a 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -37,7 +37,10 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.all() - user_groups = user.group_memberships.values_list("group_id", flat=True) + 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( @@ -45,7 +48,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(shared_groups__in=user_groups) + | models.Q(pk__in=group_shared_ids) ) .distinct("id") ) @@ -136,13 +139,17 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel): # Introduced field to establish M2M relation between users and adapters. # This will introduce intermediary table which relates both the models. shared_users = models.ManyToManyField(User, related_name="shared_adapters_instance") - shared_groups = models.ManyToManyField( - "tenant_account_v2.OrganizationGroup", - related_name="shared_adapter_instances", - blank=True, - ) 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 1f5e0470c9..f2a9391e95 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -6,6 +6,8 @@ from django.conf import settings from rest_framework import serializers from rest_framework.serializers import ModelSerializer +from tenant_account_v2.models import OrganizationGroup +from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from tenant_account_v2.sharing_helpers import ( serialize_group_refs, validate_shared_groups_in_org, @@ -29,7 +31,15 @@ class TestAdapterSerializer(serializers.Serializer): adapter_type = serializers.JSONField() -class BaseAdapterSerializer(AuditSerializer): +class BaseAdapterSerializer(SharedGroupsSerializerMixin, AuditSerializer): + # ``shared_groups`` is no longer an M2M on AdapterInstance — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. + shared_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=OrganizationGroup.objects.all(), + required=False, + ) + class Meta: model = AdapterInstance fields = "__all__" diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 272feba2d8..2c37caddf9 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,7 +136,7 @@ def test(self, request: Request) -> Response: ) -class AdapterInstanceViewSet(ModelViewSet): +class AdapterInstanceViewSet(ResourceShareManagementMixin, ModelViewSet): serializer_class = AdapterInstanceSerializer def get_permissions(self) -> list[Any]: @@ -294,93 +295,99 @@ 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) - - # 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 - ) - ) - - 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 - - # Perform the update + # Adapter-specific: when a user loses access, clear their + # ``UserDefaultAdapter`` rows that pointed at this adapter so they + # don't keep a stale default. Must run BEFORE the M2M update. + self._clear_default_adapter_for_removed_users( + adapter, before["shared_users"], request.data + ) + response = super().partial_update(request, *args, **kwargs) - # TODO: notify group members when shared_groups changes (Phase 2) - # 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 - ) - - # 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, - ) - except Exception as e: - logger.exception(f"Failed to send sharing notification: {e}") + if response.status_code != 200 or not notification_plugin: + return response + + diffs = self.diff_share_axes(adapter, before, request.data) + # TODO: notify group members when shared_groups changes (UN-2977 follow-up) + users_diff = diffs.get("shared_users") + if not (users_diff and users_diff.added): + return response + + 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=request.user, + shared_to=list(users_diff.added), + resource_instance=adapter, + ) + except Exception as e: + logger.exception("Failed to send sharing notification: %s", e) return response + def _clear_default_adapter_for_removed_users( + self, + adapter: AdapterInstance, + current_shared_users: set[Any], + request_data: dict[str, Any], + ) -> None: + """Null out ``UserDefaultAdapter`` rows pointing at ``adapter`` for + users about to be unshared. + + Computed against ``request.data`` because this runs *before* the M2M + update lands; the post-update diff would be too late. + """ + requested_user_ids = { + int(user_id) for user_id in request_data.get("shared_users", []) + } + current_user_ids = {user.id for user in current_shared_users} + removed_user_ids = current_user_ids - requested_user_ids + + adapter_fields = ( + "default_llm_adapter", + "default_embedding_adapter", + "default_vector_db_adapter", + "default_x2text_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: + user_default_adapter.save() + @action(detail=True, methods=["get"]) def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response: adapter = self.get_object() @@ -389,16 +396,6 @@ def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response return Response(serialized_instances) - @action(detail=True, methods=["get"], url_path="effective-members") - def effective_members(self, request: HttpRequest, pk: Any = None) -> Response: - """Return all users with access (direct/group/org), priority-deduped.""" - from tenant_account_v2.group_serializers import EffectiveMemberSerializer - from tenant_account_v2.sharing_helpers import compute_effective_members - - adapter = self.get_object() - members = compute_effective_members(adapter) - return Response(EffectiveMemberSerializer(members, many=True).data) - def update( self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any] ) -> Response: diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 02ed54de6a..d47394844c 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]: @@ -369,50 +370,34 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo serializer = SharedUserListSerializer(instance) return Response(serializer.data) - @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.""" - from tenant_account_v2.group_serializers import EffectiveMemberSerializer - from tenant_account_v2.sharing_helpers import compute_effective_members - - instance = self.get_object() - members = compute_effective_members(instance) - return Response(EffectiveMemberSerializer(members, many=True).data) - 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) - # TODO: notify group members when shared_groups changes (Phase 2) - # 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 or not notification_plugin: + return response + + diffs = self.diff_share_axes(instance, before, request.data) + # TODO: notify group members when shared_groups changes (UN-2977 follow-up) + users_diff = diffs.get("shared_users") + if not (users_diff and users_diff.added): + return response + + 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=request.user, + shared_to=list(users_diff.added), + resource_instance=instance, + ) + except Exception as e: + logger.exception("Failed to send sharing notification: %s", e) return response diff --git a/backend/api_v2/migrations/0004_add_shared_groups.py b/backend/api_v2/migrations/0004_add_shared_groups.py deleted file mode 100644 index 4337a8a691..0000000000 --- a/backend/api_v2/migrations/0004_add_shared_groups.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2026-05-22 07:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tenant_account_v2", "0002_organization_group_group_membership"), - ("api_v2", "0003_add_organization_rate_limit"), - ] - - operations = [ - migrations.AddField( - model_name="apideployment", - name="shared_groups", - field=models.ManyToManyField( - blank=True, - related_name="shared_api_deployments", - to="tenant_account_v2.organizationgroup", - ), - ), - ] diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index e8bde4d663..a330ff1809 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -37,13 +37,15 @@ def for_user(self, user): return self.all() from django.db.models import Q + from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_groups = user.group_memberships.values_list("group_id", flat=True) + 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(shared_groups__in=user_groups) # Shared via group membership + | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() @@ -107,11 +109,15 @@ class APIDeployment(DefaultOrganizationMixin, BaseModel): default=False, db_comment="Whether this API deployment is shared with the entire organization", ) - shared_groups = models.ManyToManyField( - "tenant_account_v2.OrganizationGroup", - related_name="shared_api_deployments", - 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) # Manager objects = APIDeploymentModelManager() diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index f7887592be..53264bc9b3 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,8 @@ ValidationError, ) from tags.serializers import TagParamsSerializer +from tenant_account_v2.models import OrganizationGroup +from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from tenant_account_v2.sharing_helpers import ( serialize_group_refs, validate_shared_groups_in_org, @@ -38,7 +41,17 @@ from backend.serializers import AuditSerializer -class APIDeploymentSerializer(IntegrityErrorMixin, AuditSerializer): +class APIDeploymentSerializer( + SharedGroupsSerializerMixin, IntegrityErrorMixin, AuditSerializer +): + # ``shared_groups`` is no longer an M2M on APIDeployment — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. + shared_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=OrganizationGroup.objects.all(), + required=False, + ) + class Meta: model = APIDeployment fields = "__all__" diff --git a/backend/connector_v2/migrations/0006_add_shared_groups.py b/backend/connector_v2/migrations/0006_add_shared_groups.py deleted file mode 100644 index 4c9d51117f..0000000000 --- a/backend/connector_v2/migrations/0006_add_shared_groups.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2026-05-22 07:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tenant_account_v2", "0002_organization_group_group_membership"), - ("connector_v2", "0005_fix_unintended_connector_sharing"), - ] - - operations = [ - migrations.AddField( - model_name="connectorinstance", - name="shared_groups", - field=models.ManyToManyField( - blank=True, - related_name="shared_connector_instances", - to="tenant_account_v2.organizationgroup", - ), - ), - ] diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index 15d205efcb..490876d3d9 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -30,14 +30,17 @@ def for_user(self, user: User) -> models.QuerySet: if getattr(user, "is_service_account", False): return self.all() - user_groups = user.group_memberships.values_list("group_id", flat=True) + 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(shared_groups__in=user_groups) + | models.Q(pk__in=group_shared_ids) ) .distinct("id") ) @@ -102,11 +105,15 @@ class ConnectorMode(models.TextChoices): shared_users = models.ManyToManyField( User, related_name="shared_connectors", blank=True ) - shared_groups = models.ManyToManyField( - "tenant_account_v2.OrganizationGroup", - related_name="shared_connector_instances", - 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() diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index 4eb93d78a5..907887bcc6 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -8,7 +8,10 @@ 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 tenant_account_v2.models import OrganizationGroup +from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from tenant_account_v2.sharing_helpers import validate_shared_groups_in_org from utils.fields import EncryptedBinaryFieldSerializer from utils.input_sanitizer import validate_name_field @@ -23,10 +26,17 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceSerializer(AuditSerializer): +class ConnectorInstanceSerializer(SharedGroupsSerializerMixin, 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. + shared_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=OrganizationGroup.objects.all(), + required=False, + ) class Meta: model = ConnectorInstance diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index 8d6dcc10f2..56166695cf 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -9,9 +9,9 @@ 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.decorators import action from rest_framework.request import Request from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceViewSet(viewsets.ModelViewSet): +class ConnectorInstanceViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): versioning_class = URLPathVersioning serializer_class = ConnectorInstanceSerializer @@ -205,51 +205,36 @@ 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) - # TODO: notify group members when shared_groups changes (Phase 2) - 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 or not notification_plugin: + return response - return response + diffs = self.diff_share_axes(instance, before, request.data) + # TODO: notify group members when shared_groups changes (UN-2977 follow-up) + users_diff = diffs.get("shared_users") + if not (users_diff and users_diff.added): + return response - @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.""" - from tenant_account_v2.group_serializers import EffectiveMemberSerializer - from tenant_account_v2.sharing_helpers import compute_effective_members + try: + 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(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), + ) - connector = self.get_object() - members = compute_effective_members(connector) - return Response(EffectiveMemberSerializer(members, many=True).data) + return response diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 157e2ba909..9c7808ecbd 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -24,14 +24,21 @@ def _is_service_account(request: Request) -> bool: def _has_group_access(user: Any, obj: Any) -> bool: """Check if a user has access to a resource via group membership. - Returns False for objects that don't carry a ``shared_groups`` field - (e.g. resources whose model hasn't been extended yet), so callers can - OR this in safely without per-model guards. + 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. """ - if not hasattr(obj, "shared_groups"): - return False - user_groups = user.group_memberships.values_list("group_id", flat=True) - return bool(obj.shared_groups.filter(id__in=user_groups).exists()) + # 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() class IsOwner(permissions.BasePermission): diff --git a/backend/permissions/resource_share_views.py b/backend/permissions/resource_share_views.py new file mode 100644 index 0000000000..fa4d8015c4 --- /dev/null +++ b/backend/permissions/resource_share_views.py @@ -0,0 +1,105 @@ +"""Shared share-management surface for resource ViewSets. + +The mixin is **axis-agnostic** — it operates over any number of M2M sharing +"axes" declared on the resource model. Phase-1 axes for UN-2977 are +``shared_users`` and ``shared_groups``; UN-2022 (co-owners) will append +``co_owners`` via the :attr:`ResourceShareManagementMixin.share_axes` +attribute without changes here. +""" + +from dataclasses import dataclass, field +from typing import Any, ClassVar + +from django.db.models import Model +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + + +@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`. Phase-1 default + covers ``shared_users`` + ``shared_groups``; UN-2022 will set + ``share_axes = (..., "co_owners")`` on the relevant ViewSets. + """ + + share_axes: ClassVar[tuple[str, ...]] = ("shared_users", "shared_groups") + + @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/migrations/0004_add_shared_groups.py b/backend/pipeline_v2/migrations/0004_add_shared_groups.py deleted file mode 100644 index 33fef912bd..0000000000 --- a/backend/pipeline_v2/migrations/0004_add_shared_groups.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2026-05-22 07:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tenant_account_v2", "0002_organization_group_group_membership"), - ("pipeline_v2", "0003_add_sharing_fields_to_pipeline"), - ] - - operations = [ - migrations.AddField( - model_name="pipeline", - name="shared_groups", - field=models.ManyToManyField( - blank=True, - related_name="shared_pipelines", - to="tenant_account_v2.organizationgroup", - ), - ), - ] diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 0fb4263593..4ec834f35d 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -30,12 +30,16 @@ def for_user(self, user): if getattr(user, "is_service_account", False): return self.all() - user_groups = user.group_memberships.values_list("group_id", flat=True) + # 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(shared_groups__in=user_groups) # Shared via group membership + | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() @@ -120,11 +124,17 @@ class PipelineStatus(models.TextChoices): default=False, db_comment="Whether this pipeline is shared with the entire organization", ) - shared_groups = models.ManyToManyField( - "tenant_account_v2.OrganizationGroup", - related_name="shared_pipelines", - blank=True, - ) + # ``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 9a50bc23fb..2b567d1d63 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -13,6 +13,8 @@ from rest_framework import serializers from rest_framework.serializers import SerializerMethodField from scheduler.helper import SchedulerHelper +from tenant_account_v2.models import OrganizationGroup +from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from tenant_account_v2.sharing_helpers import validate_shared_groups_in_org from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from utils.serializer_utils import SerializerUtils @@ -27,11 +29,20 @@ DEPLOYMENT_ENDPOINT = settings.API_DEPLOYMENT_PATH_PREFIX + "/pipeline" -class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): +class PipelineSerializer( + SharedGroupsSerializerMixin, IntegrityErrorMixin, AuditSerializer +): api_endpoint = SerializerMethodField() 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. + shared_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=OrganizationGroup.objects.all(), + required=False, + ) class Meta: model = Pipeline diff --git a/backend/pipeline_v2/views.py b/backend/pipeline_v2/views.py index 3a88d1545a..58a527a5c4 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 @@ -140,65 +141,52 @@ def list_of_shared_users(self, request: Request, pk: str | None = None) -> Respo serializer = SharedUserListSerializer(pipeline) return Response(serializer.data, status=status.HTTP_200_OK) - @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.""" - from tenant_account_v2.group_serializers import EffectiveMemberSerializer - from tenant_account_v2.sharing_helpers import compute_effective_members - - pipeline = self.get_object() - members = compute_effective_members(pipeline) - return Response( - EffectiveMemberSerializer(members, many=True).data, - status=status.HTTP_200_OK, - ) - def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Override to handle sharing notifications.""" instance = self.get_object() - current_shared_users = set(instance.shared_users.all()) + before = self.snapshot_share_axes(instance) response = super().partial_update(request, *args, **kwargs) - # TODO: notify group members when shared_groups changes (Phase 2) - if ( - response.status_code == 200 - and "shared_users" in request.data - and notification_plugin + if response.status_code != 200 or not notification_plugin: + return response + + diffs = self.diff_share_axes(instance, before, request.data) + # TODO: notify group members when shared_groups changes (UN-2977 follow-up) + users_diff = diffs.get("shared_users") + if not (users_diff and users_diff.added): + return response + + # Only ETL/TASK pipelines map to a notification ``ResourceType``; + # DEFAULT/APP pipelines have no analogue and skip the fan-out. + 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 + + try: + resource_type = instance.pipeline_type + 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(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), + ) return response diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py deleted file mode 100644 index 7a73b700e8..0000000000 --- a/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_add_shared_groups.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2026-05-22 07:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tenant_account_v2", "0002_organization_group_group_membership"), - ("prompt_studio_core_v2", "0007_customtool_last_exported_at"), - ] - - operations = [ - migrations.AddField( - model_name="customtool", - name="shared_groups", - field=models.ManyToManyField( - blank=True, - related_name="shared_custom_tools", - to="tenant_account_v2.organizationgroup", - ), - ), - ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index 84f00dfaff..ae9d878b4a 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -26,14 +26,17 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.all() - user_groups = user.group_memberships.values_list("group_id", flat=True) + 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(shared_groups__in=user_groups) + | models.Q(pk__in=group_shared_ids) ) .distinct("tool_id") ) @@ -162,11 +165,15 @@ class CustomTool(DefaultOrganizationMixin, BaseModel): default=False, db_comment="Flag to share this custom tool with all users in the organization", ) - shared_groups = models.ManyToManyField( - "tenant_account_v2.OrganizationGroup", - related_name="shared_custom_tools", - blank=True, - ) + # ``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 5a389aacc1..1c685ecc8e 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -7,6 +7,8 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from rest_framework.exceptions import ValidationError +from tenant_account_v2.models import OrganizationGroup +from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from tenant_account_v2.sharing_helpers import ( serialize_group_refs, validate_shared_groups_in_org, @@ -80,13 +82,22 @@ def get_prompt_count(self, instance): return instance.mapped_prompt.count() -class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): +class CustomToolSerializer( + SharedGroupsSerializerMixin, IntegrityErrorMixin, AuditSerializer +): shared_users = serializers.PrimaryKeyRelatedField( queryset=User.objects.filter(is_service_account=False), required=False, allow_null=True, many=True, ) + # ``shared_groups`` is no longer an M2M on CustomTool — declare it + # explicitly so ``fields = "__all__"`` continues to expose it. + shared_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=OrganizationGroup.objects.all(), + required=False, + ) class Meta: model = CustomTool diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 2d828e32e2..29da455307 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,47 +296,43 @@ 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) - # TODO: notify group members when shared_groups changes (Phase 2) - # 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)}" - ) + if response.status_code != 200: + return response + + notification_plugin = get_plugin("notification") + if not notification_plugin: + return response + + diffs = self.diff_share_axes(custom_tool, before, request.data) + # TODO: notify group members when shared_groups changes (UN-2977 follow-up) + users_diff = diffs.get("shared_users") + if not (users_diff and users_diff.added): + return response + + from plugins.notification.constants import ResourceType + + try: + service_class = notification_plugin["service_class"] + notification_service = service_class() + notification_service.send_sharing_notification( + resource_type=ResourceType.TEXT_EXTRACTOR.value, + resource_name=custom_tool.tool_name, + resource_id=str(custom_tool.tool_id), + shared_by=request.user, + 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), + ) return response @@ -891,16 +888,6 @@ def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response return Response(serialized_instances) - @action(detail=True, methods=["get"], url_path="effective-members") - def effective_members(self, request: HttpRequest, pk: Any = None) -> Response: - """Return all users with access (direct/group/org), priority-deduped.""" - from tenant_account_v2.group_serializers import EffectiveMemberSerializer - from tenant_account_v2.sharing_helpers import compute_effective_members - - custom_tool = self.get_object() - members = compute_effective_members(custom_tool) - return Response(EffectiveMemberSerializer(members, many=True).data) - @action(detail=True, methods=["post"]) def create_prompt(self, request: HttpRequest, pk: Any = None) -> Response: context = super().get_serializer_context() diff --git a/backend/tenant_account_v2/group_views.py b/backend/tenant_account_v2/group_views.py index 6f13849a6e..352aad87cc 100644 --- a/backend/tenant_account_v2/group_views.py +++ b/backend/tenant_account_v2/group_views.py @@ -235,9 +235,13 @@ def _collect_resources_shared_with_group( ("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 = model.objects.filter(shared_groups=group).values_list(id_field, name_field) + qs = list_resources_shared_with_group(group, model).values_list( + id_field, name_field + ) for resource_id, name in qs: results.append( { 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 7f48d8f8ba..b8dc914d49 100644 --- a/backend/tenant_account_v2/models.py +++ b/backend/tenant_account_v2/models.py @@ -1,4 +1,5 @@ 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 ( @@ -100,6 +101,47 @@ class Meta: ] +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. diff --git a/backend/tenant_account_v2/share_serializer_mixin.py b/backend/tenant_account_v2/share_serializer_mixin.py new file mode 100644 index 0000000000..a2636ede52 --- /dev/null +++ b/backend/tenant_account_v2/share_serializer_mixin.py @@ -0,0 +1,44 @@ +"""Serializer mixin for the polymorphic ``shared_groups`` axis. + +Each shareable resource serializer composes :class:`SharedGroupsSerializerMixin` +to write ``shared_groups`` into :class:`tenant_account_v2.models.ResourceGroupShare` +— the per-resource M2M field has been removed (see UN-2977). + +Reads work via a ``shared_groups`` ``@property`` defined on each resource +model (returns ``QuerySet[OrganizationGroup]``); DRF's natural +``PrimaryKeyRelatedField`` serialization then yields a list of group IDs +without any custom ``to_representation``. + +Usage:: + + class PipelineSerializer(SharedGroupsSerializerMixin, ...): + shared_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=OrganizationGroup.objects.all(), + required=False, + ) +""" + +from __future__ import annotations + +from typing import Any + +from tenant_account_v2.sharing_helpers import set_resource_share_groups + + +class SharedGroupsSerializerMixin: + """Adds polymorphic ``shared_groups`` writes to a ModelSerializer.""" + + def create(self, validated_data: dict[str, Any]) -> Any: + groups = validated_data.pop("shared_groups", None) + instance = super().create(validated_data) # type: ignore[misc] + if groups is not None: + set_resource_share_groups(instance, [g.id for g in groups]) + return instance + + def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: + groups = validated_data.pop("shared_groups", None) + instance = super().update(instance, validated_data) # type: ignore[misc] + if groups is not None: + set_resource_share_groups(instance, [g.id for g in groups]) + return instance diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py index 7fb87256a0..46621412fb 100644 --- a/backend/tenant_account_v2/sharing_helpers.py +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -1,15 +1,25 @@ """Shared helpers for group-based resource sharing. Centralizes the per-resource hooks so each shareable viewset and serializer -plugs into the same logic. +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`` M2M payload. + 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 - ``users/`` sharing-info endpoints, so the share modal can render the - currently-shared groups. +* ``serialize_group_refs`` — small ``[{id, name, source}]`` listing for + the share modal's currently-shared listing. """ from __future__ import annotations @@ -19,12 +29,16 @@ from typing import Any from account_v2.models import Organization, User +from django.contrib.contenttypes.models import ContentType +from django.db import 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__) @@ -52,9 +66,90 @@ def validate_shared_groups_in_org( 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 list_resources_shared_with_group( + group: OrganizationGroup, model: type[Model] +) -> QuerySet: + """Resources of ``model`` shared with ``group`` (replaces + ``model.objects.filter(shared_groups=group)``). + """ + shared_object_ids = ResourceGroupShare.objects.filter( + group=group, + content_type=ContentType.objects.get_for_model(model), + ).values("object_id") + return model.objects.filter(pk__in=shared_object_ids) + + +def resources_visible_via_groups( + model: type[Model], user_group_ids: Iterable[int] +) -> QuerySet[str]: + """Subquery feeding ``for_user()``: object_ids of ``model`` rows + shared with any group the user belongs to. + """ + return ResourceGroupShare.objects.filter( + content_type=ContentType.objects.get_for_model(model), + group_id__in=user_group_ids, + ).values("object_id") + + def serialize_group_refs(resource_obj: Any) -> list[dict[str, Any]]: """Return a compact ``[{id, name, source}]`` listing for share modals.""" - return list(resource_obj.shared_groups.values("id", "name", "source")) + return list(get_resource_share_groups(resource_obj).values("id", "name", "source")) def compute_effective_members(resource_obj: Any) -> list[dict[str, Any]]: @@ -84,9 +179,9 @@ def compute_effective_members(resource_obj: Any) -> list[dict[str, Any]]: "group_name": None, } - # Group shares — collect via the resource's shared_groups M2M + # Group shares — via the polymorphic resource_group_share table group_memberships = GroupMembership.objects.filter( - group__in=resource_obj.shared_groups.all(), + group__in=get_resource_share_groups(resource_obj), ).select_related("group", "user") for membership in group_memberships: user = membership.user diff --git a/backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py b/backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py deleted file mode 100644 index 4c22cc799f..0000000000 --- a/backend/workflow_manager/workflow_v2/migrations/0020_add_shared_groups.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2026-05-22 07:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tenant_account_v2", "0002_organization_group_group_membership"), - ("workflow_v2", "0019_remove_filehistory_trigram_index"), - ] - - operations = [ - migrations.AddField( - model_name="workflow", - name="shared_groups", - field=models.ManyToManyField( - blank=True, - related_name="shared_workflows", - to="tenant_account_v2.organizationgroup", - ), - ), - ] diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index 8a8311cb93..2eff54bcea 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -28,13 +28,15 @@ def for_user(self, user): return self.all() from django.db.models import Q + from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_groups = user.group_memberships.values_list("group_id", flat=True) + 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(shared_groups__in=user_groups) # Shared via group membership + | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() @@ -106,11 +108,15 @@ class ExecutionAction(models.TextChoices): default=False, db_comment="Whether this workflow is shared with the entire organization", ) - shared_groups = models.ManyToManyField( - "tenant_account_v2.OrganizationGroup", - related_name="shared_workflows", - 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) # Manager objects = WorkflowModelManager() diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index b6739e83f1..1f8dabd92d 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,8 @@ UUIDField, ValidationError, ) +from tenant_account_v2.models import OrganizationGroup +from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from tenant_account_v2.sharing_helpers import ( serialize_group_refs, validate_shared_groups_in_org, @@ -33,8 +36,17 @@ logger = logging.getLogger(__name__) -class WorkflowSerializer(IntegrityErrorMixin, AuditSerializer): +class WorkflowSerializer( + SharedGroupsSerializerMixin, 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. + shared_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=OrganizationGroup.objects.all(), + required=False, + ) class Meta: model = Workflow diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index 5816010d4c..3cad053c8a 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]: @@ -138,53 +139,41 @@ 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) - # TODO: notify group members when shared_groups changes (Phase 2) - # 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, - ) + if response.status_code != 200 or not notification_plugin: + return response - logger.info( - f"Sent sharing notifications for workflow {workflow.id} " - f"to {len(newly_shared_users)} users" - ) + diffs = self.diff_share_axes(workflow, before, request.data) + # TODO: notify group members when shared_groups changes (UN-2977 follow-up) + users_diff = diffs.get("shared_users") + if not (users_diff and users_diff.added): + return response - 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)}" - ) + 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=request.user, + 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), + ) return response @@ -360,19 +349,6 @@ def list_of_shared_users(self, request: Request, pk: str) -> Response: serializer = SharedUserListSerializer(workflow) return Response(serializer.data, status=status.HTTP_200_OK) - @action(detail=True, methods=["get"], url_path="effective-members") - def effective_members(self, request: Request, pk: str) -> Response: - """Return all users with access (direct/group/org), priority-deduped.""" - from tenant_account_v2.group_serializers import EffectiveMemberSerializer - from tenant_account_v2.sharing_helpers import compute_effective_members - - workflow = self.get_object() - members = compute_effective_members(workflow) - return Response( - EffectiveMemberSerializer(members, many=True).data, - status=status.HTTP_200_OK, - ) - # ============================================================================= # INTERNAL API VIEWS - Used by Celery workers for service-to-service communication 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/groups/GroupMemberManager.jsx b/frontend/src/components/groups/GroupMemberManager.jsx index 5e23cceb55..6a8dc079b8 100644 --- a/frontend/src/components/groups/GroupMemberManager.jsx +++ b/frontend/src/components/groups/GroupMemberManager.jsx @@ -50,9 +50,7 @@ function GroupMemberManager({ open, group, onClose }) { }, [open, group?.id]); const memberIds = new Set(members.map((m) => m.user_id)); - const candidateUsers = orgUsers.filter( - (u) => !memberIds.has(u.id) && !pendingAddIds.includes(u.id), - ); + const candidateUsers = orgUsers.filter((u) => !memberIds.has(u.id)); const handleAdd = () => { if (!pendingAddIds.length) { diff --git a/frontend/src/components/groups/Groups.jsx b/frontend/src/components/groups/Groups.jsx index 409a3bd0ac..d77100bf5b 100644 --- a/frontend/src/components/groups/Groups.jsx +++ b/frontend/src/components/groups/Groups.jsx @@ -145,15 +145,17 @@ function Groups() { }; const columns = [ - { title: "Name", dataIndex: "name" }, - { title: "Description", dataIndex: "description" }, { - title: "Source", - dataIndex: "source", - render: (source) => ( - {source} + title: "Name", + dataIndex: "name", + render: (name, record) => ( + + {name} + {record.source === "IDP" && IdP} + ), }, + { title: "Description", dataIndex: "description" }, { title: "Members", dataIndex: "member_count", align: "center" }, { title: "Actions", diff --git a/frontend/src/components/widgets/share-permission/SharePermission.css b/frontend/src/components/widgets/share-permission/SharePermission.css index b100d25b9e..267c43918f 100644 --- a/frontend/src/components/widgets/share-permission/SharePermission.css +++ b/frontend/src/components/widgets/share-permission/SharePermission.css @@ -14,3 +14,12 @@ .share-per-checkbox { margin: 10px 0px; } + +.share-permission-section { + margin-top: 16px; +} + +.share-permission-section > .ant-typography { + display: block; + margin-bottom: 6px; +} diff --git a/frontend/src/components/widgets/share-permission/SharePermission.jsx b/frontend/src/components/widgets/share-permission/SharePermission.jsx index 829619131d..949a72f9fb 100644 --- a/frontend/src/components/widgets/share-permission/SharePermission.jsx +++ b/frontend/src/components/widgets/share-permission/SharePermission.jsx @@ -190,7 +190,7 @@ function SharePermission({ return ( adapter && ( setOpen(false)} maskClosable={false} @@ -220,45 +220,53 @@ function SharePermission({ )} {permissionEdit && !shareWithEveryone && ( <> - { - if (!selectedGroupIds.includes(groupId)) { - setSelectedGroupIds([...selectedGroupIds, groupId]); + onChange={(selectedValue) => { + const isValueSelected = + selectedUsers.includes(selectedValue); + if (!isValueSelected) { + setSelectedUsers([...selectedUsers, selectedValue]); } }} - options={groupCandidateOptions} + options={filteredUsers.map((user) => ({ + label: user.email, + value: user.id, + }))} /> + + {allGroups.length > 0 && ( +
+ Add groups + ({ - value: u.id, - label: u.email, - }))} - filterOption={(input, option) => - (option?.label ?? "") - .toString() - .toLowerCase() - .includes(input.toLowerCase()) - } - showSearch - /> - )} + + )} {children}
@@ -55,6 +59,7 @@ TopBar.propTypes = { searchData: PropTypes.array, setFilteredUserList: PropTypes.func, searchKey: PropTypes.string, + searchPlaceholder: PropTypes.string, children: PropTypes.element, }; From 44b73c2dca43b16fd70179d6dc870f53533dbffc Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Tue, 26 May 2026 10:53:19 +0530 Subject: [PATCH 10/14] UN-2977 [FIX] Restrict group resources endpoint to org admins The /groups/{pk}/resources/ action is the delete blast-radius view used only by the admin delete-confirm flow, but IsOrgAdminForWrite permits GET for any authenticated org member. That let non-members enumerate the names/UUIDs of resources shared with groups they are not in, contradicting the model where org admin has no implicit resource access. Gate the action to org admins, mirroring the existing members POST / remove_member guards. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/tenant_account_v2/group_views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/tenant_account_v2/group_views.py b/backend/tenant_account_v2/group_views.py index 9e8a53b7e2..d80df78703 100644 --- a/backend/tenant_account_v2/group_views.py +++ b/backend/tenant_account_v2/group_views.py @@ -166,6 +166,11 @@ def remove_member( @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) From 49fd4851169ff9a212de1a8ddc1c2389919c2df1 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Tue, 26 May 2026 12:19:48 +0530 Subject: [PATCH 11/14] UN-2977 [FIX] Drop stale IdP group-sync leftovers from sample.env Group sharing is manual-only after the 2026-05-25 reversal; there is no IdP group sync. Remove the dead IDP_GROUP_SYNC_INTERVAL_MIN var and the "LOCAL + IDP combined" wording so sample.env stops documenting a feature that no longer exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/sample.env | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/sample.env b/backend/sample.env index 004fc8ddde..377016fdec 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -232,12 +232,10 @@ FILE_EXECUTION_TRACKER_COMPLETED_TTL_IN_SECOND=600 MAX_FILE_EXECUTION_COUNT=3 # Org-scoped group sharing (UN-2977 / mfbt UNS-612) -# Max OrganizationGroup rows allowed per Organization (LOCAL + IDP combined) +# Max OrganizationGroup rows allowed per Organization MAX_GROUPS_PER_ORG=200 # Max GroupMembership rows allowed per OrganizationGroup MAX_MEMBERS_PER_GROUP=500 -# IdP group sync reconcile cadence (Celery beat, cloud-only) -IDP_GROUP_SYNC_INTERVAL_MIN=30 # Runner polling timeout (3 hours) MAX_RUNNER_POLLING_WAIT_SECONDS=10800 From 2d8d0980e9608cc2cf65adb42f15d2eba949ccf6 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Tue, 26 May 2026 13:45:17 +0530 Subject: [PATCH 12/14] UN-2977 [FIX] Harden group-share signals + correct member_count and guards Addresses self-review findings on PR #1986: - member_count: count via a decoupled Subquery so the optional ?member filter on the same relation no longer collapses it to 1. - Add a post_delete receiver purging ResourceGroupShare rows when a shareable resource is deleted (object_id is varchar, no FK/cascade). - Wrap cleanup_user_org_access in transaction.atomic() with per-model error handling that logs and re-raises, so a partial purge can't re-open the rejoin backdoor. - Skip-and-log malformed object_id UUIDs instead of 500ing the list. - Error-handle the post-commit adapter default-cleanup save. - Comment accuracy: shared_groups is polymorphic (not M2M); drop the duplicate service-account docstring bullet; clarify the mixin's writable vs read-only usage. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/adapter_processor_v2/views.py | 13 +- backend/api_v2/models.py | 1 - backend/permissions/resource_share_views.py | 9 +- backend/pipeline_v2/models.py | 1 - .../tenant_account_v2/group_serializers.py | 14 ++- .../share_serializer_mixin.py | 13 +- backend/tenant_account_v2/sharing_helpers.py | 22 +++- backend/tenant_account_v2/signals.py | 114 +++++++++++++----- .../workflow_v2/models/workflow.py | 1 - 9 files changed, 147 insertions(+), 41 deletions(-) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 886a3ac0e6..6b6c082b04 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -395,7 +395,18 @@ def _clear_default_adapter_for_removed_users( setattr(user_default_adapter, field_name, None) updated = True if updated: - user_default_adapter.save() + # 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, + ) @action(detail=True, methods=["get"]) def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response: diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index 1f4313f665..e4d52aeee5 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -33,7 +33,6 @@ def for_user(self, 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 see all org resources - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): diff --git a/backend/permissions/resource_share_views.py b/backend/permissions/resource_share_views.py index 7be2627a74..b4ed73bf52 100644 --- a/backend/permissions/resource_share_views.py +++ b/backend/permissions/resource_share_views.py @@ -1,9 +1,10 @@ """Shared share-management surface for resource ViewSets. -The mixin is **axis-agnostic** — it operates over any number of M2M sharing -"axes" declared on the resource model. The current axes are -``shared_users`` and ``shared_groups``; new axes can be added by extending -:attr:`ResourceShareManagementMixin.share_axes`. +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 diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index ffcc993966..7aea106521 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -26,7 +26,6 @@ def for_user(self, 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 see all org resources - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): diff --git a/backend/tenant_account_v2/group_serializers.py b/backend/tenant_account_v2/group_serializers.py index b9366db3c6..520c38bae6 100644 --- a/backend/tenant_account_v2/group_serializers.py +++ b/backend/tenant_account_v2/group_serializers.py @@ -4,7 +4,7 @@ from typing import Any from django.conf import settings -from django.db.models import Count, Q +from django.db.models import Count, IntegerField, OuterRef, Subquery from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -172,9 +172,19 @@ def list_groups_with_member_counts(organization: Any, user: Any | None = None) - 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=Count("memberships", filter=Q(memberships__isnull=False)) + memberships__count=Subquery(member_count_sq, output_field=IntegerField()) ).distinct() diff --git a/backend/tenant_account_v2/share_serializer_mixin.py b/backend/tenant_account_v2/share_serializer_mixin.py index 275b0896e9..8e438ae317 100644 --- a/backend/tenant_account_v2/share_serializer_mixin.py +++ b/backend/tenant_account_v2/share_serializer_mixin.py @@ -9,9 +9,18 @@ ``PrimaryKeyRelatedField`` serialization then yields a list of group IDs without any custom ``to_representation``. -Usage:: +Two write modes share this mixin: - class PipelineSerializer(SharedGroupsSerializerMixin, ...): +* **Writable field** (``queryset=…``) — ``create``/``update`` below commit the + groups. Used by serializers that accept ``shared_groups`` on the resource + payload directly (e.g. the cloud ``AgenticProject`` serializer). +* **Read-only field** (``read_only=True``) — the OSS resource serializers route + every share mutation through the ``POST //{id}/share/`` action, so + ``create``/``update`` see no ``shared_groups`` and no-op for them. + +Writable usage:: + + class AgenticProjectSerializer(SharedGroupsSerializerMixin, ...): shared_groups = serializers.PrimaryKeyRelatedField( many=True, queryset=OrganizationGroup.objects.all(), diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py index 8770a4232b..407313de08 100644 --- a/backend/tenant_account_v2/sharing_helpers.py +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -119,6 +119,24 @@ def set_resource_share_groups(resource_obj: Any, group_ids: Iterable[int]) -> No ) +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: @@ -135,7 +153,7 @@ def list_resources_shared_with_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] = [uuid.UUID(s) for s in raw_ids if s] + pks: list[Any] = _safe_uuids(raw_ids) else: pks = list(raw_ids) return model.objects.filter(pk__in=pks) @@ -158,7 +176,7 @@ def resources_visible_via_groups( group_id__in=user_group_ids, ).values_list("object_id", flat=True) if isinstance(model._meta.pk, models.UUIDField): - return [uuid.UUID(s) for s in raw_ids if s] + return _safe_uuids(raw_ids) return list(raw_ids) diff --git a/backend/tenant_account_v2/signals.py b/backend/tenant_account_v2/signals.py index 3389ca8e63..e7a270f48d 100644 --- a/backend/tenant_account_v2/signals.py +++ b/backend/tenant_account_v2/signals.py @@ -1,6 +1,7 @@ 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 @@ -36,41 +37,100 @@ def cleanup_user_org_access( silently regain direct access. Uses a signal (not DB CASCADE) so notification / audit hooks can attach - here later without a schema change. + 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). """ - deleted_count, _ = GroupMembership.objects.filter( - group__organization=instance.organization, - user=instance.user, + 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_count: + if deleted: logger.info( - "Removed %s group memberships for user=%s org=%s after OrganizationMember delete", - deleted_count, - instance.user_id, - instance.organization_id, + "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: - # App not installed in this deployment (e.g. cloud-only - # agentic_studio_v1 in pure OSS). Skip cleanly. continue - resources = model.objects.filter( - organization=instance.organization, - shared_users=instance.user, + post_delete.connect( + cleanup_resource_group_shares, + sender=model, + dispatch_uid=f"cleanup_resource_group_shares_{app_label}_{model_name}", ) - removed = 0 - for resource in resources: - resource.shared_users.remove(instance.user) - removed += 1 - 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, - ) + + +_connect_resource_group_share_cleanup() diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index b32613ccd3..1b2891937e 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -24,7 +24,6 @@ def for_user(self, 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 see all org resources - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): From 1da71055a92a083e7ba7a2491881bf2edd068212 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Tue, 26 May 2026 13:45:26 +0530 Subject: [PATCH 13/14] UN-2977 [FIX] Restore close-on-success in share modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR had moved the share-modal close into .finally() (close on success or failure), discarding the user's selection on a 400/403. Revert useShareModal, ListOfTools and Workflows to close only on success — restoring pre-PR behavior and matching ToolSettings — so a rejected share keeps the modal open for the user to review and retry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/custom-tools/list-of-tools/ListOfTools.jsx | 8 +++++--- frontend/src/components/workflows/workflow/Workflows.jsx | 4 +++- frontend/src/hooks/useShareModal.js | 7 +++---- 3 files changed, 11 insertions(+), 8 deletions(-) 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 15ecc16914..5017e2b285 100644 --- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx +++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx @@ -343,11 +343,13 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { }, }; axiosPrivate(requestOptions) + .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) => { setAlertDetails(handleException(err, "Failed to load")); - }) - .finally(() => { - setOpenSharePermissionModal(false); }); }; diff --git a/frontend/src/components/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx index abd5f5ede4..1b11b8b12e 100644 --- a/frontend/src/components/workflows/workflow/Workflows.jsx +++ b/frontend/src/components/workflows/workflow/Workflows.jsx @@ -286,12 +286,14 @@ function Workflows() { content: "Workflow sharing updated successfully", }); 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"), ); } finally { - setShareOpen(false); setShareLoading(false); } }; diff --git a/frontend/src/hooks/useShareModal.js b/frontend/src/hooks/useShareModal.js index 9c74222efe..3c380383f6 100644 --- a/frontend/src/hooks/useShareModal.js +++ b/frontend/src/hooks/useShareModal.js @@ -111,15 +111,14 @@ function useShareModal({ content: "Sharing permissions updated successfully", }); 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)); }) .finally(() => { - // Close after every Apply (success or failure) so the modal - // doesn't keep showing rejected entries; reopening reseeds from - // the server's authoritative state. - setOpenShareModal(false); setIsLoadingShare(false); }); }; From befd61c9bd5bf912c66dc1df9f7fd866b4644b79 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Tue, 26 May 2026 14:07:33 +0530 Subject: [PATCH 14/14] UN-2977 [FIX] Remove dead SharedGroupsSerializerMixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 7 resource serializers (6 OSS + cloud AgenticProject) declare shared_groups as read_only, so DRF strips it from validated_data and the mixin's create/update never wrote group shares — every group write flows through ShareAuthorizationService._commit via the POST /share/ action. The mixin was therefore dead code with a misleading docstring and a latent org-scope footgun if a field were ever made writable without re-adding validation. Delete it and unwire it from the 6 serializers; the read_only shared_groups declarations stay. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/adapter_processor_v2/serializers.py | 3 +- backend/api_v2/serializers.py | 5 +- backend/connector_v2/serializers.py | 3 +- backend/pipeline_v2/serializers/crud.py | 5 +- .../prompt_studio_core_v2/serializers.py | 5 +- .../share_serializer_mixin.py | 59 ------------------- .../workflow_v2/serializers.py | 5 +- 7 files changed, 6 insertions(+), 79 deletions(-) delete mode 100644 backend/tenant_account_v2/share_serializer_mixin.py diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index ead0997385..0676a543cd 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -6,7 +6,6 @@ from django.conf import settings from rest_framework import serializers from rest_framework.serializers import ModelSerializer -from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from tenant_account_v2.sharing_helpers import serialize_group_refs from utils.input_sanitizer import validate_name_field, validate_no_html_tags @@ -26,7 +25,7 @@ class TestAdapterSerializer(serializers.Serializer): adapter_type = serializers.JSONField() -class BaseAdapterSerializer(SharedGroupsSerializerMixin, AuditSerializer): +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). diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 3ec944c0f6..2c7c911476 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -23,7 +23,6 @@ ValidationError, ) from tags.serializers import TagParamsSerializer -from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin 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 @@ -36,9 +35,7 @@ from backend.serializers import AuditSerializer -class APIDeploymentSerializer( - SharedGroupsSerializerMixin, IntegrityErrorMixin, AuditSerializer -): +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). diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index cc54c37960..8782199e74 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -10,7 +10,6 @@ from connector_processor.exceptions import InvalidConnectorID, OAuthTimeOut from rest_framework import serializers from rest_framework.serializers import CharField, SerializerMethodField, ValidationError -from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from utils.fields import EncryptedBinaryFieldSerializer from utils.input_sanitizer import validate_name_field @@ -23,7 +22,7 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceSerializer(SharedGroupsSerializerMixin, AuditSerializer): +class ConnectorInstanceSerializer(AuditSerializer): connector_metadata = EncryptedBinaryFieldSerializer(required=False, allow_null=True) icon = SerializerMethodField() created_by_email = CharField(source="created_by.email", read_only=True) diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index 779b3281e9..da1c2981cf 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -13,7 +13,6 @@ from rest_framework import serializers from rest_framework.serializers import SerializerMethodField from scheduler.helper import SchedulerHelper -from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from utils.serializer_utils import SerializerUtils from workflow_manager.endpoint_v2.models import WorkflowEndpoint @@ -26,9 +25,7 @@ DEPLOYMENT_ENDPOINT = settings.API_DEPLOYMENT_PATH_PREFIX + "/pipeline" -class PipelineSerializer( - SharedGroupsSerializerMixin, IntegrityErrorMixin, AuditSerializer -): +class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): api_endpoint = SerializerMethodField() created_by_email = SerializerMethodField() last_5_run_statuses = SerializerMethodField() diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index 67bee3ad50..f465d31488 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -6,7 +6,6 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from rest_framework.exceptions import ValidationError -from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin 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 @@ -76,9 +75,7 @@ def get_prompt_count(self, instance): return instance.mapped_prompt.count() -class CustomToolSerializer( - SharedGroupsSerializerMixin, IntegrityErrorMixin, AuditSerializer -): +class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): # 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) diff --git a/backend/tenant_account_v2/share_serializer_mixin.py b/backend/tenant_account_v2/share_serializer_mixin.py deleted file mode 100644 index 8e438ae317..0000000000 --- a/backend/tenant_account_v2/share_serializer_mixin.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Serializer mixin for the polymorphic ``shared_groups`` axis. - -Each shareable resource serializer composes :class:`SharedGroupsSerializerMixin` -to write ``shared_groups`` into :class:`tenant_account_v2.models.ResourceGroupShare` -— the per-resource M2M field has been removed (see UN-2977). - -Reads work via a ``shared_groups`` ``@property`` defined on each resource -model (returns ``QuerySet[OrganizationGroup]``); DRF's natural -``PrimaryKeyRelatedField`` serialization then yields a list of group IDs -without any custom ``to_representation``. - -Two write modes share this mixin: - -* **Writable field** (``queryset=…``) — ``create``/``update`` below commit the - groups. Used by serializers that accept ``shared_groups`` on the resource - payload directly (e.g. the cloud ``AgenticProject`` serializer). -* **Read-only field** (``read_only=True``) — the OSS resource serializers route - every share mutation through the ``POST //{id}/share/`` action, so - ``create``/``update`` see no ``shared_groups`` and no-op for them. - -Writable usage:: - - class AgenticProjectSerializer(SharedGroupsSerializerMixin, ...): - shared_groups = serializers.PrimaryKeyRelatedField( - many=True, - queryset=OrganizationGroup.objects.all(), - required=False, - ) -""" - -from __future__ import annotations - -from typing import Any - -from django.db import transaction - -from tenant_account_v2.sharing_helpers import set_resource_share_groups - - -class SharedGroupsSerializerMixin: - """Adds polymorphic ``shared_groups`` writes to a ModelSerializer.""" - - def create(self, validated_data: dict[str, Any]) -> Any: - groups = validated_data.pop("shared_groups", None) - # Model save and group-share write must commit together so a failure - # in the second step can't leave a created-but-unshared resource. - with transaction.atomic(): - instance = super().create(validated_data) # type: ignore[misc] - if groups is not None: - set_resource_share_groups(instance, [g.id for g in groups]) - return instance - - def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: - groups = validated_data.pop("shared_groups", None) - with transaction.atomic(): - instance = super().update(instance, validated_data) # type: ignore[misc] - if groups is not None: - set_resource_share_groups(instance, [g.id for g in groups]) - return instance diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index f15c142a49..97a72d389b 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -13,7 +13,6 @@ UUIDField, ValidationError, ) -from tenant_account_v2.share_serializer_mixin import SharedGroupsSerializerMixin 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 @@ -31,9 +30,7 @@ logger = logging.getLogger(__name__) -class WorkflowSerializer( - SharedGroupsSerializerMixin, IntegrityErrorMixin, AuditSerializer -): +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