diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 58704bfd47f..bea443e2f7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -15,7 +15,6 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/ import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { usePermissionConfig } from '@/hooks/use-permission-config' -import { getProviderFromModel } from '@/providers/utils' import { useSubBlockStore } from '@/stores/workflows/subblock/store' /** @@ -108,30 +107,18 @@ export const ComboBox = memo(function ComboBox({ const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue // Permission-based filtering for model dropdowns - const { - isProviderAllowed, - isModelAllowed, - isLoading: isPermissionLoading, - } = usePermissionConfig() + const { isModelUsable, isLoading: isPermissionLoading } = usePermissionConfig() // Evaluate static options if provided as a function const staticOptions = useMemo(() => { const opts = typeof options === 'function' ? options() : options if (subBlockId === 'model') { - return opts.filter((opt) => { - const modelId = typeof opt === 'string' ? opt : opt.id - if (!isModelAllowed(modelId)) return false - try { - return isProviderAllowed(getProviderFromModel(modelId)) - } catch { - return true - } - }) + return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id)) } return opts - }, [options, subBlockId, isProviderAllowed, isModelAllowed]) + }, [options, subBlockId, isModelUsable]) const { fetchedOptions, @@ -210,15 +197,7 @@ export const ComboBox = memo(function ComboBox({ fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) { - opts = opts.filter((opt) => { - const modelId = typeof opt === 'string' ? opt : opt.id - if (!isModelAllowed(modelId)) return false - try { - return isProviderAllowed(getProviderFromModel(modelId)) - } catch { - return true - } - }) + opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id)) } // Merge hydrated option if not already present @@ -251,8 +230,7 @@ export const ComboBox = memo(function ComboBox({ hydratedOption, createdOption, subBlockId, - isProviderAllowed, - isModelAllowed, + isModelUsable, ]) // Convert options to Combobox format diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 1eb22d6b5a8..996fbf7f3f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -2,6 +2,10 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { ChipTag, Combobox, type ComboboxOption } from '@sim/emcn' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' +import { + NO_DENIED_OPERATIONS, + OPERATION_SUBBLOCK_ID, +} from '@/lib/permission-groups/operation-access' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options' @@ -11,7 +15,7 @@ import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' -import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useOperationAccess } from '@/hooks/use-operation-access' import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** Selected-value badges shown before folding the rest into a "+N" badge. */ @@ -97,7 +101,7 @@ export const Dropdown = memo(function Dropdown({ preserveLabelCase = false, }: DropdownProps) { const activeSearchTarget = useActiveSearchTarget() - const { isToolAllowed } = usePermissionConfig() + const { getDeniedOperations, resolveDefaultOperation, isPermissionLoading } = useOperationAccess() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) as [ string | string[] | null | undefined, (value: string | string[]) => void, @@ -189,26 +193,17 @@ export const Dropdown = memo(function Dropdown({ /** * Operation IDs whose resolved tool is denied by the caller's permission - * group. Only the `operation` selector of a block with a tool selector is - * gated. Denied operations are hidden from the picker (still resolvable for - * label display); the server is the authoritative gate regardless. + * group. Only the `operation` selector is gated. Denied operations are hidden + * from the picker (still resolvable for label display); the server is the + * authoritative gate regardless. */ const deniedOperationIds = useMemo(() => { - const denied = new Set() - if (subBlockId !== 'operation') return denied - const selectTool = blockConfig?.tools?.config?.tool - if (!selectTool) return denied - for (const opt of allOptions) { - const optionId = typeof opt === 'string' ? opt : opt.id - try { - const toolId = selectTool({ operation: optionId }) - if (toolId && !isToolAllowed(toolId)) denied.add(optionId) - } catch { - // Unresolvable from the operation alone — leave it visible; the server still enforces. - } - } - return denied - }, [subBlockId, blockConfig, allOptions, isToolAllowed]) + if (subBlockId !== OPERATION_SUBBLOCK_ID) return NO_DENIED_OPERATIONS + return getDeniedOperations( + blockConfig, + allOptions.map((opt) => (typeof opt === 'string' ? opt : opt.id)) + ) + }, [subBlockId, blockConfig, allOptions, getDeniedOperations]) const comboboxOptions = useMemo((): ComboboxOption[] => { const toLabel = (raw: string) => (preserveLabelCase ? raw : raw.toLowerCase()) @@ -232,17 +227,22 @@ export const Dropdown = memo(function Dropdown({ const defaultOptionValue = useMemo(() => { if (multiSelect) return undefined - const firstSelectable = comboboxOptions.find((opt) => !opt.hidden) - if (defaultValue !== undefined) { - // Don't seed a denied operation as the default; use the first allowed option. - if (deniedOperationIds.has(defaultValue)) { - return firstSelectable?.value - } - return defaultValue + /** + * The operation field defaults through the permission gate, which withholds + * a value until the group config has loaded. Seeding the static first + * option in that window would persist an operation the group denies — + * nothing revisits a field that already holds a value, so the correction + * that arrives with the config would never apply. + */ + if (subBlockId === OPERATION_SUBBLOCK_ID) { + const selectableIds = comboboxOptions.filter((opt) => !opt.hidden).map((opt) => opt.value) + return resolveDefaultOperation(blockConfig, selectableIds, defaultValue) } - return firstSelectable?.value - }, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect]) + if (defaultValue !== undefined) return defaultValue + + return comboboxOptions.find((opt) => !opt.hidden)?.value + }, [defaultValue, comboboxOptions, multiSelect, subBlockId, blockConfig, resolveDefaultOperation]) useEffect(() => { if (multiSelect || defaultOptionValue === undefined) { @@ -437,7 +437,9 @@ export const Dropdown = memo(function Dropdown({ onChange={handleChange} onMultiSelectChange={handleMultiSelectChange} placeholder={placeholder} - disabled={disabled} + /* The operation list only drops denied entries once the config resolves, + and a pick here persists — matching the agent tool selector. */ + disabled={disabled || (subBlockId === OPERATION_SUBBLOCK_ID && isPermissionLoading)} editable={false} onOpenChange={handleOpenChange} overlayContent={multiSelectOverlay ?? singleSelectOverlay} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index fe30b4dc656..55920dce208 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -26,6 +26,10 @@ import { } from '@/lib/mcp/tool-validation' import type { McpToolSchema } from '@/lib/mcp/types' import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth' +import { + NO_DENIED_OPERATIONS, + OPERATION_SUBBLOCK_ID, +} from '@/lib/permission-groups/operation-access' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { resolveStoredToolName } from '@/lib/workflows/subblocks/display' import { buildToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' @@ -65,7 +69,7 @@ import { getAllBlocks, getBlock } from '@/blocks' import { isCustomBlockType } from '@/blocks/custom/build-config' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getTileIconColorClass } from '@/blocks/icon-color' -import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' +import type { BlockConfig, SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' @@ -85,6 +89,7 @@ import { import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows' import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useOperationAccess } from '@/hooks/use-operation-access' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' @@ -355,25 +360,23 @@ function resolveCustomToolFromReference( /** * Checks if a block supports multiple operations. * - * @param blockType - The block type to check + * @param block - The block config to check * @returns `true` if the block has more than one tool operation available */ -function hasMultipleOperations(blockType: string): boolean { - const block = getAllBlocks().find((b) => b.type === blockType) +function hasMultipleOperations(block: BlockConfig | undefined): boolean { return (block?.tools?.access?.length || 0) > 1 } /** * Gets the available operation options for a multi-operation tool. * - * @param blockType - The block type to get operations for + * @param block - The block config to get operations for * @returns Array of operation options with label and id properties */ -function getOperationOptions(blockType: string): { label: string; id: string }[] { - const block = getAllBlocks().find((b) => b.type === blockType) +function getOperationOptions(block: BlockConfig | undefined): { label: string; id: string }[] { if (!block || !block.tools?.access) return [] - const operationSubBlock = block.subBlocks.find((sb) => sb.id === 'operation') + const operationSubBlock = block.subBlocks.find((sb) => sb.id === OPERATION_SUBBLOCK_ID) if ( operationSubBlock && operationSubBlock.type === 'dropdown' && @@ -662,13 +665,47 @@ export const ToolInput = memo(function ToolInput({ const provider = model ? getProviderFromModel(model) : '' const supportsToolControl = provider ? supportsToolUsageControl(provider) : false - const { filterBlocks, config: permissionConfig } = usePermissionConfig() + const { + filterBlocks, + config: permissionConfig, + isLoading: isPermissionLoading, + } = usePermissionConfig() + const { getDeniedOperations } = useOperationAccess() + + /** + * A tool block's selectable operations paired with the ones the caller's + * permission group denies. + * + * Both callers derive from this single result so they cannot drift: the + * picker *removes* denied operations (it must never offer or default to one), + * while the editor's selector *hides* them (a tool already saved on one keeps + * showing its name). + */ + const getOperationChoices = useCallback( + (block: BlockConfig | undefined) => { + const options = getOperationOptions(block).filter((option) => option.id !== '') + return { + options, + denied: getDeniedOperations( + block, + options.map((option) => option.id) + ), + } + }, + [getDeniedOperations] + ) const customBlockOverlayVersion = useCustomBlockOverlayVersion() const toolBlocks = useMemo(() => { const allToolBlocks = getAllBlocks().filter(isAgentToolBlock) - return filterBlocks(allToolBlocks) - }, [filterBlocks, customBlockOverlayVersion]) + /* An empty option list means the block declares no selectable operation, so + there is nothing to gate — only a wholly denied one leaves the picker. */ + return filterBlocks(allToolBlocks).filter((block) => { + if (!hasMultipleOperations(block)) return true + const { options, denied } = getOperationChoices(block) + return options.length === 0 || options.some((option) => !denied.has(option.id)) + }) + }, [filterBlocks, customBlockOverlayVersion, getOperationChoices]) const hasBackfilledRef = useRef(false) useEffect(() => { @@ -744,7 +781,7 @@ export const ToolInput = memo(function ToolInput({ * @returns `true` if tool is already selected (for single-operation tools only) */ const isToolAlreadySelected = (toolId: string, blockType: string) => { - if (hasMultipleOperations(blockType)) { + if (hasMultipleOperations(getBlock(blockType))) { return false } // Custom blocks all share toolId `workflow_executor`, so dedup-by-toolId would @@ -783,9 +820,10 @@ export const ToolInput = memo(function ToolInput({ (toolBlock: (typeof toolBlocks)[0]) => { if (isPreview || disabled) return - const hasOperations = hasMultipleOperations(toolBlock.type) - const operationOptions = hasOperations ? getOperationOptions(toolBlock.type) : [] - const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined + const { options, denied } = hasMultipleOperations(toolBlock) + ? getOperationChoices(toolBlock) + : { options: [], denied: NO_DENIED_OPERATIONS } + const defaultOperation = options.find((option) => !denied.has(option.id))?.id const toolId = getToolIdForOperation(toolBlock.type, defaultOperation, toolBlock) if (!toolId) return @@ -821,7 +859,7 @@ export const ToolInput = memo(function ToolInput({ setOpen(false) }, - [isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue] + [isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue, getOperationChoices] ) const handleAddCustomTool = useCallback( @@ -1670,7 +1708,11 @@ export const ToolInput = memo(function ToolInput({ options={[]} groups={toolGroups} placeholder='Add tool...' - disabled={disabled} + /* Every list this picker offers — blocks, operations, MCP and custom + tools — reads as unrestricted until the permission config resolves, + and adding a tool is a one-shot write that nothing revisits. Closed + rather than optimistic for that beat. */ + disabled={disabled || isPermissionLoading} searchable searchPlaceholder='Search tools...' maxHeight={240} @@ -1799,7 +1841,8 @@ export const ToolInput = memo(function ToolInput({ ) : [] - const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) + const hasOperations = + !isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined) const hasParams = useSubBlocks ? displaySubBlocks.length > 0 : displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0 @@ -2059,26 +2102,33 @@ export const ToolInput = memo(function ToolInput({
{/* Operation dropdown for tools with multiple operations */} {(() => { - const hasOperations = hasMultipleOperations(tool.type) - const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] + if (!hasOperations) return null + const { options: operationOptions, denied } = getOperationChoices( + toolBlock ?? undefined + ) + if (operationOptions.length === 0) return null - return hasOperations && operationOptions.length > 0 ? ( + return (
Operation
option.id !== '') - .map((option) => ({ - label: option.label, - value: option.id, - }))} - value={tool.operation || operationOptions[0].id} + options={operationOptions.map((option) => ({ + label: option.label, + value: option.id, + hidden: denied.has(option.id), + }))} + value={ + tool.operation || + operationOptions.find((option) => !denied.has(option.id))?.id + } onChange={(value) => handleOperationChange(toolIndex, value)} placeholder='Select operation' - disabled={disabled} + /* Denied operations only drop out once the config + resolves, and picking one rewrites the stored tool. */ + disabled={disabled || isPermissionLoading} />
- ) : null + ) })()} {(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index fdfc5a2ef8e..2d035136aeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -17,6 +17,7 @@ import 'reactflow/dist/style.css' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { omit } from '@sim/utils/object' import type { SubflowNodeData } from '@sim/workflow-renderer' import { BLOCK_DIMENSIONS, @@ -40,6 +41,7 @@ import { useSession } from '@/lib/auth/auth-client' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import type { OAuthProvider } from '@/lib/oauth' +import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' import { getDefaultBlockName } from '@/lib/workflows/blocks/canvas-presentation' import { requestNoteImage, requestNoteRename } from '@/lib/workflows/notes/canvas-requests' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' @@ -126,6 +128,7 @@ import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows' import { useCanvasViewport } from '@/hooks/use-canvas-viewport' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return' +import { useOperationAccess } from '@/hooks/use-operation-access' import { useCanvasModeStore } from '@/stores/canvas-mode' import { useChatStore } from '@/stores/chat/store' import { @@ -867,6 +870,8 @@ const WorkflowContent = React.memo( */ const pendingFocusBlockIdRef = useRef(null) + const { resolveSeedGate } = useOperationAccess() + const addBlock = useCallback( ( id: string, @@ -888,6 +893,8 @@ const WorkflowContent = React.memo( if (parentId) blockData.parentId = parentId if (extent) blockData.extent = extent + const seedGate = resolveSeedGate(getBlock(type)) + const block = prepareBlockState({ id, type, @@ -897,6 +904,7 @@ const WorkflowContent = React.memo( parentId, extent, triggerMode, + isSeededValueAllowed: seedGate, }) const subBlockValues: Record> = {} @@ -914,7 +922,21 @@ const WorkflowContent = React.memo( if (!subBlockValues[id]) { subBlockValues[id] = {} } - Object.assign(subBlockValues[id], presetSubBlockValues) + /* The same gate as the declared default, deliberately: a preset is + offered by search and the connection picker, whose index reads as + unrestricted until the config resolves — so it is not the informed + pick it looks like, and honouring it would persist an operation + from an unfiltered list. */ + const presetOperation = presetSubBlockValues[OPERATION_SUBBLOCK_ID] + const presetOperationDenied = + typeof presetOperation === 'string' && !seedGate(OPERATION_SUBBLOCK_ID, presetOperation) + + Object.assign( + subBlockValues[id], + presetOperationDenied + ? omit(presetSubBlockValues, [OPERATION_SUBBLOCK_ID]) + : presetSubBlockValues + ) } collaborativeBatchAddBlocks( @@ -926,7 +948,7 @@ const WorkflowContent = React.memo( ) usePanelEditorStore.getState().setCurrentBlockId(id) }, - [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection] + [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection, resolveSeedGate] ) const { activeBlockIds, pendingBlocks, isDebugging, isExecuting } = useExecutionStore( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 696fa18ef49..e800bc91553 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -457,6 +457,7 @@ export const Sidebar = memo(function Sidebar({ config: permissionConfig, filterBlocks, isBlockAllowed, + isToolAllowed, integrationAvailability, } = usePermissionConfig() const { navigateToSettings } = useSettingsNavigation() @@ -472,8 +473,14 @@ export const Sidebar = memo(function Sidebar({ ) useEffect(() => { - initializeSearchData(filterBlocks) - }, [initializeSearchData, filterBlocks, providerModelSignature, customBlockOverlayVersion]) + initializeSearchData(filterBlocks, isToolAllowed) + }, [ + initializeSearchData, + filterBlocks, + isToolAllowed, + providerModelSignature, + customBlockOverlayVersion, + ]) const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth) const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) diff --git a/apps/sim/hooks/use-operation-access.ts b/apps/sim/hooks/use-operation-access.ts new file mode 100644 index 00000000000..1395e20658c --- /dev/null +++ b/apps/sim/hooks/use-operation-access.ts @@ -0,0 +1,99 @@ +'use client' + +import { useMemo } from 'react' +import { + collectDeniedOperationIds, + isOperationAllowed, + MODEL_SUBBLOCK_ID, + NO_DENIED_OPERATIONS, + OPERATION_SUBBLOCK_ID, + type OperationGateBlock, + pickDefaultOperation, + type SeedValueGate, +} from '@/lib/permission-groups/operation-access' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +export interface OperationAccess { + /** + * Whether the permission config is still loading. Every list this module + * filters reads as unrestricted until it resolves, so a surface that + * *persists* a pick from one must not accept input while this is true. + */ + isPermissionLoading: boolean + /** + * The operation ids of `block` the caller may not run. Empty while the + * config loads, so pickers show everything rather than flashing a short list. + */ + getDeniedOperations: ( + block: OperationGateBlock | null | undefined, + operationIds: Iterable + ) => ReadonlySet + /** + * The operation to seed an unset field with: `preferred` when allowed, else + * the first allowed candidate. + */ + resolveDefaultOperation: ( + block: OperationGateBlock | null | undefined, + candidates: Iterable, + preferred?: string + ) => string | undefined + /** + * A predicate for deciding whether an operation of `block` may be *persisted* + * — or `undefined` while the permission config is still loading. + * + * The withholding is the point. The config resolves as "nothing denied" in + * flight, so a value written during that window would outlive the correction + * that arrives with it. Handing back `undefined` rather than an + * always-`true` predicate means a caller cannot persist without first + * deciding what to do when the answer is unknown. + */ + resolveOperationGate: ( + block: OperationGateBlock | null | undefined + ) => ((operationId: string) => boolean) | undefined + /** + * The veto `prepareBlockState` applies to a new block's declared defaults. + * + * Creation is one-shot, so unlike the pickers it cannot answer "unknown" by + * waiting — a value written there is never revisited. This gate therefore + * rejects both restricted fields until the config resolves, leaving them + * empty for the pickers to fill, and owns that rule so no caller re-derives + * it. Every other field passes through untouched. + */ + resolveSeedGate: (block: OperationGateBlock | null | undefined) => SeedValueGate +} + +/** + * Permission-group access to a block's operations. + * + * The single place the "which operations may this user run, and which one + * should an unset field land on" question is answered, so every surface that + * offers operations — the block editor's dropdown, the agent block's tool list, + * canvas search, block creation — agrees. + */ +export function useOperationAccess(): OperationAccess { + const { isToolAllowed, isModelUsable, isLoading } = usePermissionConfig() + + return useMemo(() => { + const isReady = !isLoading + return { + isPermissionLoading: isLoading, + getDeniedOperations: (block, operationIds) => + isReady + ? collectDeniedOperationIds(block, operationIds, isToolAllowed) + : NO_DENIED_OPERATIONS, + resolveDefaultOperation: (block, candidates, preferred) => + isReady ? pickDefaultOperation(block, candidates, isToolAllowed, preferred) : undefined, + resolveOperationGate: (block) => + isReady + ? (operationId: string) => isOperationAllowed(block, operationId, isToolAllowed) + : undefined, + resolveSeedGate: (block) => (subBlockId, value) => { + if (subBlockId !== OPERATION_SUBBLOCK_ID && subBlockId !== MODEL_SUBBLOCK_ID) return true + if (!isReady) return false + return subBlockId === OPERATION_SUBBLOCK_ID + ? isOperationAllowed(block, value, isToolAllowed) + : isModelUsable(value) + }, + } + }, [isToolAllowed, isModelUsable, isLoading]) +} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 0f132d1dde7..0b1cb308568 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -24,6 +24,7 @@ import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/p import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { overlayVisibility } from '@/blocks/visibility/context' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' +import { findProviderFromModel } from '@/providers/utils' export interface PermissionConfigResult { config: PermissionGroupConfig @@ -32,8 +33,12 @@ export interface PermissionConfigResult { filterBlocks: (blocks: T[]) => T[] filterProviders: (providerIds: string[]) => string[] isBlockAllowed: (blockType: string) => boolean - isProviderAllowed: (providerId: string) => boolean - isModelAllowed: (model: string) => boolean + /** + * Whether a model is usable at all: allowed by the model denylist *and* by + * the provider allowlist. Both gates apply to every model field, so this is + * the only model predicate the interface exposes. + */ + isModelUsable: (model: string) => boolean isToolAllowed: (toolId: string) => boolean isInvitationsDisabled: boolean isPublicApiDisabled: boolean @@ -120,20 +125,35 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [config.allowedModelProviders]) + /** Indexed so the per-model check stays O(1) over a long denylist. */ + const deniedModelSet = useMemo( + () => new Set(config.deniedModels.map((denied) => denied.toLowerCase())), + [config.deniedModels] + ) + const isModelAllowed = useMemo(() => { + return (model: string) => !deniedModelSet.has(model.toLowerCase()) + }, [deniedModelSet]) + + const isModelUsable = useMemo(() => { return (model: string) => { - if (config.deniedModels.length === 0) return true - const normalized = model.toLowerCase() - return !config.deniedModels.some((denied) => denied.toLowerCase() === normalized) + if (!isModelAllowed(model)) return false + const providerId = findProviderFromModel(model) + /* Only chat models resolve to a provider. A `model` field holding an + embedding, speech, image or video id is not a provider choice, so the + provider allowlist has nothing to say about it — judging it anyway + would read every such id as Ollama and reject it. */ + if (!providerId) return true + return isProviderAllowed(providerId) } - }, [config.deniedModels]) + }, [isModelAllowed, isProviderAllowed]) + + /** Indexed so the per-tool check stays O(1) over a long denylist. */ + const deniedToolSet = useMemo(() => new Set(config.deniedTools), [config.deniedTools]) const isToolAllowed = useMemo(() => { - return (toolId: string) => { - if (config.deniedTools.length === 0) return true - return !config.deniedTools.includes(toolId) - } - }, [config.deniedTools]) + return (toolId: string) => !deniedToolSet.has(toolId) + }, [deniedToolSet]) const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { @@ -171,8 +191,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isProviderAllowed, - isModelAllowed, + isModelUsable, isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, @@ -185,8 +204,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isProviderAllowed, - isModelAllowed, + isModelUsable, isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, diff --git a/apps/sim/lib/permission-groups/operation-access.test.ts b/apps/sim/lib/permission-groups/operation-access.test.ts new file mode 100644 index 00000000000..457cd0815dc --- /dev/null +++ b/apps/sim/lib/permission-groups/operation-access.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + collectDeniedOperationIds, + isOperationAllowed, + type OperationGateBlock, + pickDefaultOperation, +} from '@/lib/permission-groups/operation-access' + +/** A block that resolves its tool from the operation, like most integrations. */ +const selectorBlock: OperationGateBlock = { + tools: { + access: ['slack_message', 'slack_canvas', 'slack_read'], + config: { + tool: (params) => { + const map: Record = { + send: 'slack_message', + canvas: 'slack_canvas', + read: 'slack_read', + } + const toolId = map[params.operation as string] + if (!toolId) throw new Error(`unknown operation: ${params.operation}`) + return toolId + }, + }, + }, +} + +/** A block with no selector, whose operation ids are its tool ids. */ +const bareBlock: OperationGateBlock = { + tools: { access: ['sqs_send', 'sqs_receive'] }, +} + +const singleToolBlock: OperationGateBlock = { + tools: { access: ['dropcontact_enrich_contact'] }, +} + +const denyAll = () => false +const allowAll = () => true +const deny = (...toolIds: string[]) => { + const denied = new Set(toolIds) + return (toolId: string) => !denied.has(toolId) +} + +describe('operation-to-tool resolution', () => { + it('resolves through the block tool selector', () => { + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_canvas'))).toBe(false) + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_message'))).toBe(true) + }) + + it('gates on the only tool when the block has no selection to make', () => { + expect( + isOperationAllowed(singleToolBlock, 'anything', deny('dropcontact_enrich_contact')) + ).toBe(false) + }) + + it('treats an operation id as a tool id when the block has no selector', () => { + expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_receive'))).toBe(false) + expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_send'))).toBe(true) + }) + + it('allows rather than guessing when a block has no tools at all', () => { + expect(isOperationAllowed({ tools: { access: [] } }, 'send', denyAll)).toBe(true) + expect(isOperationAllowed(null, 'send', denyAll)).toBe(true) + expect(isOperationAllowed(undefined, 'send', denyAll)).toBe(true) + }) +}) + +describe('isOperationAllowed', () => { + it('denies an operation whose tool the group denies', () => { + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_canvas'))).toBe(false) + expect(isOperationAllowed(selectorBlock, 'send', deny('slack_canvas'))).toBe(true) + }) + + it('allows an unresolvable operation, leaving the server as the gate', () => { + expect(isOperationAllowed(selectorBlock, 'not-an-operation', denyAll)).toBe(true) + }) +}) + +describe('collectDeniedOperationIds', () => { + it('collects only the operations whose tools are denied', () => { + const denied = collectDeniedOperationIds( + selectorBlock, + ['send', 'canvas', 'read'], + deny('slack_message', 'slack_read') + ) + expect([...denied]).toEqual(['send', 'read']) + }) + + it('is empty when nothing is denied', () => { + expect(collectDeniedOperationIds(selectorBlock, ['send', 'canvas'], allowAll).size).toBe(0) + }) +}) + +describe('pickDefaultOperation', () => { + const candidates = ['send', 'canvas', 'read'] + + it('keeps the preferred operation when the group allows it', () => { + expect(pickDefaultOperation(selectorBlock, candidates, allowAll, 'canvas')).toBe('canvas') + }) + + it('falls back to the first allowed operation when the preferred one is denied', () => { + expect(pickDefaultOperation(selectorBlock, candidates, deny('slack_message'), 'send')).toBe( + 'canvas' + ) + }) + + it('takes the first allowed operation when there is no preference', () => { + expect(pickDefaultOperation(selectorBlock, candidates, deny('slack_message'))).toBe('canvas') + }) + + it('returns undefined when every candidate is denied', () => { + expect( + pickDefaultOperation( + selectorBlock, + candidates, + deny('slack_message', 'slack_canvas', 'slack_read'), + 'send' + ) + ).toBeUndefined() + }) + + it('keeps a preferred operation it cannot resolve, matching the permissive gate', () => { + expect(pickDefaultOperation(selectorBlock, candidates, denyAll, 'not-an-operation')).toBe( + 'not-an-operation' + ) + }) +}) diff --git a/apps/sim/lib/permission-groups/operation-access.ts b/apps/sim/lib/permission-groups/operation-access.ts new file mode 100644 index 00000000000..6856cb06976 --- /dev/null +++ b/apps/sim/lib/permission-groups/operation-access.ts @@ -0,0 +1,127 @@ +import type { BlockConfig } from '@/blocks/types' + +/** + * The subblock id that carries a block's operation. + * + * Singular only. Four blocks (`elasticsearch`, `mailchimp`, `onepassword`, + * `typeform`) also declare an `operations` subblock, but it holds a JSON-patch + * payload rather than an operation selector — matching it could only ever key + * a gate off the wrong value. + */ +export const OPERATION_SUBBLOCK_ID = 'operation' + +export const MODEL_SUBBLOCK_ID = 'model' + +/** Shared empty result, so a caller's memo sees a stable identity. */ +export const NO_DENIED_OPERATIONS: ReadonlySet = new Set() + +/** The slice of a block config the operation gate reads. */ +export type OperationGateBlock = Pick + +/** Decides whether the caller's permission group allows a concrete tool id. */ +export type IsToolAllowed = (toolId: string) => boolean + +/** + * Vetoes a subblock's declared default when the caller's permission group does + * not allow it — or when the group config is not known yet, since a default + * written during block creation is never revisited. + */ +export type SeedValueGate = (subBlockId: string, value: string) => boolean + +/** + * The tool id a block operation maps to, or `null` when it cannot be resolved + * from the operation alone. + * + * Deliberately not `getToolIdForOperation` from `@/tools/params`: that one ends + * with an unconditional `access[0]` fallback, which for a gate would authorize + * an unrecognized operation against a tool it has nothing to do with. It also + * logs on every selector throw, and this runs once per option of every block + * offered. + * + * Never guesses. A selector that also reads sibling fields throws when handed + * an operation on its own, and an operation the block does not recognize has + * no tool — both yield `null`, which callers read as "not gateable here". The + * server-side gate in `assertPermissionsAllowed` stays authoritative either + * way, so a `null` only ever costs a denied option staying visible, never a + * permitted option disappearing. + */ +function resolveOperationToolId( + block: OperationGateBlock | null | undefined, + operationId: string +): string | null { + const access = block?.tools?.access + if (!access || access.length === 0) return null + + /* One tool means there is nothing to select: the block runs that tool + whatever its operation dropdown says. */ + if (access.length === 1) return access[0] + + const selectTool = block?.tools?.config?.tool + if (selectTool) { + try { + const toolId = selectTool({ operation: operationId }) + if (toolId) return toolId + } catch { + /* Unresolvable from the operation alone; see the TSDoc above. */ + } + } + + /* Blocks with no selector list their tool ids as their operation ids. */ + return access.includes(operationId) ? operationId : null +} + +/** Whether the caller's permission group allows a block operation. */ +export function isOperationAllowed( + block: OperationGateBlock | null | undefined, + operationId: string, + isToolAllowed: IsToolAllowed +): boolean { + const toolId = resolveOperationToolId(block, operationId) + if (!toolId) return true + return isToolAllowed(toolId) +} + +/** + * The operation ids of `block` whose tool the caller's permission group denies. + * + * Denied operations are hidden from pickers rather than removed from the model, + * so a workflow that already references one keeps resolving its label. + */ +export function collectDeniedOperationIds( + block: OperationGateBlock | null | undefined, + operationIds: Iterable, + isToolAllowed: IsToolAllowed +): ReadonlySet { + const denied = new Set() + for (const operationId of operationIds) { + if (!isOperationAllowed(block, operationId, isToolAllowed)) { + denied.add(operationId) + } + } + return denied +} + +/** + * The operation an unset field should be seeded with: `preferred` when the + * group allows it, otherwise the first candidate it does allow. + * + * Returns `undefined` when every candidate is denied. `useOperationAccess` + * additionally returns `undefined` while the permission config is loading, so a + * caller that seeds only on a defined value can never persist a denied default. + */ +export function pickDefaultOperation( + block: OperationGateBlock | null | undefined, + candidates: Iterable, + isToolAllowed: IsToolAllowed, + preferred?: string +): string | undefined { + if (preferred !== undefined && isOperationAllowed(block, preferred, isToolAllowed)) { + return preferred + } + + for (const candidate of candidates) { + if (isOperationAllowed(block, candidate, isToolAllowed)) return candidate + } + + return undefined +} diff --git a/apps/sim/lib/workflows/blocks/canvas-sentence.ts b/apps/sim/lib/workflows/blocks/canvas-sentence.ts index f88af9654f3..b8979245a5d 100644 --- a/apps/sim/lib/workflows/blocks/canvas-sentence.ts +++ b/apps/sim/lib/workflows/blocks/canvas-sentence.ts @@ -1,3 +1,4 @@ +import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' import { resolveFieldNoun } from '@/lib/workflows/blocks/canvas-sentence-noun' import { resolveTriggerSentence } from '@/lib/workflows/blocks/canvas-trigger-sentence' import type { @@ -26,16 +27,6 @@ import type { */ export type ResolvedSentenceSegment = string | { subBlockId: string; noun?: string } -/** - * The subblock id that carries a block's operation. - * - * Singular only. Four blocks (`elasticsearch`, `mailchimp`, `onepassword`, - * `typeform`) also declare an `operations` subblock, but it holds a JSON-patch - * payload rather than an operation selector — matching it could only ever key - * a sentence off the wrong value. - */ -const OPERATION_SUBBLOCK_ID = 'operation' - type CanvasSentenceConfig = Pick /** diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 6ca29d14578..9deedf19aab 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -16,6 +16,7 @@ import { describeModelLevel, extractAndParseJSON, filterBlacklistedModels, + findProviderFromModel, formatCost, generateStructuredOutputInstructions, getAllModelProviders, @@ -2045,3 +2046,27 @@ describe('describeModelLevel', () => { expect(describeModelLevel('')).toBe('(unset)') }) }) + +describe('findProviderFromModel', () => { + it('resolves a chat model to its declaring provider', () => { + expect(findProviderFromModel('claude-sonnet-5')).toBe('anthropic') + expect(findProviderFromModel('gpt-5.2')).toBe('openai') + }) + + it('is case-insensitive, like getProviderFromModel', () => { + expect(findProviderFromModel('Claude-Sonnet-5')).toBe('anthropic') + }) + + it('returns null for ids the registry does not declare, instead of guessing ollama', () => { + /* The registry holds chat models only. Speech, image, video and embedding + ids reach `model` subblocks too, and a permission gate must not read them + as Ollama models — see isModelUsable. */ + for (const id of ['whisper-1', 'dall-e-3', 'veo-3.1', 'embed-v4.0', 'tts-1']) { + expect(findProviderFromModel(id)).toBeNull() + } + }) + + it('still lets getProviderFromModel fall back to ollama for those ids', () => { + expect(getProviderFromModel('whisper-1')).toBe('ollama') + }) +}) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1cb25c7da2d..037dc757712 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -286,27 +286,35 @@ export function getAllModelProviders(): Record { ) } -export function getProviderFromModel(model: string): ProviderId { +/** + * The provider that declares `model`, or `null` when none does. + * + * The non-guessing half of {@link getProviderFromModel}. A caller that *gates* + * on the answer needs "unknown" to stay distinct from "ollama": this registry + * holds chat models only, so every embedding, speech, image and video model id + * would otherwise read as an Ollama model and be judged against an allowlist + * that was never about it. + */ +export function findProviderFromModel(model: string): ProviderId | null { const normalizedModel = model.toLowerCase() - let providerId: ProviderId | null = null + const declared = getAllModelProviders()[normalizedModel] + if (declared) return declared - if (normalizedModel in getAllModelProviders()) { - providerId = getAllModelProviders()[normalizedModel] - } else { - for (const [id, config] of Object.entries(providers)) { - if (config.modelPatterns) { - for (const pattern of config.modelPatterns) { - if (pattern.test(normalizedModel)) { - providerId = id as ProviderId - break - } - } - } - if (providerId) break + for (const [id, config] of Object.entries(providers)) { + for (const pattern of config.modelPatterns ?? []) { + if (pattern.test(normalizedModel)) return id as ProviderId } } + return null +} + +export function getProviderFromModel(model: string): ProviderId { + const normalizedModel = model.toLowerCase() + + let providerId = findProviderFromModel(model) + if (!providerId) { logger.warn(`No provider found for model: ${model}, defaulting to ollama`) providerId = 'ollama' diff --git a/apps/sim/stores/modals/search/store.test.ts b/apps/sim/stores/modals/search/store.test.ts index 1d96b0a4662..032bc2875bd 100644 --- a/apps/sim/stores/modals/search/store.test.ts +++ b/apps/sim/stores/modals/search/store.test.ts @@ -136,7 +136,10 @@ describe('search modal store', () => { mockGetAllBlocks.mockReturnValue([visibleBlock, hiddenBlock]) - useSearchModalStore.getState().initializeData((blocks) => blocks) + useSearchModalStore.getState().initializeData( + (blocks) => blocks, + () => true + ) const { tools } = useSearchModalStore.getState().data expect(tools).toHaveLength(1) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 337583e9672..8c60ec9d152 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -1,10 +1,11 @@ import { Repeat, Split } from '@sim/emcn/icons' import { create } from 'zustand' import { devtools } from 'zustand/middleware' +import { isOperationAllowed } from '@/lib/permission-groups/operation-access' import { toSearchToken } from '@/lib/search/tokens' import { getToolOperationsIndex } from '@/lib/search/tool-operations' import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' -import { getAllBlocks } from '@/blocks' +import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { SearchBlockItem, @@ -80,7 +81,7 @@ export const useSearchModalStore = create()( set({ isOpen: false }) }, - initializeData: (filterBlocks) => { + initializeData: (filterBlocks, isToolAllowed) => { const allBlocks = getAllBlocks() const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks @@ -158,6 +159,10 @@ export const useSearchModalStore = create()( const allowedBlockTypes = new Set(tools.map((t) => t.type)) const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex() .filter((op) => allowedBlockTypes.has(op.blockType)) + /* Selecting a result drops a block already set to that operation, so + the group's tool denylist has to apply here too — the block-level + allowlist above only decides whether the integration is offered. */ + .filter((op) => isOperationAllowed(getBlock(op.blockType), op.operationId, isToolAllowed)) .map((op) => { const aliasesStr = op.aliases?.length ? ` ${op.aliases.map(toSearchToken).join(' ')}` diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index e7f8c7cee39..16a8d3b4821 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -1,4 +1,5 @@ import type { ComponentType } from 'react' +import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' import type { BlockConfig } from '@/blocks/types' /** @@ -78,7 +79,11 @@ export interface SearchModalState { close: () => void /** - * Initialize search data. Called once on app load. + * Initialize search data. Re-runs whenever the caller's permission config + * resolves or changes, since both predicates are derived from it. */ - initializeData: (filterBlocks: (blocks: T[]) => T[]) => void + initializeData: ( + filterBlocks: (blocks: T[]) => T[], + isToolAllowed: IsToolAllowed + ) => void } diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 0d1850b0695..622feb67662 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -7,9 +7,10 @@ import { createStarterBlock, } from '@sim/testing' import type { Edge } from 'reactflow' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getBlock } from '@/blocks/registry' import { normalizeName } from '@/executor/constants' -import { filterNewEdges, getUniqueBlockName, regenerateBlockIds } from './utils' +import { filterNewEdges, getUniqueBlockName, prepareBlockState, regenerateBlockIds } from './utils' describe('normalizeName', () => { it.concurrent('should convert to lowercase', () => { @@ -1063,3 +1064,102 @@ describe('regenerateBlockIds — cloned webhook path', () => { expect(result.subBlockValues[newId].token).toBe('user-secret') }) }) + +describe('prepareBlockState — permission-group seed veto', () => { + const blockWithDefaults = { + name: 'Mock Block', + description: '', + icon: () => null, + outputs: {}, + tools: { access: ['slack_message'] }, + subBlocks: [ + { id: 'operation', type: 'dropdown', defaultValue: 'send' }, + { id: 'model', type: 'combobox', defaultValue: 'claude-sonnet-5' }, + { id: 'channel', type: 'short-input', defaultValue: '#general' }, + { id: 'blank', type: 'short-input', defaultValue: '' }, + { id: 'headers', type: 'table', defaultValue: [] }, + ], + } + + const seededValues = (isSeededValueAllowed?: (subBlockId: string, value: string) => boolean) => { + vi.mocked(getBlock).mockReturnValueOnce(blockWithDefaults as never) + const block = prepareBlockState({ + id: 'b1', + type: 'slack', + name: 'Slack', + position: { x: 0, y: 0 }, + isSeededValueAllowed, + }) + return Object.fromEntries( + Object.entries(block.subBlocks).map(([id, subBlock]) => [id, subBlock.value]) + ) + } + + afterEach(() => { + vi.mocked(getBlock).mockReset() + }) + + it('seeds every declared default when no gate is supplied', () => { + expect(seededValues()).toEqual({ + operation: 'send', + model: 'claude-sonnet-5', + channel: '#general', + blank: '', + headers: [], + }) + }) + + it('seeds every declared default when the gate allows them', () => { + expect(seededValues(() => true)).toEqual({ + operation: 'send', + model: 'claude-sonnet-5', + channel: '#general', + blank: '', + headers: [], + }) + }) + + it('never consults the gate for an empty or non-string default', () => { + /* Both are "nothing was declared" rather than a value to authorize, and a + gate that saw them would veto every unfilled field. */ + const seen: string[] = [] + seededValues((subBlockId) => { + seen.push(subBlockId) + return true + }) + expect(seen).not.toContain('blank') + expect(seen).not.toContain('headers') + }) + + it('keeps an empty or non-string default even when the gate rejects everything', () => { + const values = seededValues(() => false) + expect(values.blank).toBe('') + expect(values.headers).toEqual([]) + }) + + it('leaves a denied operation unseeded rather than substituting one', () => { + const values = seededValues((subBlockId) => subBlockId !== 'operation') + expect(values.operation).toBeNull() + expect(values.model).toBe('claude-sonnet-5') + expect(values.channel).toBe('#general') + }) + + it('leaves a denied model unseeded', () => { + const values = seededValues((subBlockId) => subBlockId !== 'model') + expect(values.model).toBeNull() + expect(values.operation).toBe('send') + }) + + it('passes the seeded value to the gate, not just the field id', () => { + const seen: Array<[string, string]> = [] + seededValues((subBlockId, value) => { + seen.push([subBlockId, value]) + return true + }) + expect(seen).toEqual([ + ['operation', 'send'], + ['model', 'claude-sonnet-5'], + ['channel', '#general'], + ]) + }) +}) diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 9075df4d958..34a5cc1a54a 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -2,6 +2,7 @@ import { generateId } from '@sim/utils/id' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' +import type { SeedValueGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' @@ -101,6 +102,23 @@ export interface PrepareBlockStateOptions { parentId?: string extent?: 'parent' triggerMode?: boolean + /** + * Vetoes a declared default that the creator's permission group denies — + * today the `operation` and `model` fields, both of which blocks pre-fill. + * + * A vetoed field is seeded with nothing rather than a substitute. The editor's + * own permission-aware pickers already resolve the right replacement (first + * allowed operation; preferred-then-first allowed model) and they only fill a + * field that is empty, so leaving it empty hands the choice to the one place + * that knows how to make it. Substituting here instead would also drift from + * `getDefaultBlockName`, which names the block after its *declared* default. + * + * Omit it entirely only where permission gating does not apply, in which case + * declared defaults are seeded unchanged. A caller that cannot yet answer — + * config still loading — vetoes rather than omitting, since a value written + * here is never revisited. + */ + isSeededValueAllowed?: SeedValueGate } /** @@ -108,7 +126,17 @@ export interface PrepareBlockStateOptions { * Generates subBlocks and outputs from the block registry. */ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState { - const { id, type, name, position, data, parentId, extent, triggerMode = false } = options + const { + id, + type, + name, + position, + data, + parentId, + extent, + triggerMode = false, + isSeededValueAllowed, + } = options const blockConfig = getBlock(type) @@ -153,6 +181,15 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState initialValue = [] } + if ( + isSeededValueAllowed && + typeof initialValue === 'string' && + initialValue !== '' && + !isSeededValueAllowed(subBlock.id, initialValue) + ) { + initialValue = null + } + subBlocks[subBlock.id] = { id: subBlock.id, type: subBlock.type,