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 && (
{subtitle && ( @@ -134,6 +145,7 @@ ToolNavBar.propTypes = { titleAdornment: PropTypes.node, subtitle: PropTypes.string, onEditTitle: PropTypes.func, + editTitleDisabled: PropTypes.bool, enableSearch: PropTypes.bool, customButtons: PropTypes.node, setSearchList: PropTypes.func, diff --git a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx index 646d2fbced..e678109b9f 100644 --- a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx +++ b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx @@ -27,6 +27,8 @@ import { formattedDateTime, shortenApiEndpoint, } from "../../../helpers/GetStaticData"; +import { canEditResource } from "../../../helpers/resourceAccess"; +import { useSessionStore } from "../../../store/session-store"; /** * Reusable action box with Edit, Share, Delete icons and kebab menu @@ -47,6 +49,11 @@ function CardActionBox({ */ testIdPrefix, }) { + const { sessionDetails } = useSessionStore(); + // Sharing grants read only: no edit, no delete. Sharing onward stays + // available -- see the Share button below. + const canEdit = canEditResource(item, sessionDetails); + const lockedTitle = canEdit ? undefined : "Only the owner can change this"; const testId = (suffix) => testIdPrefix ? `${testIdPrefix}-${suffix}-${item?.id}` : undefined; const handleEditAction = (e) => { @@ -63,13 +70,18 @@ function CardActionBox({ return ( -
+ ); +} + +ReadOnlyNotice.propTypes = { + message: PropTypes.string, +}; + +export { ReadOnlyNotice }; diff --git a/frontend/src/components/widgets/resource-table/ResourceTable.jsx b/frontend/src/components/widgets/resource-table/ResourceTable.jsx index c2a99ecb35..75db8e9165 100644 --- a/frontend/src/components/widgets/resource-table/ResourceTable.jsx +++ b/frontend/src/components/widgets/resource-table/ResourceTable.jsx @@ -22,6 +22,7 @@ import { Table } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; import { formattedDateTime, timeAgo } from "../../../helpers/GetStaticData"; +import { canEditResource } from "../../../helpers/resourceAccess"; import "./ResourceTable.css"; // Stable, distinct avatar swatch per owner (seeded on email/name) like the @@ -288,6 +289,12 @@ function ResourceTable({ const renderActions = (item) => { const deprecated = item?.is_deprecated; const disabledTitle = deprecated ? "This adapter is deprecated" : ""; + // Sharing grants read only: no edit, no delete. Both controls stay on + // screen but disabled, so it is obvious they exist and why they are not + // available. Sharing onward stays open to shared users. + const canEdit = canEditResource(item, sessionDetails); + const locked = !canEdit; + const lockedTitle = "Only the owner can change this"; return ( event.stopPropagation()} role="none" > - + @@ -328,6 +337,7 @@ function ResourceTable({ * because every row has one. */} 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 };