diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index cd4d9dba9f..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,6 +38,9 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.get_queryset().filter(is_friction_less=False) + 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..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, @@ -30,12 +32,13 @@ 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 django.db.models import Q + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() return self.filter( Q(created_by=user) # Owned by user diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index 73ea38b57c..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,6 +31,9 @@ def for_user(self, user: User) -> models.QuerySet: if getattr(user, "is_service_account", False): return self.all() + 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 7f5724919f..dd9dae8fd5 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -4,8 +4,11 @@ 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" + def _is_service_account(request: Request) -> bool: """Allow service accounts through for all non-DELETE methods. @@ -21,13 +24,37 @@ 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 + is_admin = OrganizationMemberService.is_user_organization_admin(request.user) + setattr(request, _REQUEST_ADMIN_CACHE_ATTR, is_admin) + return is_admin + + class IsOwner(permissions.BasePermission): - """Custom permission to only allow owners of an object.""" + """Allow owners and org admins. + + 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: 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_organization_admin(request): + return True + return False class IsOrganizationMember(permissions.BasePermission): @@ -39,43 +66,44 @@ 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): """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( @@ -85,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): @@ -99,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/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 65fb5257a8..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, @@ -24,11 +25,14 @@ 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() + 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/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..2dede57aa3 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/models.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/models.py @@ -3,12 +3,37 @@ 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 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 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. + + 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_scope = Q(prompt_studio_tool__organization=UserContext.get_organization()) + + if getattr(user, "is_service_account", False): + return self.filter(org_scope) + + if OrganizationMemberService.is_user_organization_admin(user): + return self.filter(org_scope) + + return self.filter( + org_scope + & (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 +126,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..0f02cf14cd 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,22 @@ 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]: + # Mutations require ownership; reads honor sharing. + if self.action in ("create", "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( diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index cd2a12dac8..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,6 +27,9 @@ def for_user(self, user: User) -> QuerySet[Any]: if getattr(user, "is_service_account", False): return self.all() + 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..715c9c5ea4 100644 --- a/backend/tenant_account_v2/organization_member_service.py +++ b/backend/tenant_account_v2/organization_member_service.py @@ -1,9 +1,12 @@ +import logging from typing import Any from utils.cache_service import CacheService from tenant_account_v2.models import OrganizationMember +logger = logging.getLogger(__name__) + class OrganizationMemberService: @staticmethod @@ -13,6 +16,34 @@ 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: + logger.exception( + "admin-role lookup failed for user %s; denying", + getattr(user, "id", None), + ) + 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: try: 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 diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index 45886bd64e..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,8 +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() + if OrganizationMemberService.is_user_organization_admin(user): 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 0029f95997..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, @@ -21,12 +23,13 @@ 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 django.db.models import Q + if OrganizationMemberService.is_user_organization_admin(user): + return self.all() return self.filter( Q(created_by=user) # Owned by user