From 8f2e5c6e167e381c9c577ba345ff448e77c2a9b6 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 12:10:06 +0530 Subject: [PATCH 1/3] UN-3318 [FIX] Stop auto-sharing platform-key-created resources with admin Resources created via a platform API key were auto-added to the key owner's shared_users list. Since org admins already see every resource via the for_user admin bypass, this entry granted no extra access and just surfaced a lone admin in the "Shared with" list. Remove the auto-add so shared_users reflects only explicit shares. Co-Authored-By: Claude Opus 4.8 --- backend/backend/serializers.py | 10 +------- .../prompt_studio_helper.py | 24 ------------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/backend/backend/serializers.py b/backend/backend/serializers.py index 62a92fc8a3..6f788f0284 100644 --- a/backend/backend/serializers.py +++ b/backend/backend/serializers.py @@ -11,15 +11,7 @@ def create(self, validated_data: dict[str, Any]) -> Any: if request: validated_data[RequestKey.CREATED_BY] = request.user validated_data[RequestKey.MODIFIED_BY] = request.user - instance = super().create(validated_data) - - # Auto-add key owner as co-owner for resources created via API key - if request and hasattr(request, "platform_api_key"): - platform_api_key = request.platform_api_key - if hasattr(instance, "shared_users") and platform_api_key.created_by: - instance.shared_users.add(platform_api_key.created_by) - - return instance + return super().create(validated_data) def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: if self.context.get(RequestKey.REQUEST): diff --git a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py index c2f0b6f0e9..89b55f8b18 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py +++ b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py @@ -2729,30 +2729,6 @@ def create_tool_from_import_data( organization=organization, ) - # When a service account creates a tool, add the API key owner - # as a shared user so they can see it in the UI. - if getattr(user, "is_service_account", False): - from platform_api.models import PlatformApiKey - - try: - key = PlatformApiKey.objects.get(api_user=user) - if key.created_by: - tool.shared_users.add(key.created_by) - else: - logger.warning( - "PlatformApiKey for service account %s has no " - "created_by while creating tool %s", - user.id, - tool.tool_id, - ) - except PlatformApiKey.DoesNotExist: - logger.warning( - "No PlatformApiKey found for service account %s " - "while creating tool %s", - user.id, - tool.tool_id, - ) - return tool @staticmethod From e2f206b75d2def209df634e461d298f226049627 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 13:57:27 +0530 Subject: [PATCH 2/3] UN-3318 [FIX] Hide current user and admins from share dropdown The "Share Users" dropdown listed every org member, including the viewer themselves and admins. Sharing with yourself is meaningless, and admins already have access to every resource via the for_user admin bypass, so offering them as share targets is misleading (and would create stale grants that survive a later role demotion). - OrganizationMemberSerializer now returns an `is_admin` flag computed via is_admin_by_role, so the frontend doesn't hardcode role strings (works for OSS "admin" and Auth0 "unstract_admin"). - SharePermission dropdown excludes the current user and admins on top of the existing creator/selected-user exclusions. - Thread `is_admin` through the share-user fetch sites. Co-Authored-By: Claude Opus 4.8 --- backend/tenant_account_v2/serializer.py | 13 +++++- backend/tenant_account_v2/users_view.py | 4 +- .../components/custom-tools/header/Header.jsx | 1 + .../list-of-tools/ListOfTools.jsx | 1 + .../tool-settings/ToolSettings.jsx | 1 + .../share-permission/SharePermission.jsx | 44 +++++++++++++------ frontend/src/hooks/useShareModal.js | 3 ++ 7 files changed, 52 insertions(+), 15 deletions(-) diff --git a/backend/tenant_account_v2/serializer.py b/backend/tenant_account_v2/serializer.py index 7947464543..4465bf71b4 100644 --- a/backend/tenant_account_v2/serializer.py +++ b/backend/tenant_account_v2/serializer.py @@ -29,10 +29,21 @@ class UserInviteResponseSerializer(serializers.Serializer): class OrganizationMemberSerializer(serializers.ModelSerializer): email = serializers.CharField(source="user.email", read_only=True) id = serializers.CharField(source="user.id", read_only=True) + is_admin = serializers.SerializerMethodField() class Meta: model = OrganizationMember - fields = ("id", "email", "role") + fields = ("id", "email", "role", "is_admin") + + def get_is_admin(self, obj: OrganizationMember) -> bool: + # Admin determination is auth-plugin specific (OSS "admin" vs Auth0 + # "unstract_admin"); defer to the same check used for access control. + auth_controller = self.context.get("auth_controller") + if auth_controller is None: + from account_v2.authentication_controller import AuthenticationController + + auth_controller = AuthenticationController() + return auth_controller.is_admin_by_role(obj.role) class LimitedUserEmailListSerializer(serializers.ListSerializer): diff --git a/backend/tenant_account_v2/users_view.py b/backend/tenant_account_v2/users_view.py index 22fc47187f..127c67af17 100644 --- a/backend/tenant_account_v2/users_view.py +++ b/backend/tenant_account_v2/users_view.py @@ -177,7 +177,9 @@ def get_organization_members(self, request: Request) -> Response: members: list[OrganizationMember] = ( auth_controller.get_organization_members_by_org_id() ) - serialized_members = OrganizationMemberSerializer(members, many=True).data + serialized_members = OrganizationMemberSerializer( + members, many=True, context={"auth_controller": auth_controller} + ).data return Response( status=status.HTTP_200_OK, data={"message": "success", "members": serialized_members}, diff --git a/frontend/src/components/custom-tools/header/Header.jsx b/frontend/src/components/custom-tools/header/Header.jsx index 471588a4b2..4e0d39a519 100644 --- a/frontend/src/components/custom-tools/header/Header.jsx +++ b/frontend/src/components/custom-tools/header/Header.jsx @@ -181,6 +181,7 @@ function Header({ users.map((user) => ({ id: user?.id, email: user?.email, + is_admin: user?.is_admin, })), ); return users; 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..390c9da6b0 100644 --- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx +++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx @@ -308,6 +308,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { users.map((user) => ({ id: user?.id, email: user?.email, + is_admin: user?.is_admin, })), ); }) diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx index 31df2c290e..7be40a5c62 100644 --- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx +++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx @@ -172,6 +172,7 @@ function ToolSettings({ type }) { users.map((user) => ({ id: user?.id, email: user?.email, + is_admin: user?.is_admin, })), ); }) diff --git a/frontend/src/components/widgets/share-permission/SharePermission.jsx b/frontend/src/components/widgets/share-permission/SharePermission.jsx index 8cb9260924..392f59a6a1 100644 --- a/frontend/src/components/widgets/share-permission/SharePermission.jsx +++ b/frontend/src/components/widgets/share-permission/SharePermission.jsx @@ -16,6 +16,7 @@ import { import PropTypes from "prop-types"; import { useEffect, useState } from "react"; +import { useSessionStore } from "../../../store/session-store"; import { SpinnerLoader } from "../spinner-loader/SpinnerLoader"; function SharePermission({ @@ -28,31 +29,48 @@ function SharePermission({ onApply, isSharableToOrg = false, }) { + const { sessionDetails } = useSessionStore(); + const currentUserId = sessionDetails?.userId?.toString(); const [selectedUsers, setSelectedUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]); const [shareWithEveryone, setShareWithEveryone] = useState(false); useEffect(() => { if (permissionEdit && adapter && adapter?.shared_users) { - // If permissionEdit is true, and adapter is available, - // set the selectedUsers to the IDs of shared users + const creatorId = ( + adapter?.created_by?.id ?? adapter?.created_by + )?.toString(); const users = allUsers.filter((user) => { - if (adapter?.created_by?.id !== undefined) { - return isSharableToOrg - ? !selectedUsers.includes(user?.id?.toString()) - : user?.id !== adapter?.created_by?.id?.toString() && - !selectedUsers.includes(user?.id?.toString()); - } else { - return isSharableToOrg - ? !selectedUsers.includes(user?.id?.toString()) - : user?.id !== adapter?.created_by?.toString() && - !selectedUsers.includes(user?.id?.toString()); + const userId = user?.id?.toString(); + // Already-selected users shouldn't reappear as options + if (selectedUsers.includes(userId)) { + return false; } + // Can't share a resource with yourself + if (userId === currentUserId) { + return false; + } + // Admins already have access to everything — sharing is a no-op + if (user?.is_admin) { + return false; + } + // The creator already owns it, unless we're sharing org-wide + if (!isSharableToOrg && userId === creatorId) { + return false; + } + return true; }); setFilteredUsers(users); setShareWithEveryone(adapter?.shared_to_org || false); } - }, [permissionEdit, adapter, allUsers, selectedUsers]); + }, [ + permissionEdit, + adapter, + allUsers, + selectedUsers, + currentUserId, + isSharableToOrg, + ]); useEffect(() => { if (adapter?.shared_users) { diff --git a/frontend/src/hooks/useShareModal.js b/frontend/src/hooks/useShareModal.js index 82d915474a..703a4e1cfd 100644 --- a/frontend/src/hooks/useShareModal.js +++ b/frontend/src/hooks/useShareModal.js @@ -42,16 +42,19 @@ function useShareModal({ userList = responseData.map((user) => ({ id: user.id, email: user.email, + is_admin: user.is_admin, })); } else if (responseData?.members && Array.isArray(responseData.members)) { userList = responseData.members.map((member) => ({ id: member.id, email: member.email, + is_admin: member.is_admin, })); } else if (responseData?.users && Array.isArray(responseData.users)) { userList = responseData.users.map((user) => ({ id: user.id, email: user.email, + is_admin: user.is_admin, })); } From 20a67c02f38beefaf29aadd6514ad4826ee885ad Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 1 Jun 2026 15:04:39 +0530 Subject: [PATCH 3/3] UN-3318 [FIX] Address PR review on share dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use sessionDetails.id (not .userId, which the session store doesn't expose) so the "can't share with yourself" filter actually fires. - Hide admins from the "Shared with" list too, so legacy admin rows from before the auto-share removal no longer appear — closing the noise gap without a data migration. Co-Authored-By: Claude Opus 4.8 --- .../share-permission/SharePermission.jsx | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/widgets/share-permission/SharePermission.jsx b/frontend/src/components/widgets/share-permission/SharePermission.jsx index 392f59a6a1..7f5aeaffff 100644 --- a/frontend/src/components/widgets/share-permission/SharePermission.jsx +++ b/frontend/src/components/widgets/share-permission/SharePermission.jsx @@ -30,7 +30,7 @@ function SharePermission({ isSharableToOrg = false, }) { const { sessionDetails } = useSessionStore(); - const currentUserId = sessionDetails?.userId?.toString(); + const currentUserId = sessionDetails?.id?.toString(); const [selectedUsers, setSelectedUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]); const [shareWithEveryone, setShareWithEveryone] = useState(false); @@ -98,25 +98,30 @@ function SharePermission({ setShareWithEveryone(checked); }; + // Admins have org-wide access, so they aren't listed as explicit shares. + // This also hides legacy admin rows created before the auto-share removal. + const sharedUsersToDisplay = selectedUsers + .map((userId) => { + const user = allUsers.find((u) => + u?.id !== undefined + ? u?.id.toString() === userId.toString() + : u?.toString() === userId.toString(), + ); + return { + id: user?.id, + email: user?.email, + is_admin: user?.is_admin, + }; + }) + .filter((user) => !user.is_admin); + let sharedWithContent; if (shareWithEveryone) { sharedWithContent = Shared with everyone; - } else if (selectedUsers.length > 0) { + } else if (sharedUsersToDisplay.length > 0) { sharedWithContent = ( { - const user = allUsers.find((u) => { - if (u?.id !== undefined) { - return u?.id.toString() === userId.toString(); - } else { - return u?.toString() === userId.toString(); - } - }); - return { - id: user?.id, - email: user?.email, - }; - })} + dataSource={sharedUsersToDisplay} renderItem={(item) => (