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/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py
index acb3a243d7..d59e9d3210 100644
--- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py
+++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py
@@ -99,6 +99,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
@@ -107,6 +110,10 @@ class Meta:
"shared_to_org": {"read_only": True},
}
+ 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",
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 && (
diff --git a/frontend/src/helpers/resourceAccess.js b/frontend/src/helpers/resourceAccess.js
new file mode 100644
index 0000000000..a048c705b8
--- /dev/null
+++ b/frontend/src/helpers/resourceAccess.js
@@ -0,0 +1,21 @@
+/**
+ * Whether the current user may change a shared resource.
+ *
+ * Sharing — direct, via group, or org-wide — grants READ only. Owners,
+ * co-owners and org admins may edit and delete. The backend is the authority
+ * (`is_workflow_mutator` and the `IsOwner` family); this only decides what the
+ * UI offers, so nobody fills in a form that can only fail.
+ *
+ * `is_owner` is set by every shareable resource's serializer.
+ */
+function canEditResource(resource, sessionDetails) {
+ // Payload not in yet. The backend still refuses the write, so assume
+ // editable rather than flash a read-only view at the resource's own owner
+ // while the request is in flight.
+ if (!resource || resource.is_owner === undefined) {
+ return true;
+ }
+ return Boolean(resource.is_owner || sessionDetails?.isAdmin);
+}
+
+export { canEditResource };
diff --git a/frontend/src/hooks/usePromptStudioCanEdit.js b/frontend/src/hooks/usePromptStudioCanEdit.js
new file mode 100644
index 0000000000..a41bac5940
--- /dev/null
+++ b/frontend/src/hooks/usePromptStudioCanEdit.js
@@ -0,0 +1,18 @@
+import { canEditResource } from "../helpers/resourceAccess";
+import { useCustomToolStore } from "../store/custom-tool-store";
+import { useSessionStore } from "../store/session-store";
+
+/**
+ * Whether the current user may change the Prompt Studio project being viewed.
+ *
+ * Pairs with the existing `isPublicSource` flag rather than replacing it:
+ * that one means "opened through a public read-only link" and also selects
+ * API paths, while this one means "shared with me, so read only".
+ */
+function usePromptStudioCanEdit() {
+ const { details } = useCustomToolStore();
+ const { sessionDetails } = useSessionStore();
+ return canEditResource(details, sessionDetails);
+}
+
+export { usePromptStudioCanEdit };
diff --git a/frontend/src/hooks/useWorkflowCanEdit.js b/frontend/src/hooks/useWorkflowCanEdit.js
new file mode 100644
index 0000000000..d183b8b1d0
--- /dev/null
+++ b/frontend/src/hooks/useWorkflowCanEdit.js
@@ -0,0 +1,17 @@
+import { canEditResource } from "../helpers/resourceAccess";
+import { useSessionStore } from "../store/session-store";
+import { useWorkflowStore } from "../store/workflow-store";
+
+/**
+ * Whether the current user may change the workflow being viewed.
+ *
+ * Thin wrapper over `canEditResource` for the workflow builder, which reads
+ * its resource from the workflow store rather than a list row.
+ */
+function useWorkflowCanEdit() {
+ const { details } = useWorkflowStore();
+ const { sessionDetails } = useSessionStore();
+ return canEditResource(details, sessionDetails);
+}
+
+export { useWorkflowCanEdit };