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" ? (