diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 33d4dd5079..ea87017b60 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -3,6 +3,7 @@ from adapter_processor_v2.models import AdapterInstance from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.views import APIView from tenant_account_v2.organization_member_service import OrganizationMemberService @@ -148,6 +149,36 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo return is_workflow_mutator(request, obj.workflow) +class WorkflowOwnerMutationMixin: + """Viewset mixin gating mutation of a workflow sub-resource. + + Shared access to the parent workflow -- direct, via group, or org-wide -- + grants read only. Admits owners, co-owners, org admins and service + accounts, via :func:`is_workflow_mutator`. Requires the resource to carry + a ``workflow`` FK. + + ``create`` is handled separately from the rest: it is collection-level, so + DRF never calls ``get_object()`` and ``IsParentWorkflowOwner`` cannot run. + """ + + mutation_denied_message = ( + "Only the workflow owner or an organization admin can change this." + ) + + def get_permissions(self) -> list[Any]: + if self.action in ("update", "partial_update", "destroy"): + return [IsParentWorkflowOwner()] + return list(super().get_permissions()) + + def perform_create(self, serializer: Any) -> None: + # Fails closed: this mixin only guards resources that carry a parent + # workflow, so a payload without one cannot be authorised at all. + workflow = serializer.validated_data.get("workflow") + if not workflow or not is_workflow_mutator(self.request, workflow): + raise PermissionDenied(self.mutation_denied_message) + serializer.save() + + class IsParentToolOwner(permissions.BasePermission): """Mutation gate for Prompt Studio sub-resources owned via the parent tool. diff --git a/backend/prompt_studio/permission.py b/backend/prompt_studio/permission.py index 43eb9c75da..255cf89688 100644 --- a/backend/prompt_studio/permission.py +++ b/backend/prompt_studio/permission.py @@ -11,6 +11,24 @@ from tenant_account_v2.organization_member_service import OrganizationMemberService +def _can_access_tool(user: Any, tool: Any) -> bool: + """Whether ``user`` may work on ``tool``. + + Prompt Studio is shared for collaboration (UN-2868): a shared user edits + the project's prompts and settings, the same as its owner. Only the + project's name, its existence and who else it is shared with stay with + the owner, and those are gated on the project viewset. + """ + if _is_resource_owner(user, tool): + return True + if _is_resource_viewer(user, tool): + return True + if has_group_access(user, tool): + return True + # Left last: the admin lookup is uncached, so shared users resolve without it. + return OrganizationMemberService.is_user_organization_admin(user) + + class PromptAcesssToUser(permissions.BasePermission): """Is the crud to Prompt/Notes allowed to user. @@ -23,14 +41,41 @@ class PromptAcesssToUser(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if getattr(request.user, "is_service_account", False): return True - tool = obj.tool_id - if _is_resource_owner(request.user, tool): + return _can_access_tool(request.user, obj.tool_id) + + +class ParentToolAccess(permissions.BasePermission): + """Gate for Prompt Studio sub-resources keyed to a project. + + A ``ProfileManager`` carries no membership of its own, so access follows + the parent ``CustomTool`` -- anyone the project is shared with manages its + profiles as they do its prompts. ``create`` is collection-level, so DRF + never calls the object check for it and the parent is resolved from the + payload instead. + """ + + def has_permission(self, request: Request, view: APIView) -> bool: + if getattr(view, "action", None) != "create": return True - if _is_resource_viewer(request.user, tool): + if getattr(request.user, "is_service_account", False): return True - if has_group_access(request.user, tool): + # Imported here: the models pull in this module at import time. + from prompt_studio.prompt_profile_manager_v2.constants import ProfileManagerKeys + from prompt_studio.prompt_studio_core_v2.models import CustomTool + + tool = CustomTool.objects.filter( + tool_id=request.data.get(ProfileManagerKeys.PROMPT_STUDIO_TOOL) + ).first() + return bool(tool and _can_access_tool(request.user, tool)) + + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + if getattr(request.user, "is_service_account", False): return True - return OrganizationMemberService.is_user_organization_admin(request.user) + tool = obj.prompt_studio_tool + if not tool: + # Orphan row: the parent FK is nullable, so fall back to its creator. + return obj.created_by_id == request.user.id + return _can_access_tool(request.user, tool) class IsRegistryToolOwner(permissions.BasePermission): diff --git a/backend/prompt_studio/prompt_profile_manager_v2/views.py b/backend/prompt_studio/prompt_profile_manager_v2/views.py index 907a137e4f..8d3b3dd094 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/views.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/views.py @@ -4,15 +4,13 @@ from django.db import IntegrityError from django.db.models import QuerySet from django.http import HttpRequest -from permissions.permission import ( - IsOwnerOrSharedUserOrSharedToOrg, - IsParentToolOwner, -) +from permissions.permission import IsOwnerOrSharedUserOrSharedToOrg from rest_framework import status, viewsets from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning from utils.filtering import FilterHelper +from prompt_studio.permission import ParentToolAccess from prompt_studio.prompt_profile_manager_v2.constants import ( ProfileManagerErrors, ProfileManagerKeys, @@ -29,10 +27,10 @@ class ProfileManagerView(viewsets.ModelViewSet): serializer_class = ProfileManagerSerializer def get_permissions(self) -> list[Any]: - # Mutations require ownership of the parent tool (creator + co-owners); - # reads honor sharing. + # A profile is part of the project's design, so anyone the project is + # shared with manages it (UN-2868); reads honor sharing. if self.action in ("create", "destroy", "partial_update", "update"): - return [IsParentToolOwner()] + return [ParentToolAccess()] return [IsOwnerOrSharedUserOrSharedToOrg()] def get_queryset(self) -> QuerySet | None: diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index 8525b7f086..f48bde4d18 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -7,6 +7,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from tenant_account_v2.sharing_helpers import ( + is_org_admin, serialize_group_refs, serialize_owner_refs, ) @@ -99,6 +100,9 @@ class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): # groups axis is read-only here (UN-2977 plan §B). Direct viewers live in # the membership table (UN-2202) and surface via the share-modal serializer. shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + # The editor needs to know whether to offer edit controls at all; the list + # serializer already carries this. + is_owner = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -114,6 +118,10 @@ class Meta: "output", ) + def get_is_owner(self, instance: CustomTool) -> bool: + request = self.context.get("request") + return instance.is_owner(request.user) if request else False + unique_error_message_map: dict[str, dict[str, str]] = { "unique_tool_name": { "field": "tool_name", @@ -124,7 +132,19 @@ class Meta: } def validate_tool_name(self, value: str) -> str: - return validate_name_field(value, field_name="Tool name") + value = validate_name_field(value, field_name="Tool name") + # Settings and the project's name share this endpoint, and settings are + # collaborative -- so the rename is gated here rather than on the view + # (UN-2868). + request = self.context.get("request") + if not self.instance or not request or value == self.instance.tool_name: + return value + user = request.user + if getattr(user, "is_service_account", False): + return value + if not self.instance.is_owner(user) and not is_org_admin(user): + raise ValidationError("Only the owner can rename this project.") + return value def validate_summarize_llm_adapter(self, value): """Validate that the adapter type is LLM and is accessible to the user.""" diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 8990e870eb..1d71359821 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -149,6 +149,10 @@ def get_serializer_class(self): return CustomToolSerializer def get_permissions(self) -> list[Any]: + # Settings are collaborative (UN-2868); only the project's existence + # and who it is shared with stay with the owner. Renaming is blocked + # per-field in the serializer, since it shares an endpoint with + # every settings write. if self.action in ["destroy", "add_co_owner", "remove_co_owner"]: return [IsOwner()] diff --git a/backend/prompt_studio/prompt_studio_v2/views.py b/backend/prompt_studio/prompt_studio_v2/views.py index a540480274..f1a0dba3e3 100644 --- a/backend/prompt_studio/prompt_studio_v2/views.py +++ b/backend/prompt_studio/prompt_studio_v2/views.py @@ -60,5 +60,12 @@ def reorder_prompts(self, request: Request) -> Response: Returns: Response: The HTTP response indicating the status of the reorder operation. """ + # Routed without a pk, so DRF runs no object check of its own; resolve + # the prompt so reordering is gated like every other write here. + prompt = ToolStudioPrompt.objects.filter( + prompt_id=request.data.get(ToolStudioPromptKeys.PROMPT_ID) + ).first() + if prompt: + self.check_object_permissions(request, prompt) prompt_studio_controller = PromptStudioController() return prompt_studio_controller.reorder_prompts(request, ToolStudioPrompt) diff --git a/backend/workflow_manager/endpoint_v2/views.py b/backend/workflow_manager/endpoint_v2/views.py index 67c5f012ad..ce9985c235 100644 --- a/backend/workflow_manager/endpoint_v2/views.py +++ b/backend/workflow_manager/endpoint_v2/views.py @@ -1,4 +1,5 @@ from django.db.models import QuerySet +from permissions.permission import WorkflowOwnerMutationMixin from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.request import Request @@ -11,8 +12,19 @@ from workflow_manager.workflow_v2.models.workflow import Workflow -class WorkflowEndpointViewSet(viewsets.ModelViewSet): +class WorkflowEndpointViewSet(WorkflowOwnerMutationMixin, viewsets.ModelViewSet): + """Workflow source / destination endpoints. + + Config here selects the connector and its settings -- the destination + folder for filesystem, the table for database. Shared users may read + it; only owners and org admins may change it. + """ + serializer_class = WorkflowEndpointSerializer + mutation_denied_message = ( + "Only the workflow owner or an organization admin can change its " + "connector configuration." + ) def get_queryset(self) -> QuerySet: # Get workflows accessible to the user (owned or shared) diff --git a/frontend/src/components/agency/agency/Agency.jsx b/frontend/src/components/agency/agency/Agency.jsx index 10735cb67d..bb008b1bc8 100644 --- a/frontend/src/components/agency/agency/Agency.jsx +++ b/frontend/src/components/agency/agency/Agency.jsx @@ -15,6 +15,7 @@ import useClearFileHistory from "../../../hooks/useClearFileHistory"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; import useRequestUrl from "../../../hooks/useRequestUrl"; +import { useWorkflowCanEdit } from "../../../hooks/useWorkflowCanEdit"; import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; @@ -56,6 +57,7 @@ function Agency() { } = workflowStore; const { sessionDetails } = useSessionStore(); const { orgName } = sessionDetails; + const canEdit = useWorkflowCanEdit(); const { getUrl } = useRequestUrl(); const axiosPrivate = useAxiosPrivate(); const { setAlertDetails } = useAlertStore(); @@ -1146,14 +1148,22 @@ function Agency() { {selectedTool ? (
+ {/* exportedTools holds only the viewer's own + projects, so a shared workflow misses; the + tool instance carries the name either way. */} {exportedTools.find( (t) => t.function_name === selectedTool, - )?.name || selectedTool} + )?.name || + details?.tool_instances?.find( + (ti) => ti.tool_id === selectedTool, + )?.name || + selectedTool} @@ -1163,6 +1173,7 @@ function Agency() { type="default" onClick={() => setShowToolSelectionSidebar(true)} className="select-tool-btn" + disabled={!canEdit} > Select Prompt Studio project diff --git a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx index 1c90a2d813..ba44a92723 100644 --- a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx +++ b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx @@ -15,10 +15,12 @@ import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; import useRequestUrl from "../../../hooks/useRequestUrl"; +import { useWorkflowCanEdit } from "../../../hooks/useWorkflowCanEdit"; import { useAlertStore } from "../../../store/alert-store"; import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal"; import { ManageFiles } from "../../input-output/manage-files/ManageFiles"; import { CustomButton } from "../../widgets/custom-button/CustomButton"; +import { ReadOnlyNotice } from "../../widgets/read-only-notice/ReadOnlyNotice"; import { ConfigureFormsLayout } from "../configure-forms-layout/ConfigureFormsLayout"; import "./ConfigureConnectorModal.css"; @@ -72,6 +74,11 @@ function ConfigureConnectorModal({ const [hasInitializedFormData, setHasInitializedFormData] = useState(false); const [schemaLoadedForSession, setSchemaLoadedForSession] = useState(false); const [ruleEngineHasChanges, setRuleEngineHasChanges] = useState(false); + const canEdit = useWorkflowCanEdit(); + // Grey out a region without touching each third-party widget inside it. + const roClass = canEdit ? undefined : "uneditable"; + // Lets the single footer Save flush the HITL plugin's rules too. + const ruleEngineRef = useRef(null); const fileExplorerRef = useRef(null); const formRef = useRef(null); @@ -280,6 +287,10 @@ function ConfigureConnectorModal({ folderSectionConfig[connType] || folderSectionConfig.input; const hasUnsavedChanges = () => { + // A view-only user cannot have changed anything, so never prompt them. + if (!canEdit) { + return false; + } // For API mode, only check RuleEngine's dirty state if (connMode === "API") { return ruleEngineHasChanges; @@ -293,7 +304,10 @@ function ConfigureConnectorModal({ return hasConfigChanges || hasConnectorChanged || ruleEngineHasChanges; }; - const handleValidateAndSubmit = async (validatedFormData) => { + const handleValidateAndSubmit = async ( + validatedFormData, + notifySuccess = true, + ) => { const hasConfigChanges = !isEqual(validatedFormData, initialFormDataConfig); const hasConnectorChanged = connDetails?.id !== initialConnectorId; const hasChanges = hasConfigChanges || hasConnectorChanged; @@ -314,38 +328,66 @@ function ConfigureConnectorModal({ // Update initial values after successful save setInitialFormDataConfig(cloneDeep(validatedFormData)); setInitialConnectorId(connDetails?.id); - setAlertDetails({ - type: "success", - content: "Configuration saved successfully.", - }); + if (notifySuccess) { + setAlertDetails({ + type: "success", + content: "Configuration saved successfully.", + }); + } + return true; } catch (error) { setAlertDetails({ type: "error", content: error?.message || "Failed to save changes. Please try again.", }); + return false; } finally { setIsSavingEndpoint(false); } } + // Nothing to write. + return true; }; + // The read-only styling stops the mouse but not the keyboard, so cut the + // form's own submit path too rather than let Enter fire a doomed request. + const submitIfEditable = canEdit ? handleValidateAndSubmit : undefined; + const handleSave = async () => { const hasConfigChanges = !isEqual(formDataConfig, initialFormDataConfig); - if (hasConfigChanges && formRef?.current) { - if (formRef?.current?.validateForm()) { - await handleValidateAndSubmit(formDataConfig); - return true; - } else { - // RJSF shows validation errors - return false; + if ( + hasConfigChanges && + formRef?.current && + !formRef.current.validateForm() + ) { + // RJSF shows validation errors + return false; + } + // HITL rules live in the plugin and used to need their own button. One + // Save now writes everything the modal shows. Only when they actually + // changed -- otherwise every connector save would write a rule too. + const writesRules = ruleEngineHasChanges && !!ruleEngineRef.current?.save; + // Stop here if the endpoint write failed, rather than writing half the + // configuration and closing as though everything saved. Stay quiet on + // success when rules follow: the rule write reports the real outcome, and + // a success toast ahead of its failure would read as though both landed. + if (!(await handleValidateAndSubmit(formDataConfig, !writesRules))) { + return false; + } + if (writesRules) { + setIsSavingEndpoint(true); + try { + // Keep the modal open on failure so the edit is not lost. + if (!(await ruleEngineRef.current.save())) { + return false; + } + } finally { + setIsSavingEndpoint(false); } - } else { - // No config changes, just save connector changes if any - await handleValidateAndSubmit(formDataConfig); - return true; } + return true; }; const handleModalClose = () => { @@ -532,8 +574,10 @@ function ConfigureConnectorModal({ footer={ connDetails?.id || connMode === "API" ? (
- - {connMode !== "API" && ( + + {canEdit && (