Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/permissions/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment thread
greptile-apps[bot] marked this conversation as resolved.


class IsParentToolOwner(permissions.BasePermission):
"""Mutation gate for Prompt Studio sub-resources owned via the parent tool.

Expand Down
7 changes: 7 additions & 0 deletions backend/prompt_studio/prompt_studio_core_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion backend/workflow_manager/endpoint_v2/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/agency/agency/Agency.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1146,14 +1148,22 @@ function Agency() {
{selectedTool ? (
<div className="selected-tool-info">
<span className="selected-tool-name">
{/* 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}
</span>
<Button
type="link"
onClick={() => setShowToolSelectionSidebar(true)}
size="small"
disabled={!canEdit}
>
Change Prompt Studio project
</Button>
Expand All @@ -1163,6 +1173,7 @@ function Agency() {
type="default"
onClick={() => setShowToolSelectionSidebar(true)}
className="select-tool-btn"
disabled={!canEdit}
>
Select Prompt Studio project
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -532,8 +574,10 @@ function ConfigureConnectorModal({
footer={
connDetails?.id || connMode === "API" ? (
<div className="conn-modal-footer">
<Button onClick={handleModalClose}>Cancel</Button>
{connMode !== "API" && (
<Button onClick={handleModalClose}>
{canEdit ? "Cancel" : "Close"}
</Button>
{canEdit && (
<Button
type="primary"
loading={isSavingEndpoint}
Expand All @@ -554,9 +598,11 @@ function ConfigureConnectorModal({
{connMode === "API" ? "Configure HITL Rules" : "Configure Connector"}
</Typography.Text>

{!canEdit && <ReadOnlyNotice />}

{/* Connector Selection Dropdown (not shown for API connectors) */}
{connMode !== "API" && (
<div className="connector-selection-section">
<div className={`connector-selection-section ${roClass ?? ""}`}>
<Typography.Text strong className="connector-selection-label">
Select Connector
</Typography.Text>
Expand Down Expand Up @@ -622,11 +668,14 @@ function ConfigureConnectorModal({

{/* API connectors: Show only HITL rules (no connector selection needed) */}
{connMode === "API" && RuleEngine && (
<RuleEngine
workflowDetails={workflowDetails}
ruleType="API"
onDirtyStateChange={setRuleEngineHasChanges}
/>
<div className={roClass}>
<RuleEngine
ref={ruleEngineRef}
workflowDetails={workflowDetails}
ruleType="API"
onDirtyStateChange={setRuleEngineHasChanges}
/>
</div>
)}

{/* Only show configuration form and file browser after a connector is selected */}
Expand All @@ -645,31 +694,32 @@ function ConfigureConnectorModal({
label: item.label,
disabled: item.disabled,
children: (
<>
<div className={roClass}>
{item.key === "1" && (
<ConfigureFormsLayout
specConfig={specConfig}
formDataConfig={formDataConfig}
setFormDataConfig={setFormDataConfig}
isSpecConfigLoading={isSpecConfigLoading}
formRef={formRef}
validateAndSubmit={handleValidateAndSubmit}
validateAndSubmit={submitIfEditable}
/>
)}
{item.key === "MANUALREVIEW" && RuleEngine && (
<RuleEngine
ref={ruleEngineRef}
workflowDetails={workflowDetails}
ruleType="DB"
onDirtyStateChange={setRuleEngineHasChanges}
/>
)}
</>
</div>
),
}))}
/>
) : (
/* Other connector types: Show existing layout */
<Row className="conn-modal-row" gutter={24}>
<Row className={`conn-modal-row ${roClass ?? ""}`} gutter={24}>
{/* Left side - Configuration Form */}
<Col span={12} className="conn-modal-col">
<div className="conn-modal-fs-config">
Expand All @@ -679,7 +729,7 @@ function ConfigureConnectorModal({
setFormDataConfig={setFormDataConfig}
isSpecConfigLoading={isSpecConfigLoading}
formRef={formRef}
validateAndSubmit={handleValidateAndSubmit}
validateAndSubmit={submitIfEditable}
/>
</div>
</Col>
Expand Down
Loading
Loading