From 738e27f63df3928122a0abedacd92d79c7c011a2 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Sun, 24 May 2026 00:27:13 +0530 Subject: [PATCH 1/8] feat(platform-api): service-account access for adapters, prompts, tool instances Enables the unstract-python-client SDK migration subpackage to drive org-to-org data migration purely through admin-issued Platform API keys. Specifically: - adapter_processor_v2/models.py: AdapterInstanceModelManager.for_user returns non-frictionless adapters for service-account callers (was: all()) - permissions/permission.py: IsFrictionLessAdapter grants access to service accounts on non-frictionless adapters, keeping the friction-first check - prompt_studio/permission.py: PromptAcesssToUser short-circuits to True for service accounts so Platform API can GET/POST prompts - tool_instance_v2/views.py: get_queryset scopes via Workflow.for_user so service accounts see all tool instances under workflows they can access Plan: org-to-org data migration v1 (KB: zipstuff/org-data-migration/05). --- backend/tool_instance_v2/views.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/tool_instance_v2/views.py b/backend/tool_instance_v2/views.py index d148238b0b..055ffb4d6d 100644 --- a/backend/tool_instance_v2/views.py +++ b/backend/tool_instance_v2/views.py @@ -10,6 +10,7 @@ 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,17 +95,16 @@ def get_queryset(self) -> QuerySet: RequestKey.WORKFLOW, ) - # Service accounts (Platform API key holders) need org-wide - # tool-instance visibility scoped via the workflows they can - # access — otherwise the migration SDK can't enumerate - # ToolInstance rows it didn't create. Regular users keep the - # original per-creator scope so shared-workflow access does NOT - # silently broaden their tool-instance visibility. - if getattr(self.request.user, "is_service_account", False): - accessible_workflows = Workflow.objects.for_user(self.request.user) + # Per-creator scope for regular users avoids leaking sibling rows + # in shared workflows; admins and service accounts get org-wide. + 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=self.request.user) + queryset = ToolInstance.objects.filter(created_by=user) if filter_args: queryset = queryset.filter(**filter_args) return queryset From 8654ee366ea07a434660d405b003b376ccb6bcca Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Sun, 24 May 2026 14:08:41 +0530 Subject: [PATCH 2/8] feat(permissions): admin override on IsOwner + profile sharing fields Two changes that together let org admins manage resources that org-to-org migration leaves owned by the service-account user. 1. IsOwner now admits org admins for objects whose `created_by` is a service account. Scope is narrowed to service-account-owned rows so the change does not widen admin rights over resources created by other human org members. Admin lookup is cached per-request. 2. ProfileManager joins the standard sharing model (`shared_users`, `shared_to_org`, `for_user()` manager) used by Workflow / Pipeline / APIDeployment / Connector. The ViewSet now splits per-action permissions (IsOwner for write, IsOwnerOrSharedUserOrSharedToOrg for read) instead of being owner-only on every action. Combined effect: migrated profiles become visible to admins via shared_to_org / shared_users (set by the migration SDK on POST) and editable via the IsOwner admin override. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/permissions/permission.py | 49 ++++++++++++++++++- ...5_profilemanager_shared_to_org_and_more.py | 31 ++++++++++++ .../prompt_profile_manager_v2/models.py | 30 +++++++++++- .../prompt_profile_manager_v2/views.py | 13 +++-- 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 backend/prompt_studio/prompt_profile_manager_v2/migrations/0005_profilemanager_shared_to_org_and_more.py diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 7f5724919f..8fa4deab6f 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -6,6 +6,8 @@ from rest_framework.views import APIView from utils.user_context import UserContext +_REQUEST_ADMIN_CACHE_ATTR = "_cached_is_organization_admin" + def _is_service_account(request: Request) -> bool: """Allow service accounts through for all non-DELETE methods. @@ -21,13 +23,56 @@ def _is_service_account(request: Request) -> bool: return getattr(request.user, "is_service_account", False) +def _is_organization_admin(request: Request) -> bool: + """Return True if the requesting user has the org-admin role. + + Result is cached per-request to keep cost at one membership lookup per + incoming HTTP call regardless of how many permission classes ask. + """ + cached = getattr(request, _REQUEST_ADMIN_CACHE_ATTR, None) + if cached is not None: + return cached + try: + from account_v2.authentication_controller import AuthenticationController + + auth_controller = AuthenticationController() + member = auth_controller.get_organization_members_by_user(user=request.user) + is_admin = bool(member) and auth_controller.is_admin_by_role(member.role) + except Exception: + is_admin = False + setattr(request, _REQUEST_ADMIN_CACHE_ATTR, is_admin) + return is_admin + + +def _is_service_account_owned(obj: Any) -> bool: + """True if ``obj.created_by`` is a service-account user. + + Migration-created rows have a service-account ``created_by``; this lets + the admin-override below stay scoped to those rows and avoid widening + edit rights over resources created by other humans in the org. + """ + created_by = getattr(obj, "created_by", None) + return bool(created_by) and getattr(created_by, "is_service_account", False) + + class IsOwner(permissions.BasePermission): - """Custom permission to only allow owners of an object.""" + """Custom permission to only allow owners of an object. + + Org admins are also allowed when the object was created by a service + account (e.g. via the Platform API). This unblocks editing/deleting + resources that org-to-org migration left owned by the service-account + user, without widening admin rights over resources created by human + org members. + """ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if _is_service_account(request): return True - return True if obj.created_by == request.user else False + if obj.created_by == request.user: + return True + if _is_service_account_owned(obj) and _is_organization_admin(request): + return True + return False class IsOrganizationMember(permissions.BasePermission): diff --git a/backend/prompt_studio/prompt_profile_manager_v2/migrations/0005_profilemanager_shared_to_org_and_more.py b/backend/prompt_studio/prompt_profile_manager_v2/migrations/0005_profilemanager_shared_to_org_and_more.py new file mode 100644 index 0000000000..b9441c770e --- /dev/null +++ b/backend/prompt_studio/prompt_profile_manager_v2/migrations/0005_profilemanager_shared_to_org_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.1 on 2026-05-24 08:36 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("prompt_profile_manager_v2", "0004_merge_20250805_1025"), + ] + + operations = [ + migrations.AddField( + model_name="profilemanager", + name="shared_to_org", + field=models.BooleanField( + db_comment="Whether this profile is shared with the entire organization", + default=False, + ), + ), + migrations.AddField( + model_name="profilemanager", + name="shared_users", + field=models.ManyToManyField( + blank=True, + related_name="shared_profile_managers", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/prompt_studio/prompt_profile_manager_v2/models.py b/backend/prompt_studio/prompt_profile_manager_v2/models.py index dac8bad829..c12f21f0d4 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/models.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/models.py @@ -3,12 +3,30 @@ from account_v2.models import User from adapter_processor_v2.models import AdapterInstance from django.db import models -from utils.models.base_model import BaseModel +from utils.models.base_model import BaseModel, BaseModelManager from prompt_studio.prompt_studio_core_v2.exceptions import DefaultProfileError from prompt_studio.prompt_studio_core_v2.models import CustomTool +class ProfileManagerModelManager(BaseModelManager): + def for_user(self, user): + """Mirror the visibility model used by Workflow/Pipeline/etc. + + Without this, ProfileManager rows created by another user (notably + the service account used by org-to-org migration) are invisible to + every other org member. + """ + if getattr(user, "is_service_account", False): + return self.all() + + from django.db.models import Q + + return self.filter( + Q(created_by=user) | Q(shared_users=user) | Q(shared_to_org=True) + ).distinct() + + class ProfileManager(BaseModel): """Model to store the LLM Triad management details for Prompt.""" @@ -101,6 +119,16 @@ class RetrievalStrategy(models.TextChoices): db_comment="DEPRECATED: Default LLM Profile used for summarizing. Use CustomTool.summarize_llm_adapter instead.", ) + shared_users = models.ManyToManyField( + User, related_name="shared_profile_managers", blank=True + ) + shared_to_org = models.BooleanField( + default=False, + db_comment="Whether this profile is shared with the entire organization", + ) + + objects = ProfileManagerModelManager() + class Meta: verbose_name = "Profile Manager" verbose_name_plural = "Profile Managers" diff --git a/backend/prompt_studio/prompt_profile_manager_v2/views.py b/backend/prompt_studio/prompt_profile_manager_v2/views.py index 51978b1d80..c91cbf6aed 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/views.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/views.py @@ -4,7 +4,7 @@ from django.db import IntegrityError from django.db.models import QuerySet from django.http import HttpRequest -from permissions.permission import IsOwner +from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from rest_framework import status, viewsets from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning @@ -23,18 +23,21 @@ class ProfileManagerView(viewsets.ModelViewSet): """Viewset to handle all Custom tool related operations.""" versioning_class = URLPathVersioning - permission_classes = [IsOwner] serializer_class = ProfileManagerSerializer + def get_permissions(self) -> list[Any]: + if self.action in ("destroy", "partial_update", "update"): + return [IsOwner()] + return [IsOwnerOrSharedUserOrSharedToOrg()] + def get_queryset(self) -> QuerySet | None: + queryset = ProfileManager.objects.for_user(self.request.user) filter_args = FilterHelper.build_filter_args( self.request, ProfileManagerKeys.CREATED_BY, ) if filter_args: - queryset = ProfileManager.objects.filter(**filter_args) - else: - queryset = ProfileManager.objects.all() + queryset = queryset.filter(**filter_args) return queryset def create( From 308b2bc62552ba45d5a6b671de3dc9ebfbf337a8 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Sun, 24 May 2026 14:25:57 +0530 Subject: [PATCH 3/8] feat(permissions): admins see + edit every resource in their org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the prior commit so the admin override is no longer scoped to service-account-owned rows. - New helper: OrganizationMemberService.is_user_organization_admin(user) - IsOwner / IsOwnerOrSharedUser / IsOwnerOrSharedUserOrSharedToOrg all admit org admins (in addition to the existing owner / shared rules). - Every for_user() manager (AdapterInstance, ConnectorInstance, CustomTool, Workflow, WorkflowExecution, Pipeline, APIDeployment, ProfileManager) short-circuits to the full org-scoped queryset for admins, mirroring the service-account branch. Effect: an org admin can now view and edit any resource in their org regardless of who created it. Same model as typical SaaS admin roles. Org scoping continues to come from DefaultOrganizationManagerMixin / OrgAwareManager — admins do not gain cross-org visibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/adapter_processor_v2/models.py | 7 ++ backend/api_v2/models.py | 9 ++- backend/connector_v2/models.py | 7 ++ backend/permissions/permission.py | 74 +++++++------------ backend/pipeline_v2/models.py | 9 ++- .../prompt_profile_manager_v2/models.py | 10 ++- .../prompt_studio_core_v2/models.py | 7 ++ .../organization_member_service.py | 24 ++++++ .../workflow_v2/models/execution.py | 12 +++ .../workflow_v2/models/workflow.py | 9 ++- 10 files changed, 117 insertions(+), 51 deletions(-) diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index cd4d9dba9f..00ff09aa96 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -37,6 +37,13 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.get_queryset().filter(is_friction_less=False) + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.get_queryset() + return ( self.get_queryset() .filter( diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index cc19902bde..a34f57ee5f 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -30,11 +30,18 @@ def for_user(self, user): - API deployments created by the user - API deployments shared with the user - API deployments shared with the entire organization - - Service accounts see all org resources + - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): return self.all() + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() + from django.db.models import Q return self.filter( diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index 73ea38b57c..d61395c063 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -30,6 +30,13 @@ def for_user(self, user: User) -> models.QuerySet: if getattr(user, "is_service_account", False): return self.all() + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() + return ( self.get_queryset() .filter( diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 8fa4deab6f..446cf5da46 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -32,37 +32,20 @@ def _is_organization_admin(request: Request) -> bool: cached = getattr(request, _REQUEST_ADMIN_CACHE_ATTR, None) if cached is not None: return cached - try: - from account_v2.authentication_controller import AuthenticationController - - auth_controller = AuthenticationController() - member = auth_controller.get_organization_members_by_user(user=request.user) - is_admin = bool(member) and auth_controller.is_admin_by_role(member.role) - except Exception: - is_admin = False + from tenant_account_v2.organization_member_service import OrganizationMemberService + + is_admin = OrganizationMemberService.is_user_organization_admin(request.user) setattr(request, _REQUEST_ADMIN_CACHE_ATTR, is_admin) return is_admin -def _is_service_account_owned(obj: Any) -> bool: - """True if ``obj.created_by`` is a service-account user. - - Migration-created rows have a service-account ``created_by``; this lets - the admin-override below stay scoped to those rows and avoid widening - edit rights over resources created by other humans in the org. - """ - created_by = getattr(obj, "created_by", None) - return bool(created_by) and getattr(created_by, "is_service_account", False) - - class IsOwner(permissions.BasePermission): - """Custom permission to only allow owners of an object. + """Allow owners and org admins. - Org admins are also allowed when the object was created by a service - account (e.g. via the Platform API). This unblocks editing/deleting - resources that org-to-org migration left owned by the service-account - user, without widening admin rights over resources created by human - org members. + Org admins can manage every resource in their organization regardless of + ``created_by``. This matches the "admin role manages everything" model + expected by org-to-org migration (resources land owned by a service + account) and by typical admin UX. """ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: @@ -70,7 +53,7 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo return True if obj.created_by == request.user: return True - if _is_service_account_owned(obj) and _is_organization_admin(request): + if _is_organization_admin(request): return True return False @@ -84,38 +67,35 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo class IsOwnerOrSharedUser(permissions.BasePermission): - """Custom permission to only allow owners and shared users of an object.""" + """Allow owners, shared users, and org admins.""" def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: 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 - ) + if obj.created_by == request.user: + return True + if obj.shared_users.filter(pk=request.user.pk).exists(): + return True + if _is_organization_admin(request): + return True + return False class IsOwnerOrSharedUserOrSharedToOrg(permissions.BasePermission): - """Custom permission to only allow owners and shared users of an object or - if it is shared to org. - """ + """Allow owners, shared users, org-shared objects, and org admins.""" def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: 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 - ) + if obj.created_by == request.user: + return True + if obj.shared_users.filter(pk=request.user.pk).exists(): + return True + if obj.shared_to_org: + return True + if _is_organization_admin(request): + return True + return False class IsFrictionLessAdapter(permissions.BasePermission): diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 65fb5257a8..bd2e797bbc 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -24,11 +24,18 @@ def for_user(self, user): - Pipelines created by the user - Pipelines shared with the user - Pipelines shared with the entire organization - - Service accounts see all org resources + - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): return self.all() + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() + return self.filter( Q(created_by=user) # Owned by user | Q(shared_users=user) # Shared with user diff --git a/backend/prompt_studio/prompt_profile_manager_v2/models.py b/backend/prompt_studio/prompt_profile_manager_v2/models.py index c12f21f0d4..21e04176ad 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/models.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/models.py @@ -15,11 +15,19 @@ def for_user(self, user): Without this, ProfileManager rows created by another user (notably the service account used by org-to-org migration) are invisible to - every other org member. + every other org member. Service accounts and org admins see all + rows. """ if getattr(user, "is_service_account", False): return self.all() + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() + from django.db.models import Q return self.filter( diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index cd2a12dac8..eb381ef13a 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -26,6 +26,13 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.all() + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() + return ( self.get_queryset() .filter( diff --git a/backend/tenant_account_v2/organization_member_service.py b/backend/tenant_account_v2/organization_member_service.py index ce1a0d9870..3d6d8b075a 100644 --- a/backend/tenant_account_v2/organization_member_service.py +++ b/backend/tenant_account_v2/organization_member_service.py @@ -1,5 +1,6 @@ from typing import Any +from account_v2.enums import UserRole from utils.cache_service import CacheService from tenant_account_v2.models import OrganizationMember @@ -13,6 +14,29 @@ def get_user_by_email(email: str) -> OrganizationMember | None: except OrganizationMember.DoesNotExist: return None + @staticmethod + def is_user_organization_admin(user: Any) -> bool: + """Return True if ``user`` has the admin role in the current org. + + Service accounts are not org admins — they have their own bypass + path in the relevant permissions / managers. Returns False on any + lookup failure (anonymous user, no membership row, DB unavailable). + """ + if not user or not getattr(user, "is_authenticated", False): + return False + if getattr(user, "is_service_account", False): + return False + try: + member = OrganizationMember.objects.get(user=user.id) # type: ignore + except OrganizationMember.DoesNotExist: + return False + except Exception: + return False + try: + return UserRole(member.role) == UserRole.ADMIN + except ValueError: + return False + @staticmethod def get_user_by_user_id(user_id: str) -> OrganizationMember | None: try: diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index 45886bd64e..a82bbeb067 100644 --- a/backend/workflow_manager/workflow_v2/models/execution.py +++ b/backend/workflow_manager/workflow_v2/models/execution.py @@ -58,6 +58,18 @@ def for_user(self, user) -> QuerySet: return self.filter(workflow__organization=org) return self.all() + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + from utils.user_context import UserContext + + org = UserContext.get_organization() + if org: + return self.filter(workflow__organization=org) + return self.all() + # Filter for workflow access workflow_filter = Q(workflow__created_by=user) | Q(workflow__shared_users=user) diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index 0029f95997..ba3fe2d19e 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -21,11 +21,18 @@ def for_user(self, user): - Workflows created by the user - Workflows shared with the user - Workflows shared with the entire organization - - Service accounts see all org resources + - Service accounts and org admins see all org resources """ if getattr(user, "is_service_account", False): return self.all() + from tenant_account_v2.organization_member_service import ( + OrganizationMemberService, + ) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() + from django.db.models import Q return self.filter( From c26157157bc5a9c72105ffb4081c9c2835f180ce Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Sun, 24 May 2026 16:09:07 +0530 Subject: [PATCH 4/8] fix(profile-manager): scope for_user by organization to close shared_to_org leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileManager has no direct organization FK and BaseModelManager applies no org filter, so the previous Q(shared_to_org=True) branch resolved across every tenant in the DB — any auth'd user who knew a profile UUID from another org could read it. The same issue applied to the admin / service-account branches via self.all(). Scope through prompt_studio_tool__organization=UserContext.get_organization() on every branch so org boundaries are enforced before any sharing logic. Co-Authored-By: Claude Opus 4.7 --- .../prompt_profile_manager_v2/models.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/prompt_studio/prompt_profile_manager_v2/models.py b/backend/prompt_studio/prompt_profile_manager_v2/models.py index 21e04176ad..d1fa66337e 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/models.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/models.py @@ -4,6 +4,7 @@ from adapter_processor_v2.models import AdapterInstance from django.db import models from utils.models.base_model import BaseModel, BaseModelManager +from utils.user_context import UserContext from prompt_studio.prompt_studio_core_v2.exceptions import DefaultProfileError from prompt_studio.prompt_studio_core_v2.models import CustomTool @@ -16,22 +17,31 @@ def for_user(self, user): Without this, ProfileManager rows created by another user (notably the service account used by org-to-org migration) are invisible to every other org member. Service accounts and org admins see all - rows. + rows within the current org. + + ProfileManager has no direct ``organization`` FK — scope via the + parent CustomTool so the ``shared_to_org=True`` branch cannot + leak rows across tenants when a UUID is known/guessed. """ + # Service accounts and admins still need to be org-scoped — they + # otherwise see rows from every org in the DB. + from django.db.models import Q + + org_scope = Q(prompt_studio_tool__organization=UserContext.get_organization()) + if getattr(user, "is_service_account", False): - return self.all() + return self.filter(org_scope) from tenant_account_v2.organization_member_service import ( OrganizationMemberService, ) if OrganizationMemberService.is_user_organization_admin(user): - return self.all() - - from django.db.models import Q + return self.filter(org_scope) return self.filter( - Q(created_by=user) | Q(shared_users=user) | Q(shared_to_org=True) + org_scope + & (Q(created_by=user) | Q(shared_users=user) | Q(shared_to_org=True)) ).distinct() From 9416b3d54c69da906a8a8303542027b66a8a4378 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Sun, 24 May 2026 16:26:23 +0530 Subject: [PATCH 5/8] fix(profile-manager): list create under IsOwner for intent DRF skips has_object_permission on create so behaviour is unchanged, but listing the action alongside the other mutations makes the ownership requirement explicit at a glance. Co-Authored-By: Claude Opus 4.7 --- backend/prompt_studio/prompt_profile_manager_v2/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/prompt_studio/prompt_profile_manager_v2/views.py b/backend/prompt_studio/prompt_profile_manager_v2/views.py index c91cbf6aed..dd46dea7fb 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/views.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/views.py @@ -26,7 +26,10 @@ class ProfileManagerView(viewsets.ModelViewSet): serializer_class = ProfileManagerSerializer def get_permissions(self) -> list[Any]: - if self.action in ("destroy", "partial_update", "update"): + # Mutations require ownership; reads are visible to anyone the + # row is shared with. ``create`` is listed for intent even though + # DRF skips ``has_object_permission`` on it. + if self.action in ("create", "destroy", "partial_update", "update"): return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] From 2b370d93835c8160508e3e47bc6328383c8425e5 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Sun, 24 May 2026 17:36:47 +0530 Subject: [PATCH 6/8] refactor: hoist tenant/org imports to module top Inline imports inside ``for_user`` and ``_is_organization_admin`` were a defensive holdover. INSTALLED_APPS orders ``tenant_account_v2`` ahead of these apps and the service module has no reverse deps on adapter/connector/pipeline/workflow/prompt_studio models, so the top-level import is safe and removes one indirection per call. Co-Authored-By: Claude Opus 4.7 --- backend/adapter_processor_v2/models.py | 5 +---- backend/api_v2/models.py | 8 ++------ backend/connector_v2/models.py | 5 +---- backend/permissions/permission.py | 3 +-- backend/pipeline_v2/models.py | 5 +---- .../prompt_studio/prompt_profile_manager_v2/models.py | 8 ++------ backend/prompt_studio/prompt_studio_core_v2/models.py | 5 +---- .../workflow_manager/workflow_v2/models/execution.py | 10 ++-------- .../workflow_manager/workflow_v2/models/workflow.py | 8 ++------ 9 files changed, 13 insertions(+), 44 deletions(-) diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index 00ff09aa96..3755bb2714 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -9,6 +9,7 @@ from django.db import models from django.db.models import QuerySet from tenant_account_v2.models import OrganizationMember +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.exceptions import InvalidEncryptionKey from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -37,10 +38,6 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.get_queryset().filter(is_friction_less=False) - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): return self.get_queryset() diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index a34f57ee5f..b4bc67dabb 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -4,9 +4,11 @@ from account_v2.models import User from django.db import models +from django.db.models import Q from django.db.models.signals import post_delete from django.dispatch import receiver from pipeline_v2.models import Pipeline +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, @@ -35,15 +37,9 @@ def for_user(self, user): if getattr(user, "is_service_account", False): return self.all() - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): return self.all() - from django.db.models import Q - return self.filter( Q(created_by=user) # Owned by user | Q(shared_users=user) # Shared with user diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index d61395c063..bca5d6ff10 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -7,6 +7,7 @@ from connector_processor.connector_processor import ConnectorProcessor from connector_processor.constants import ConnectorKeys from django.db import models +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.fields import EncryptedBinaryField from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -30,10 +31,6 @@ def for_user(self, user: User) -> models.QuerySet: if getattr(user, "is_service_account", False): return self.all() - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): return self.all() diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 446cf5da46..d9c25d9d2d 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -4,6 +4,7 @@ from rest_framework import permissions from rest_framework.request import Request from rest_framework.views import APIView +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.user_context import UserContext _REQUEST_ADMIN_CACHE_ATTR = "_cached_is_organization_admin" @@ -32,8 +33,6 @@ def _is_organization_admin(request: Request) -> bool: cached = getattr(request, _REQUEST_ADMIN_CACHE_ATTR, None) if cached is not None: return cached - from tenant_account_v2.organization_member_service import OrganizationMemberService - is_admin = OrganizationMemberService.is_user_organization_admin(request.user) setattr(request, _REQUEST_ADMIN_CACHE_ATTR, is_admin) return is_admin diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index bd2e797bbc..d7266496c2 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import models from django.db.models import Q +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, @@ -29,10 +30,6 @@ def for_user(self, user): if getattr(user, "is_service_account", False): return self.all() - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): return self.all() diff --git a/backend/prompt_studio/prompt_profile_manager_v2/models.py b/backend/prompt_studio/prompt_profile_manager_v2/models.py index d1fa66337e..bca6f0ea62 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/models.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/models.py @@ -3,6 +3,8 @@ from account_v2.models import User from adapter_processor_v2.models import AdapterInstance from django.db import models +from django.db.models import Q +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.user_context import UserContext @@ -25,17 +27,11 @@ def for_user(self, user): """ # Service accounts and admins still need to be org-scoped — they # otherwise see rows from every org in the DB. - from django.db.models import Q - org_scope = Q(prompt_studio_tool__organization=UserContext.get_organization()) if getattr(user, "is_service_account", False): return self.filter(org_scope) - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): return self.filter(org_scope) diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index eb381ef13a..119b2d139f 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -6,6 +6,7 @@ from adapter_processor_v2.models import AdapterInstance from django.db import models from django.db.models import QuerySet +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.file_storage.constants import FileStorageKeys from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper from utils.models.base_model import BaseModel, BaseModelManager @@ -26,10 +27,6 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.all() - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): return self.all() diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index a82bbeb067..262b191a2c 100644 --- a/backend/workflow_manager/workflow_v2/models/execution.py +++ b/backend/workflow_manager/workflow_v2/models/execution.py @@ -8,11 +8,13 @@ from django.db.models import Q, QuerySet, Sum from pipeline_v2.models import Pipeline from tags.models import Tag +from tenant_account_v2.organization_member_service import OrganizationMemberService from usage_v2.constants import UsageKeys from usage_v2.helper import UsageHelper from usage_v2.models import Usage from utils.common_utils import CommonUtils from utils.models.base_model import BaseModel, BaseModelManager +from utils.user_context import UserContext from workflow_manager.execution.dto import ExecutionCache from workflow_manager.execution.execution_cache_utils import ExecutionCacheUtils @@ -51,20 +53,12 @@ def for_user(self, user) -> QuerySet: QuerySet of executions that the user has permission to access """ if getattr(user, "is_service_account", False): - from utils.user_context import UserContext - org = UserContext.get_organization() if org: return self.filter(workflow__organization=org) return self.all() - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): - from utils.user_context import UserContext - org = UserContext.get_organization() if org: return self.filter(workflow__organization=org) diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index ba3fe2d19e..58aa8e5ea6 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -4,6 +4,8 @@ from django.conf import settings from django.core.validators import MinValueValidator from django.db import models +from django.db.models import Q +from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, @@ -26,15 +28,9 @@ def for_user(self, user): if getattr(user, "is_service_account", False): return self.all() - from tenant_account_v2.organization_member_service import ( - OrganizationMemberService, - ) - if OrganizationMemberService.is_user_organization_admin(user): return self.all() - from django.db.models import Q - return self.filter( Q(created_by=user) # Owned by user | Q(shared_users=user) # Shared with user From e27229f8515037f12998f2882f4ac93e052f2733 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 25 May 2026 17:31:07 +0530 Subject: [PATCH 7/8] chore(profile-manager): tighten visibility/permission comments Co-Authored-By: Claude Opus 4.7 --- .../prompt_profile_manager_v2/models.py | 13 +++---------- .../prompt_profile_manager_v2/views.py | 4 +--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/backend/prompt_studio/prompt_profile_manager_v2/models.py b/backend/prompt_studio/prompt_profile_manager_v2/models.py index bca6f0ea62..2dede57aa3 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/models.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/models.py @@ -16,17 +16,10 @@ class ProfileManagerModelManager(BaseModelManager): def for_user(self, user): """Mirror the visibility model used by Workflow/Pipeline/etc. - Without this, ProfileManager rows created by another user (notably - the service account used by org-to-org migration) are invisible to - every other org member. Service accounts and org admins see all - rows within the current org. - - ProfileManager has no direct ``organization`` FK — scope via the - parent CustomTool so the ``shared_to_org=True`` branch cannot - leak rows across tenants when a UUID is known/guessed. + 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. """ - # Service accounts and admins still need to be org-scoped — they - # otherwise see rows from every org in the DB. org_scope = Q(prompt_studio_tool__organization=UserContext.get_organization()) if getattr(user, "is_service_account", False): diff --git a/backend/prompt_studio/prompt_profile_manager_v2/views.py b/backend/prompt_studio/prompt_profile_manager_v2/views.py index dd46dea7fb..0f02cf14cd 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/views.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/views.py @@ -26,9 +26,7 @@ class ProfileManagerView(viewsets.ModelViewSet): serializer_class = ProfileManagerSerializer def get_permissions(self) -> list[Any]: - # Mutations require ownership; reads are visible to anyone the - # row is shared with. ``create`` is listed for intent even though - # DRF skips ``has_object_permission`` on it. + # Mutations require ownership; reads honor sharing. if self.action in ("create", "destroy", "partial_update", "update"): return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] From 0f8211bad7cea4d9358b326bd9cc440c84c5e0dd Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 25 May 2026 19:37:38 +0530 Subject: [PATCH 8/8] feat(permissions): admin bypass on non-frictionless adapters + role normalize - IsFrictionLessAdapter / IsFrictionLessAdapterDelete: admit org admins on non-frictionless adapters so admins can manage teammate-owned adapters. Frictionless adapters stay blocked for everyone -- they wrap platform credentials and must not be exposed. - is_user_organization_admin: delegate role check to AuthenticationController.is_admin_by_role so casing variants from SSO/Auth0 backends ("Admin", "ADMIN") resolve correctly. - is_user_organization_admin: log via logger.exception in the broad except so a DB outage silently stripping admin access leaves a server signal. Co-Authored-By: Claude Opus 4.7 --- backend/permissions/permission.py | 14 ++++++++++---- .../organization_member_service.py | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index d9c25d9d2d..dd9dae8fd5 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -100,6 +100,10 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo class IsFrictionLessAdapter(permissions.BasePermission): """Hack for friction-less onboarding not allowing user to view or updating friction less adapter. + + Friction-less adapters wrap platform-owned credentials, so they remain + blocked for everyone including org admins -- exposing them would leak + the platform's keys. """ def has_object_permission( @@ -109,8 +113,9 @@ def has_object_permission( return False if _is_service_account(request): return True - - return True if obj.created_by == request.user else False + if obj.created_by == request.user: + return True + return _is_organization_admin(request) class IsFrictionLessAdapterDelete(permissions.BasePermission): @@ -123,5 +128,6 @@ def has_object_permission( ) -> bool: if obj.is_friction_less: return True - - return True if obj.created_by == request.user else False + if obj.created_by == request.user: + return True + return _is_organization_admin(request) diff --git a/backend/tenant_account_v2/organization_member_service.py b/backend/tenant_account_v2/organization_member_service.py index 3d6d8b075a..715c9c5ea4 100644 --- a/backend/tenant_account_v2/organization_member_service.py +++ b/backend/tenant_account_v2/organization_member_service.py @@ -1,10 +1,12 @@ +import logging from typing import Any -from account_v2.enums import UserRole from utils.cache_service import CacheService from tenant_account_v2.models import OrganizationMember +logger = logging.getLogger(__name__) + class OrganizationMemberService: @staticmethod @@ -31,11 +33,16 @@ def is_user_organization_admin(user: Any) -> bool: except OrganizationMember.DoesNotExist: return False except Exception: + logger.exception( + "admin-role lookup failed for user %s; denying", + getattr(user, "id", None), + ) return False - try: - return UserRole(member.role) == UserRole.ADMIN - except ValueError: - return False + # Lazy import: AuthenticationController -> OrganizationMemberService (circular). + # Delegate so admin-role string handling matches the active auth plugin. + from account_v2.authentication_controller import AuthenticationController + + return AuthenticationController().is_admin_by_role(member.role) @staticmethod def get_user_by_user_id(user_id: str) -> OrganizationMember | None: