From fb44e74b9ec2d1979ce0ed3f42e61d99f760fee9 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Fri, 22 May 2026 18:08:12 +0530 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 From d9f871c637a6d80bb47e538433329d9fedbadfbc Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Thu, 28 May 2026 15:49:00 +0530 Subject: [PATCH 15/21] UN-2977 [FIX] Return 403 not 404 on shared-user mutation of tool config and LLM profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRF runs get_queryset() before has_object_permission(); when a sub-resource's queryset filtered out rows reachable via parent-resource sharing, shared users got HTTP 404 on PATCH/DELETE instead of the expected 403. Strictly additive fix on read visibility (post-fix row set is a superset of pre-fix for every caller class): - permissions/permission.py: add IsParentWorkflowOwner — mirrors IsOwner but reads obj.workflow.created_by; admits org admin and service account. - tool_instance_v2/views.py: broaden get_queryset to union prior per-creator rows with rows under workflows the user can access; gate update / partial_update / destroy on IsParentWorkflowOwner. - prompt_studio/prompt_profile_manager_v2/models.py: extend ProfileManagerModelManager.for_user with a parent-CustomTool branch alongside the existing created_by | shared_users | shared_to_org branches. Viewset's IsOwner gate is unchanged. The only mutate-side narrowing: a shared user can no longer mutate a ToolInstance they themselves created under a non-owned workflow (was an unintended path that let shared users inject + mutate tool config; matches the user-stated requirement that shared users shouldn't change tool config). Verified: pre-commit clean, identical mypy error counts vs HEAD on all three files (no new errors introduced). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/permissions/permission.py | 17 +++++++++++ .../prompt_profile_manager_v2/models.py | 20 +++++++++---- backend/tool_instance_v2/views.py | 28 +++++++++++-------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 0289408a0f..07cc3c4b97 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -77,6 +77,23 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo return False +class IsParentWorkflowOwner(permissions.BasePermission): + """Mutation gate for nested workflow sub-resources. + + Admits only the parent workflow's owner, org admin, or service account. + Pairs with a parent-aware queryset that admits shared-workflow rows so + this class can return 403 -- otherwise DRF raises 404 first when the + queryset filters the row out before object permissions run. + """ + + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + if _is_service_account(request): + return True + if obj.workflow.created_by == request.user: + return True + return _is_organization_admin(request) + + class IsOrganizationMember(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: user_organization = UserContext.get_organization() diff --git a/backend/prompt_studio/prompt_profile_manager_v2/models.py b/backend/prompt_studio/prompt_profile_manager_v2/models.py index 2dede57aa3..10a234f462 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/models.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/models.py @@ -14,11 +14,13 @@ class ProfileManagerModelManager(BaseModelManager): def for_user(self, user): - """Mirror the visibility model used by Workflow/Pipeline/etc. + """Read visibility: profile's own share fields OR parent CustomTool sharing. - Org-scoped via the parent CustomTool — ProfileManager has no - direct ``organization`` FK, so the ``shared_to_org=True`` branch - would otherwise leak rows across tenants for guessed UUIDs. + Org-scoped via the parent (no ``organization`` FK on this model). + The parent-tool branch lets shared-project users see existing + profiles so ``IsOwner`` on the viewset returns 403 on mutation + instead of DRF raising 404 first. Mutation gating lives on the + viewset; this method governs read visibility only. """ org_scope = Q(prompt_studio_tool__organization=UserContext.get_organization()) @@ -28,9 +30,17 @@ def for_user(self, user): if OrganizationMemberService.is_user_organization_admin(user): return self.filter(org_scope) + # Union the legacy own-share branches with the parent-tool branch + # so any row visible before the UN-2977 fix stays visible. + accessible_tools = CustomTool.objects.for_user(user) return self.filter( org_scope - & (Q(created_by=user) | Q(shared_users=user) | Q(shared_to_org=True)) + & ( + Q(created_by=user) + | Q(shared_users=user) + | Q(shared_to_org=True) + | Q(prompt_studio_tool__in=accessible_tools) + ) ).distinct() diff --git a/backend/tool_instance_v2/views.py b/backend/tool_instance_v2/views.py index 055ffb4d6d..3f4dc1afd8 100644 --- a/backend/tool_instance_v2/views.py +++ b/backend/tool_instance_v2/views.py @@ -4,13 +4,14 @@ from account_v2.custom_exceptions import DuplicateData from django.db import IntegrityError +from django.db.models import Q from django.db.models.query import QuerySet +from permissions.permission import IsParentWorkflowOwner from rest_framework import serializers, status, viewsets from rest_framework.decorators import api_view from rest_framework.request import Request from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning -from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.filtering import FilterHelper from utils.user_session import UserSessionUtils from workflow_manager.workflow_v2.constants import WorkflowKey @@ -94,21 +95,26 @@ def get_queryset(self) -> QuerySet: RequestKey.CREATED_BY, RequestKey.WORKFLOW, ) - - # Per-creator scope for regular users avoids leaking sibling rows - # in shared workflows; admins and service accounts get org-wide. + # Union: own rows + rows under accessible workflows. The parent-shared + # branch lets ``IsParentWorkflowOwner`` return 403 on sibling mutation + # attempts instead of DRF raising 404 first. Admins / service accounts + # get org-wide via ``Workflow.for_user``'s short-circuit. user = self.request.user - if getattr( - user, "is_service_account", False - ) or OrganizationMemberService.is_user_organization_admin(user): - accessible_workflows = Workflow.objects.for_user(user) - queryset = ToolInstance.objects.filter(workflow__in=accessible_workflows) - else: - queryset = ToolInstance.objects.filter(created_by=user) + accessible_workflows = Workflow.objects.for_user(user) + queryset = ToolInstance.objects.filter( + Q(created_by=user) | Q(workflow__in=accessible_workflows) + ).distinct() if filter_args: queryset = queryset.filter(**filter_args) return queryset + def get_permissions(self) -> list[Any]: + # Mutation requires parent-workflow ownership; sibling read access + # via shared workflows must not imply mutate rights. + if self.action in ("update", "partial_update", "destroy"): + return [IsParentWorkflowOwner()] + return list(super().get_permissions()) + def get_serializer_class(self) -> serializers.Serializer: if self.action == "reorder": return TIReorderSerializer From 4389e62e96a44f0e1bbbe5b121d65fd0e8cc3b6e Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Sat, 30 May 2026 13:28:41 +0530 Subject: [PATCH 16/21] UN-2977 [FIX] Consolidate shareable-resource registry and harden membership cleanup Address review feedback on PR #1986: - Add a single SHAREABLE_RESOURCES registry (tenant_account_v2/ shareable_resources.py) and drive both the org-membership cleanup signals and the group blast-radius view from it, so the two lists can no longer drift. AgenticProject is now included, fixing the under-reported group delete count on cloud. - Purge shared_users via the unscoped M2M through table scoped by the resource's organization, instead of the UserContext-org-scoped default manager which matched zero rows outside an HTTP request (e.g. tests / management commands), silently skipping the rejoin-backdoor cleanup. - Tighten the adapter get_permissions comment and correct the pipeline shared_groups comment to match the other resource models. Co-Authored-By: Claude Opus 4.8 --- backend/adapter_processor_v2/views.py | 6 +- backend/pipeline_v2/models.py | 8 +-- backend/tenant_account_v2/group_views.py | 32 ++++------- .../tenant_account_v2/shareable_resources.py | 51 +++++++++++++++++ backend/tenant_account_v2/signals.py | 56 +++++++++---------- 5 files changed, 94 insertions(+), 59 deletions(-) create mode 100644 backend/tenant_account_v2/shareable_resources.py diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 6b6c082b04..8d40806bd7 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -140,10 +140,8 @@ class AdapterInstanceViewSet(ResourceShareManagementMixin, ModelViewSet): serializer_class = AdapterInstanceSerializer def get_permissions(self) -> list[Any]: - # Frictionless adapter quirk preserved: blocks update + retrieve so - # frictionless adapters stay hidden from non-owners, and lets any - # org member delete them. Non-frictionless adapters fall through to - # the standard owner / shared-user gating. + # Frictionless adapters: hidden from non-owners (update/retrieve), + # deletable by any org member. Others use owner / shared-user gating. if self.action in ["update", "partial_update", "retrieve"]: return [IsFrictionLessAdapter()] if self.action == "destroy": diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 7aea106521..9cbdcd34f6 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -129,11 +129,9 @@ class PipelineStatus(models.TextChoices): default=False, db_comment="Whether this pipeline is shared with the entire organization", ) - # ``shared_groups`` is no longer an M2M; group shares are stored - # polymorphically in ``tenant_account_v2.ResourceGroupShare``. The - # property below preserves the ergonomic ``instance.shared_groups`` - # read surface (queryset of ``OrganizationGroup``) so DRF and existing - # callers don't have to change. + # ``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): diff --git a/backend/tenant_account_v2/group_views.py b/backend/tenant_account_v2/group_views.py index d80df78703..2e10d3bd57 100644 --- a/backend/tenant_account_v2/group_views.py +++ b/backend/tenant_account_v2/group_views.py @@ -190,36 +190,28 @@ def _collect_resources_shared_with_group( ) -> 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. + Resolves each shareable model from ``SHAREABLE_RESOURCES`` via + ``apps.get_model`` (cloud-only models absent in OSS skip cleanly), so this + view and the cleanup signals share one source of truth and cannot drift. """ - from adapter_processor_v2.models import AdapterInstance - from api_v2.models import APIDeployment - from connector_v2.models import ConnectorInstance - from pipeline_v2.models import Pipeline - from prompt_studio.prompt_studio_core_v2.models import CustomTool - from workflow_manager.workflow_v2.models.workflow import Workflow - - sources = ( - ("workflow", Workflow, "workflow_name", "id"), - ("pipeline", Pipeline, "pipeline_name", "id"), - ("api_deployment", APIDeployment, "display_name", "id"), - ("adapter_instance", AdapterInstance, "adapter_name", "id"), - ("connector_instance", ConnectorInstance, "connector_name", "id"), - ("custom_tool", CustomTool, "tool_name", "tool_id"), - ) + from django.apps import apps + from tenant_account_v2.shareable_resources import SHAREABLE_RESOURCES 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: + for resource in SHAREABLE_RESOURCES: + try: + model = apps.get_model(resource.app_label, resource.model_name) + except LookupError: + continue # cloud-only app not installed in this deployment qs = list_resources_shared_with_group(group, model).values_list( - id_field, name_field + resource.id_field, resource.name_field ) for resource_id, name in qs: results.append( { - "resource_type": kind, + "resource_type": resource.kind, "resource_id": str(resource_id), "name": name, } diff --git a/backend/tenant_account_v2/shareable_resources.py b/backend/tenant_account_v2/shareable_resources.py new file mode 100644 index 0000000000..44216e7e19 --- /dev/null +++ b/backend/tenant_account_v2/shareable_resources.py @@ -0,0 +1,51 @@ +"""Single source of truth for group-shareable resource models. + +Both the org-membership cleanup signals and the group blast-radius view need to +know "what is shareable". Keeping two hardcoded lists let them drift (the cloud +``AgenticProject`` was missing from one). This registry holds string-only +descriptors so it is import-cheap and cycle-free; consumers resolve the actual +model lazily via ``django.apps.apps.get_model`` and skip apps absent from a +given deployment (e.g. the cloud-only agentic app in pure OSS). +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ShareableResource: + """Static descriptor for one group-shareable resource model.""" + + app_label: str + model_name: str + kind: str # serialization key surfaced in API payloads + name_field: str # human-readable display field + id_field: str # primary-key field name + + +# ``agentic_studio_v1`` is cloud-only; consumers resolve it lazily and skip it +# when the app is not installed. +SHAREABLE_RESOURCES: tuple[ShareableResource, ...] = ( + ShareableResource("workflow_v2", "Workflow", "workflow", "workflow_name", "id"), + ShareableResource("pipeline_v2", "Pipeline", "pipeline", "pipeline_name", "id"), + ShareableResource("api_v2", "APIDeployment", "api_deployment", "display_name", "id"), + ShareableResource( + "connector_v2", "ConnectorInstance", "connector_instance", "connector_name", "id" + ), + ShareableResource( + "adapter_processor_v2", + "AdapterInstance", + "adapter_instance", + "adapter_name", + "id", + ), + ShareableResource( + "prompt_studio_core_v2", "CustomTool", "custom_tool", "tool_name", "tool_id" + ), + ShareableResource( + "agentic_studio_v1", + "AgenticProject", + "agentic_project", + "project_name", + "project_id", + ), +) diff --git a/backend/tenant_account_v2/signals.py b/backend/tenant_account_v2/signals.py index e7a270f48d..8a4981a1f6 100644 --- a/backend/tenant_account_v2/signals.py +++ b/backend/tenant_account_v2/signals.py @@ -2,26 +2,15 @@ from django.apps import apps from django.db import transaction +from django.db.models.fields.related import ManyToManyRel from django.db.models.signals import post_delete from django.dispatch import receiver from tenant_account_v2.models import GroupMembership, OrganizationMember +from tenant_account_v2.shareable_resources import SHAREABLE_RESOURCES logger = logging.getLogger(__name__) -# (app_label, model_name) for every shareable resource. Lazy-loaded via -# ``apps.get_model`` so signals can fire before cross-app imports resolve, and -# so OSS-only deployments without the cloud agentic app skip cleanly. -_SHAREABLE_MODELS: tuple[tuple[str, str], ...] = ( - ("workflow_v2", "Workflow"), - ("pipeline_v2", "Pipeline"), - ("api_v2", "APIDeployment"), - ("connector_v2", "ConnectorInstance"), - ("adapter_processor_v2", "AdapterInstance"), - ("prompt_studio_core_v2", "CustomTool"), - ("agentic_studio_v1", "AgenticProject"), # cloud-only -) - @receiver(post_delete, sender=OrganizationMember) def cleanup_user_org_access( @@ -54,29 +43,34 @@ def cleanup_user_org_access( instance.organization_id, ) - for app_label, model_name in _SHAREABLE_MODELS: + for resource in SHAREABLE_RESOURCES: try: - model = apps.get_model(app_label, model_name) + model = apps.get_model(resource.app_label, resource.model_name) except LookupError: # App not installed in this deployment (e.g. cloud-only # agentic_studio_v1 in pure OSS). Skip cleanly. continue + # Delete via the M2M through table, not ``model.objects``: the + # default manager is org-scoped on ``UserContext`` (None outside + # an HTTP request), so it would match zero rows in tests / + # management commands. The through manager is unscoped; scope it + # explicitly by the resource's own organization. + m2m_rel = model._meta.get_field("shared_users").remote_field + assert isinstance(m2m_rel, ManyToManyRel) + through = m2m_rel.through + source_fk = model._meta.model_name 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 + removed, _ = through.objects.filter( + user=instance.user, + **{f"{source_fk}__organization": instance.organization}, + ).delete() 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, + resource.app_label, + resource.model_name, instance.organization_id, ) raise @@ -85,8 +79,8 @@ def cleanup_user_org_access( "Removed user=%s from shared_users on %s %s.%s rows in org=%s", instance.user_id, removed, - app_label, - model_name, + resource.app_label, + resource.model_name, instance.organization_id, ) @@ -121,15 +115,17 @@ def _connect_resource_group_share_cleanup() -> None: 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: + for resource in SHAREABLE_RESOURCES: try: - model = apps.get_model(app_label, model_name) + model = apps.get_model(resource.app_label, resource.model_name) except LookupError: continue post_delete.connect( cleanup_resource_group_shares, sender=model, - dispatch_uid=f"cleanup_resource_group_shares_{app_label}_{model_name}", + dispatch_uid=( + f"cleanup_resource_group_shares_{resource.app_label}_{resource.model_name}" + ), ) From 6a5493dc46482cee1e1bf22a5fc27a8dfda40070 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Sat, 30 May 2026 14:06:04 +0530 Subject: [PATCH 17/21] UN-2977 [FIX] Correct AgenticProject field names in shareable registry The AgenticProject descriptor in SHAREABLE_RESOURCES hardcoded name_field="project_name"/id_field="project_id", copied from the CustomTool tool_id/tool_name pattern. The real cloud model (agentic_studio_v1.AgenticProject, cloud PR #1508) uses ORM fields "id" (UUID PK) and "name"; project_id/ project_name exist only as dict keys in get_pipeline_status(). On cloud this made the group blast-radius view run values_list("project_id","project_name") -> FieldError/500. Pure OSS was unaffected (app absent, skipped via LookupError). Co-Authored-By: Claude Opus 4.8 --- backend/tenant_account_v2/shareable_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tenant_account_v2/shareable_resources.py b/backend/tenant_account_v2/shareable_resources.py index 44216e7e19..f528e2959f 100644 --- a/backend/tenant_account_v2/shareable_resources.py +++ b/backend/tenant_account_v2/shareable_resources.py @@ -45,7 +45,7 @@ class ShareableResource: "agentic_studio_v1", "AgenticProject", "agentic_project", - "project_name", - "project_id", + "name", + "id", ), ) From e36ccf01e43baf7524a3fa0d6f148a8838912698 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Sat, 30 May 2026 21:06:46 +0530 Subject: [PATCH 18/21] UN-2977 [FIX] Make share-modal user revoke type-agnostic handleDeleteUser compared a numeric item.id against selectedUsers, which holds a mix of stringified (pre-loaded) and numeric (in-session) IDs, so "Revoke Access" was a silent no-op for already-shared users. Normalize both sides with String() so removal works regardless of ID type. Co-Authored-By: Claude Opus 4.8 --- .../components/widgets/share-permission/SharePermission.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/widgets/share-permission/SharePermission.jsx b/frontend/src/components/widgets/share-permission/SharePermission.jsx index 956ae9c181..433e90b25d 100644 --- a/frontend/src/components/widgets/share-permission/SharePermission.jsx +++ b/frontend/src/components/widgets/share-permission/SharePermission.jsx @@ -79,7 +79,9 @@ function SharePermission({ }, [adapter, allUsers]); const handleDeleteUser = (userId) => { - setSelectedUsers((prev) => prev.filter((user) => user !== userId)); + setSelectedUsers((prev) => + prev.filter((user) => String(user) !== String(userId)), + ); }; const handleDeleteGroup = (groupId) => { From b1a840d8c40f2b6580f91a7f8a052e38e7149640 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Mon, 1 Jun 2026 14:56:41 +0530 Subject: [PATCH 19/21] UN-2977 [FIX] Address review: reorder authz, effective-member adapter cleanup, tenant hardening + tests - Gate tool-instance reorder on workflow ownership; scope helper lookup - Clear default adapter on effective-access loss (group/org), not just direct - Harden group-share cleanup signal + skip models lacking shared_users - Org-scope sharing read helpers + reject cross-org group writes - Add registry drift system check; fix groups-service route; restore StrictMode - useShareModal fallback + GroupMemberManager shape normalization - Add group-sharing test suite (15 tests: matrix, signals, visibility, registry) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/adapter_processor_v2/views.py | 17 +- backend/permissions/permission.py | 21 +- backend/tenant_account_v2/apps.py | 2 +- backend/tenant_account_v2/checks.py | 44 +++ backend/tenant_account_v2/sharing_helpers.py | 30 +- backend/tenant_account_v2/signals.py | 30 +- backend/tenant_account_v2/tests.py | 272 +++++++++++++++++- .../tool_instance_v2/tool_instance_helper.py | 14 +- backend/tool_instance_v2/views.py | 17 +- .../components/groups/GroupMemberManager.jsx | 23 +- .../src/components/groups/groups-service.js | 2 +- frontend/src/hooks/useShareModal.js | 4 +- frontend/src/index.jsx | 21 +- 13 files changed, 454 insertions(+), 43 deletions(-) create mode 100644 backend/tenant_account_v2/checks.py diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 8d40806bd7..d1ac083e77 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -309,20 +309,29 @@ def share(self, request: Request, pk: str | None = None) -> Response: who lost access. ``shared_users`` is read-only on the serializer, so unsharing happens - only here (not via ``partial_update``). Diff the M2M before/after the - commit so cleanup keys off who actually lost access. + only here (not via ``partial_update``). Diff *effective* access + (direct + group + org) before/after the commit so cleanup also covers + users who lose access via a group-unshare or ``shared_to_org`` + toggle-off, not just direct removals. """ adapter = self.get_object() - before_user_ids = set(adapter.shared_users.values_list("id", flat=True)) + before_user_ids = self._effective_member_ids(adapter) response = super().share(request, pk) if response.status_code == status.HTTP_200_OK: adapter.refresh_from_db() - after_user_ids = set(adapter.shared_users.values_list("id", flat=True)) + after_user_ids = self._effective_member_ids(adapter) self._clear_default_adapter_for_removed_users( adapter, before_user_ids - after_user_ids ) return response + @staticmethod + def _effective_member_ids(adapter: AdapterInstance) -> set[int]: + """User ids with effective access to ``adapter`` (direct/group/org).""" + from tenant_account_v2.sharing_helpers import compute_effective_members + + return {member["user_id"] for member in compute_effective_members(adapter)} + def _notify_shared_users( self, adapter: AdapterInstance, diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 07cc3c4b97..601b17eb3f 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -77,6 +77,21 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo return False +def is_workflow_mutator(request: Request, workflow: Any) -> bool: + """Whether the request user may mutate ``workflow`` or its sub-resources. + + Admits the workflow's owner, an org admin, or a service account. Shared + access (direct/group/org) grants read only, never mutate. Shared by the + object-level gate (``IsParentWorkflowOwner``) and the collection-level + ``reorder`` action, which can't use an object-permission class. + """ + if _is_service_account(request): + return True + if workflow.created_by == request.user: + return True + return _is_organization_admin(request) + + class IsParentWorkflowOwner(permissions.BasePermission): """Mutation gate for nested workflow sub-resources. @@ -87,11 +102,7 @@ class IsParentWorkflowOwner(permissions.BasePermission): """ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: - if _is_service_account(request): - return True - if obj.workflow.created_by == request.user: - return True - return _is_organization_admin(request) + return is_workflow_mutator(request, obj.workflow) class IsOrganizationMember(permissions.BasePermission): diff --git a/backend/tenant_account_v2/apps.py b/backend/tenant_account_v2/apps.py index e2e3ed1150..a7e95821e3 100644 --- a/backend/tenant_account_v2/apps.py +++ b/backend/tenant_account_v2/apps.py @@ -6,4 +6,4 @@ class TenantAccountV2Config(AppConfig): name = "tenant_account_v2" def ready(self): - from tenant_account_v2 import signals # noqa: F401 + from tenant_account_v2 import checks, signals # noqa: F401 diff --git a/backend/tenant_account_v2/checks.py b/backend/tenant_account_v2/checks.py new file mode 100644 index 0000000000..412162bfd2 --- /dev/null +++ b/backend/tenant_account_v2/checks.py @@ -0,0 +1,44 @@ +"""System checks for the group-sharing resource registry (UN-2977). + +``SHAREABLE_RESOURCES`` descriptors are free strings resolved lazily via +``apps.get_model`` + ``values_list(id_field, name_field)``, so a typo otherwise +surfaces only as a runtime ``FieldError`` on the path touching that resource. +This check validates every installed descriptor at ``manage.py check`` / CI / +boot, catching drift early (e.g. the ``6a5493dc`` field-name fix). +""" + +from typing import Any + +from django.apps import apps +from django.core.checks import Error, register +from django.core.exceptions import FieldDoesNotExist + +from tenant_account_v2.shareable_resources import SHAREABLE_RESOURCES + + +@register() +def check_shareable_resources_registry(app_configs: Any, **kwargs: Any) -> list[Error]: + """Verify each registered shareable resource resolves and its fields exist.""" + errors: list[Error] = [] + for resource in SHAREABLE_RESOURCES: + try: + model = apps.get_model(resource.app_label, resource.model_name) + except LookupError: + # App not installed in this deployment (e.g. cloud-only + # agentic_studio_v1 in pure OSS). Not a drift error. + continue + for attr in ("id_field", "name_field"): + field_name = getattr(resource, attr) + try: + model._meta.get_field(field_name) + except FieldDoesNotExist: + errors.append( + Error( + f"ShareableResource '{resource.kind}' declares " + f"{attr}='{field_name}', which is not a field on " + f"{resource.app_label}.{resource.model_name}.", + hint="Fix the field name in SHAREABLE_RESOURCES.", + id="tenant_account_v2.E001", + ) + ) + return errors diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py index 407313de08..6edbd39ae3 100644 --- a/backend/tenant_account_v2/sharing_helpers.py +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -65,12 +65,19 @@ def validate_shared_groups_in_org( 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( + filters: dict[str, Any] = { + "resource_shares__content_type": ContentType.objects.get_for_model( type(resource_obj) ), - resource_shares__object_id=str(resource_obj.pk), - ) + "resource_shares__object_id": str(resource_obj.pk), + } + # Defense in depth: constrain to the resource's org so tenant isolation + # lives in the read query itself, not only in UUID uniqueness + write-time + # checks. ``organization_id`` is present on every shareable resource. + organization_id = getattr(resource_obj, "organization_id", None) + if organization_id is not None: + filters["resource_shares__organization_id"] = organization_id + return OrganizationGroup.objects.filter(**filters) @transaction.atomic @@ -105,6 +112,20 @@ def set_resource_share_groups(resource_obj: Any, group_ids: Iterable[int]) -> No current_qs.filter(group_id__in=to_remove).delete() if to_add: + # Guard the representable illegal state: a share row whose + # ``organization`` disagrees with its group's. Callers validate org + # membership upstream, so a mismatch here is a programming error. + valid_ids = set( + OrganizationGroup.objects.filter( + id__in=to_add, organization_id=organization_id + ).values_list("id", flat=True) + ) + invalid = to_add - valid_ids + if invalid: + raise ValueError( + "Cannot share with groups outside the resource's organization: " + f"{sorted(invalid)}" + ) ResourceGroupShare.objects.bulk_create( [ ResourceGroupShare( @@ -151,6 +172,7 @@ def list_resources_shared_with_group( raw_ids = ResourceGroupShare.objects.filter( group=group, content_type=ContentType.objects.get_for_model(model), + organization_id=group.organization_id, ).values_list("object_id", flat=True) if isinstance(model._meta.pk, models.UUIDField): pks: list[Any] = _safe_uuids(raw_ids) diff --git a/backend/tenant_account_v2/signals.py b/backend/tenant_account_v2/signals.py index 8a4981a1f6..a705a04777 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.core.exceptions import FieldDoesNotExist from django.db import transaction from django.db.models.fields.related import ManyToManyRel from django.db.models.signals import post_delete @@ -55,7 +56,14 @@ def cleanup_user_org_access( # an HTTP request), so it would match zero rows in tests / # management commands. The through manager is unscoped; scope it # explicitly by the resource's own organization. - m2m_rel = model._meta.get_field("shared_users").remote_field + try: + m2m_rel = model._meta.get_field("shared_users").remote_field + except FieldDoesNotExist: + # A registered model can legitimately lack the sharing field + # during the OSS<->cloud sync window (e.g. AgenticProject + # before #1508 applies its migration). Group memberships were + # already purged above; skip the direct-share purge here. + continue assert isinstance(m2m_rel, ManyToManyRel) through = m2m_rel.through source_fk = model._meta.model_name @@ -97,10 +105,22 @@ def cleanup_resource_group_shares( 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() + # post_delete fires inside the resource-delete transaction; on failure the + # delete rolls back. Mirror ``cleanup_user_org_access``: name the + # sender/pk so the rollback is diagnosable, then re-raise. + try: + deleted, _ = ResourceGroupShare.objects.filter( + content_type=ContentType.objects.get_for_model(sender), + object_id=str(instance.pk), + ).delete() + except Exception: + logger.exception( + "Failed purging ResourceGroupShare rows after %s(%s) delete; " + "rolling back the resource deletion", + sender.__name__, + instance.pk, + ) + raise if deleted: logger.info( "Removed %s ResourceGroupShare rows after %s(%s) delete", diff --git a/backend/tenant_account_v2/tests.py b/backend/tenant_account_v2/tests.py index a39b155ac3..90e555629d 100644 --- a/backend/tenant_account_v2/tests.py +++ b/backend/tenant_account_v2/tests.py @@ -1 +1,271 @@ -# Create your tests here. +"""Tests for org-scoped group sharing (UN-2977 / mfbt UNS-612). + +Covers the security-sensitive surface: the ``ShareAuthorizationService`` +authorization matrix (incl. atomicity), the membership/orphan cleanup signals, +``set_resource_share_groups`` diff semantics, the ``for_user()`` group-visibility +filter, and the shareable-resource registry. + +Admin resolution (``is_org_admin`` / ``is_user_organization_admin``) is patched +to a deterministic predicate so these tests exercise the sharing logic itself, +not the active authentication plugin's role handling. +""" + +from unittest.mock import patch + +from account_v2.models import Organization, User +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldDoesNotExist +from django.test import TestCase +from rest_framework.exceptions import PermissionDenied +from utils.user_context import UserContext +from workflow_manager.workflow_v2.models.workflow import Workflow + +from tenant_account_v2.models import ( + GroupMembership, + OrganizationGroup, + OrganizationMember, + ResourceGroupShare, +) +from tenant_account_v2.shareable_resources import SHAREABLE_RESOURCES +from tenant_account_v2.sharing_helpers import ( + ShareAuthorizationService, + get_resource_share_groups, + set_resource_share_groups, +) + + +def _make_user(email: str, **kwargs) -> User: + return User.objects.create_user( + username=email, email=email, password="irrelevant", **kwargs + ) + + +def _shared_group_ids(resource) -> set[int]: + return set(get_resource_share_groups(resource).values_list("id", flat=True)) + + +def _shared_user_ids(resource) -> set[int]: + return set(resource.shared_users.values_list("id", flat=True)) + + +class GroupSharingTestBase(TestCase): + """Shared fixtures: one org with an owner, a group member, and an outsider.""" + + def setUp(self) -> None: + self.org = Organization.objects.create( + name="org-a", display_name="Org A", organization_id="org-a" + ) + UserContext.set_organization_identifier(self.org.organization_id) + + self.owner = _make_user("owner@example.com") + self.member = _make_user("member@example.com") # belongs to self.group + self.outsider = _make_user("outsider@example.com") # org member, no group + self.admin = _make_user("admin@example.com") + for user, role in ( + (self.owner, "user"), + (self.member, "user"), + (self.outsider, "user"), + (self.admin, "admin"), + ): + OrganizationMember.objects.create(organization=self.org, user=user, role=role) + + self.group = OrganizationGroup.objects.create( + organization=self.org, name="Team", created_by=self.owner + ) + GroupMembership.objects.create(group=self.group, user=self.member) + + self.workflow = Workflow.objects.create( + workflow_name="wf-1", organization=self.org, created_by=self.owner + ) + + +class ShareAuthorizationServiceTests(GroupSharingTestBase): + """The authorization matrix from the UN-2977 plan.""" + + def setUp(self) -> None: + super().setUp() + # Deterministic admin predicate — only ``self.admin`` is an admin. + patcher = patch( + "tenant_account_v2.sharing_helpers.is_org_admin", + side_effect=lambda user: getattr(user, "email", None) == self.admin.email, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _authorize(self, actor, desired) -> None: + ShareAuthorizationService.authorize_and_commit( + actor=actor, resource=self.workflow, desired=desired + ) + + def test_owner_can_add_and_remove_users(self) -> None: + self._authorize(self.owner, {"shared_users": [self.outsider.id]}) + self.assertEqual(_shared_user_ids(self.workflow), {self.outsider.id}) + self._authorize(self.owner, {"shared_users": []}) + self.assertEqual(_shared_user_ids(self.workflow), set()) + + def test_owner_can_add_group_and_toggle_org(self) -> None: + self._authorize( + self.owner, {"shared_groups": [self.group.id], "shared_to_org": True} + ) + self.assertEqual(_shared_group_ids(self.workflow), {self.group.id}) + self.workflow.refresh_from_db() + self.assertTrue(self.workflow.shared_to_org) + + def test_admin_can_remove_users(self) -> None: + self.workflow.shared_users.add(self.member) + self._authorize(self.admin, {"shared_users": []}) + self.assertEqual(_shared_user_ids(self.workflow), set()) + + def test_unprivileged_cannot_remove_users(self) -> None: + self.workflow.shared_users.add(self.member, self.outsider) + with self.assertRaises(PermissionDenied): + # outsider (a shared user, not owner/admin) tries to drop member + self._authorize(self.outsider, {"shared_users": [self.outsider.id]}) + self.assertEqual( + _shared_user_ids(self.workflow), {self.member.id, self.outsider.id} + ) + + def test_unprivileged_cannot_toggle_org(self) -> None: + with self.assertRaises(PermissionDenied): + self._authorize(self.outsider, {"shared_to_org": True}) + self.workflow.refresh_from_db() + self.assertFalse(self.workflow.shared_to_org) + + def test_group_member_can_add_only_their_groups(self) -> None: + other_group = OrganizationGroup.objects.create( + organization=self.org, name="Other", created_by=self.owner + ) + # member belongs to self.group → allowed + self._authorize(self.member, {"shared_groups": [self.group.id]}) + self.assertEqual(_shared_group_ids(self.workflow), {self.group.id}) + # member is not in other_group → denied + with self.assertRaises(PermissionDenied): + self._authorize( + self.member, {"shared_groups": [self.group.id, other_group.id]} + ) + + def test_service_account_bypasses_authorization(self) -> None: + svc = _make_user("svc@example.com", is_service_account=True) + self._authorize(svc, {"shared_to_org": True}) + self.workflow.refresh_from_db() + self.assertTrue(self.workflow.shared_to_org) + + def test_authorize_is_atomic_on_partial_denial(self) -> None: + """A denial on any axis must leave every axis uncommitted.""" + self.workflow.shared_users.add(self.outsider) + with self.assertRaises(PermissionDenied): + # users add is allowed for a shared user, but the org toggle isn't + self._authorize( + self.outsider, + { + "shared_users": [self.outsider.id, self.member.id], + "shared_to_org": True, + }, + ) + self.assertNotIn(self.member.id, _shared_user_ids(self.workflow)) + self.workflow.refresh_from_db() + self.assertFalse(self.workflow.shared_to_org) + + +class SetResourceShareGroupsTests(GroupSharingTestBase): + """Polymorphic ``.set()``-style diff semantics + cross-org guard.""" + + def _count(self) -> int: + return ResourceGroupShare.objects.filter( + content_type=ContentType.objects.get_for_model(Workflow), + object_id=str(self.workflow.pk), + ).count() + + def test_add_remove_and_noop(self) -> None: + set_resource_share_groups(self.workflow, [self.group.id]) + self.assertEqual(self._count(), 1) + set_resource_share_groups(self.workflow, [self.group.id]) # no-op + self.assertEqual(self._count(), 1) + set_resource_share_groups(self.workflow, []) # remove + self.assertEqual(self._count(), 0) + + def test_cross_org_group_rejected(self) -> None: + other_org = Organization.objects.create( + name="org-b", display_name="Org B", organization_id="org-b" + ) + foreign_group = OrganizationGroup.objects.create( + organization=other_org, name="Foreign", created_by=self.owner + ) + with self.assertRaises(ValueError): + set_resource_share_groups(self.workflow, [foreign_group.id]) + self.assertEqual(self._count(), 0) + + +class ForUserGroupVisibilityTests(GroupSharingTestBase): + """Group-shared resources appear for members and track membership live.""" + + def setUp(self) -> None: + super().setUp() + patcher = patch( + "tenant_account_v2.organization_member_service." + "OrganizationMemberService.is_user_organization_admin", + return_value=False, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_group_share_visible_to_member_only(self) -> None: + set_resource_share_groups(self.workflow, [self.group.id]) + self.assertIn(self.workflow, Workflow.objects.for_user(self.member)) + self.assertNotIn(self.workflow, Workflow.objects.for_user(self.outsider)) + + def test_membership_change_takes_effect_immediately(self) -> None: + set_resource_share_groups(self.workflow, [self.group.id]) + self.assertNotIn(self.workflow, Workflow.objects.for_user(self.outsider)) + GroupMembership.objects.create(group=self.group, user=self.outsider) + self.assertIn(self.workflow, Workflow.objects.for_user(self.outsider)) + + +class SignalCleanupTests(GroupSharingTestBase): + """The two ``post_delete`` cleanups: rejoin-backdoor and orphan prevention.""" + + def test_org_member_removal_purges_memberships_and_direct_shares(self) -> None: + self.workflow.shared_users.add(self.member) + set_resource_share_groups(self.workflow, [self.group.id]) + + OrganizationMember.objects.get(user=self.member).delete() + + self.assertFalse( + GroupMembership.objects.filter(user=self.member, group=self.group).exists() + ) + self.assertNotIn(self.member.id, _shared_user_ids(self.workflow)) + + def test_resource_delete_purges_group_shares(self) -> None: + set_resource_share_groups(self.workflow, [self.group.id]) + content_type = ContentType.objects.get_for_model(Workflow) + workflow_pk = str(self.workflow.pk) + + self.workflow.delete() + + self.assertFalse( + ResourceGroupShare.objects.filter( + content_type=content_type, object_id=workflow_pk + ).exists() + ) + + +class ShareableResourceRegistryTests(TestCase): + """Each installed descriptor must resolve and expose its declared fields.""" + + def test_descriptors_resolve_and_fields_exist(self) -> None: + from django.apps import apps + + for resource in SHAREABLE_RESOURCES: + try: + model = apps.get_model(resource.app_label, resource.model_name) + except LookupError: + continue # cloud-only app absent in this deployment + for attr in ("id_field", "name_field"): + field_name = getattr(resource, attr) + try: + model._meta.get_field(field_name) + except FieldDoesNotExist: + self.fail( + f"{resource.kind}.{attr}={field_name!r} is not a field on " + f"{resource.app_label}.{resource.model_name}" + ) diff --git a/backend/tool_instance_v2/tool_instance_helper.py b/backend/tool_instance_v2/tool_instance_helper.py index 29183759b3..107dfc2bc8 100644 --- a/backend/tool_instance_v2/tool_instance_helper.py +++ b/backend/tool_instance_v2/tool_instance_helper.py @@ -263,16 +263,24 @@ def get_relative_path(absolute_path: str, base_path: str) -> str: return relative_path @staticmethod - def reorder_tool_instances(instances_to_reorder: list[uuid.UUID]) -> None: + def reorder_tool_instances( + instances_to_reorder: list[uuid.UUID], + workflow_id: uuid.UUID | None = None, + ) -> None: """Reorders tool instances based on the list of tool UUIDs received. Saves the instance in the DB. Args: instances_to_reorder (list[uuid.UUID]): Desired order of tool UUIDs + workflow_id (uuid.UUID | None): When given, scope the lookup to + this workflow so a stray UUID can't reach a foreign row. """ - logger.info(f"Reordering instances: {instances_to_reorder}") + logger.info("Reordering instances: %s", instances_to_reorder) + queryset = ToolInstance.objects + if workflow_id is not None: + queryset = queryset.filter(workflow_id=workflow_id) for step, tool_instance_id in enumerate(instances_to_reorder): - tool_instance = ToolInstance.objects.get(pk=tool_instance_id) + tool_instance = queryset.get(pk=tool_instance_id) tool_instance.step = step + 1 tool_instance.save() diff --git a/backend/tool_instance_v2/views.py b/backend/tool_instance_v2/views.py index 3f4dc1afd8..f37388ece9 100644 --- a/backend/tool_instance_v2/views.py +++ b/backend/tool_instance_v2/views.py @@ -6,9 +6,11 @@ from django.db import IntegrityError from django.db.models import Q from django.db.models.query import QuerySet -from permissions.permission import IsParentWorkflowOwner +from django.shortcuts import get_object_or_404 +from permissions.permission import IsParentWorkflowOwner, is_workflow_mutator from rest_framework import serializers, status, viewsets from rest_framework.decorators import api_view +from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning @@ -191,7 +193,18 @@ def reorder(self, request: Any, **kwargs: Any) -> Response: WorkflowKey.WF_TOOL_INSTANCES ] - ToolInstanceHelper.reorder_tool_instances(instances_to_reorder) + # Reordering mutates step ordering, so require owner / org-admin / + # service-account — parity with update/destroy. This is a collection + # action (no get_object), so IsParentWorkflowOwner can't gate it; the + # for_user() fetch also scopes out cross-org workflow ids (404). + workflow = get_object_or_404(Workflow.objects.for_user(request.user), pk=wf_id) + if not is_workflow_mutator(request, workflow): + raise PermissionDenied( + "Only the workflow owner or an organization admin can " + "reorder its tool instances." + ) + + ToolInstanceHelper.reorder_tool_instances(instances_to_reorder, workflow_id=wf_id) tool_instances = ToolInstance.objects.get_instances_for_workflow(workflow=wf_id) ti_serializer = ToolInstanceSerializer(instance=tool_instances, many=True) return Response(ti_serializer.data, status=status.HTTP_200_OK) diff --git a/frontend/src/components/groups/GroupMemberManager.jsx b/frontend/src/components/groups/GroupMemberManager.jsx index ae83c87607..1c1a1eb122 100644 --- a/frontend/src/components/groups/GroupMemberManager.jsx +++ b/frontend/src/components/groups/GroupMemberManager.jsx @@ -9,6 +9,21 @@ import { SpinnerLoader } from "../widgets/spinner-loader/SpinnerLoader.jsx"; import { groupsService } from "./groups-service.js"; +// getAllOrgUsers may return a bare array, { members: [...] }, or { users: [...] }. +// Mirror useShareModal's defensive handling so the candidate list can't +// silently go empty if the response shape changes. +const normalizeOrgUsers = (data) => { + let list = []; + if (Array.isArray(data)) { + list = data; + } else if (Array.isArray(data?.members)) { + list = data.members; + } else if (Array.isArray(data?.users)) { + list = data.users; + } + return list.map((user) => ({ id: user.id, email: user.email })); +}; + function GroupMemberManager({ open, group, onClose }) { const service = groupsService(); const handleException = useExceptionHandler(); @@ -27,13 +42,7 @@ function GroupMemberManager({ open, group, onClose }) { 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, - })), - ); + setOrgUsers(normalizeOrgUsers(usersRes?.data)); }) .catch((err) => setAlertDetails(handleException(err, "Failed to load"))) .finally(() => setLoading(false)); diff --git a/frontend/src/components/groups/groups-service.js b/frontend/src/components/groups/groups-service.js index 6a7b3b4af2..5cdbad81e1 100644 --- a/frontend/src/components/groups/groups-service.js +++ b/frontend/src/components/groups/groups-service.js @@ -4,7 +4,7 @@ import { useSessionStore } from "../../store/session-store"; const RESOURCE_PATHS = { workflow: "workflow", pipeline: "pipeline", - api_deployment: "api/api", + api_deployment: "api/deployment", adapter_instance: "adapter", connector_instance: "connector", custom_tool: "prompt-studio", diff --git a/frontend/src/hooks/useShareModal.js b/frontend/src/hooks/useShareModal.js index 3c380383f6..2d7628e29b 100644 --- a/frontend/src/hooks/useShareModal.js +++ b/frontend/src/hooks/useShareModal.js @@ -116,7 +116,9 @@ function useShareModal({ setOpenShareModal(false); }) .catch((err) => { - setAlertDetails(handleException(err)); + setAlertDetails( + handleException(err, "Unable to update sharing permissions"), + ); }) .finally(() => { setIsLoadingShare(false); diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 982ecdb17d..9a5a3edba3 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1,5 +1,6 @@ import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; +import React from "react"; import ReactDOM from "react-dom/client"; import { GenericLoader } from "./components/generic-loader/GenericLoader"; @@ -38,13 +39,15 @@ setFavicon(config.favicon); const root = ReactDOM.createRoot(document.getElementById("root")); root.render( - - - } - component={() => import("./App.jsx")} - componentName="App" - /> - - , + + + + } + component={() => import("./App.jsx")} + componentName="App" + /> + + + , ); From 10d95b07ed6707b8282474d19888c610221555b6 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Mon, 1 Jun 2026 15:56:50 +0530 Subject: [PATCH 20/21] UN-2977 [FIX] Replace hard-coded test password with generated secret (SonarCloud) Resolves SonarCloud 'Credentials should not be hard-coded' on tests.py; the literal is never used to authenticate. Flips New-Code Security Rating to A and passes the Quality Gate. Co-Authored-By: Claude Opus 4.8 --- backend/tenant_account_v2/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tenant_account_v2/tests.py b/backend/tenant_account_v2/tests.py index 90e555629d..664a8169d8 100644 --- a/backend/tenant_account_v2/tests.py +++ b/backend/tenant_account_v2/tests.py @@ -10,6 +10,7 @@ not the active authentication plugin's role handling. """ +import secrets from unittest.mock import patch from account_v2.models import Organization, User @@ -36,7 +37,7 @@ def _make_user(email: str, **kwargs) -> User: return User.objects.create_user( - username=email, email=email, password="irrelevant", **kwargs + username=email, email=email, password=secrets.token_urlsafe(), **kwargs ) From e7ff61294c20f1ce92f0b50590cea18089a7f822 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Mon, 1 Jun 2026 16:19:13 +0530 Subject: [PATCH 21/21] UN-2977 [FIX] Preserve adapter owner's default on shared_to_org toggle-off Effective-member diff in the adapter share action counted the owner as an org member while shared_to_org=True; toggling it off dropped the owner into the removed set and wiped their UserDefaultAdapter. The owner always retains access via created_by, so exclude them from cleanup. Co-Authored-By: Claude Opus 4.8 --- backend/adapter_processor_v2/views.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index d1ac083e77..fa346fd439 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -320,9 +320,12 @@ def share(self, request: Request, pk: str | None = None) -> Response: if response.status_code == status.HTTP_200_OK: adapter.refresh_from_db() after_user_ids = self._effective_member_ids(adapter) - self._clear_default_adapter_for_removed_users( - adapter, before_user_ids - after_user_ids - ) + removed = before_user_ids - after_user_ids + # The owner always retains access via ``created_by``; never clear + # their defaults on a share-axis change (e.g. a ``shared_to_org`` + # toggle-off, which drops the owner from the org-member set). + removed.discard(adapter.created_by_id) + self._clear_default_adapter_for_removed_users(adapter, removed) return response @staticmethod