From 96f7b2395a36a1066f680cb4fef0be3dac994c87 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 14 Aug 2026 19:20:49 -0700 Subject: [PATCH] fix(access-control): default every operation and model picker to one the permission group allows (#6720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(access-control): default every operation picker to one the group allows The block editor's operation dropdown already hid operations whose tool the caller's permission group denies, but it seeded its default without waiting for that config. `usePermissionConfig` resolves as "nothing denied" while its query is in flight, so a freshly dropped block persisted the static first operation — and nothing revisits a field that already holds a value, so the correction that arrived with the config never applied. A user whose group denies `slack_message` still got a Slack block sitting on Send Message. The model combobox already had this guard; the dropdown did not. Consolidates the rule behind `lib/permission-groups/operation-access` and `useOperationAccess`, which resolves an operation to its tool without guessing (an unresolvable one stays visible; the server gate stays authoritative) and withholds a default until the config has loaded, so seeding on a defined value is the whole guard. Applies it to every surface that offers or seeds an operation: - block editor dropdown — default now waits for the config - agent block tool list — operations were not gated at all; the picker now hides denied ones, drops blocks whose every operation is denied, and defaults to the first allowed - canvas search / connection picker — the tool-operation index was filtered only by the block allowlist, so denied operations were still offered as one-click block drops - block creation — a declared default operation the group denies is replaced with the first allowed one, and a denied preset operation is discarded * fix(access-control): gate the seeded model too, and collapse the seed rule Block creation seeds `model` the same way it seeds `operation`: `agent`, `router` and `evaluator` all declare `defaultValue: 'claude-sonnet-5'`, and `prepareBlockState` wrote it unconditionally. The model combobox only fills a field that is empty, so a group denying that model (or the Anthropic provider) got an Agent block pre-filled with a model it cannot run — the same bug as the operation one, on the other axis the permission group governs. Rather than a second bespoke gate, `prepareBlockState` now takes one veto, `isSeededValueAllowed(subBlockId, value)`, and seeds nothing when a declared default is denied. Nothing substitutes a replacement there any more: the editor's own permission-aware pickers already resolve the right one and only fill an empty field, and substituting in the store would drift from `getDefaultBlockName`, which names a block after its *declared* default. That also deletes `firstAllowedOperation` and its copy of subblock-option enumeration. Review follow-ups: - `usePermissionConfig` gains `isModelUsable` (denylist AND provider allowlist); the combobox's two hand-rolled copies of that pair now call it - `isToolAllowed`/`isModelAllowed` index their denylists — the gate calls them once per option of every block offered, so a linear scan made a check's cost scale with denylist length (measured 3.2ms -> 0.30ms per search-index build at 500 denied tools) - `useOperationAccess` had three members with three different loading semantics, one documented as unsafe alone; it now exposes one withholding `resolveOperationGate` - the agent tool picker derived its option list twice with the empty-id filter on only one path; both callers now share one `{ options, denied }` result - `OPERATION_SUBBLOCK_ID` was a verbatim copy of the private constant in `canvas-sentence.ts`, doc comment included; that file now imports it * chore: trim restating comments and a dead coalesce Cleanup-pass findings on this branch's own lines: comments that restated the code they sat on, a four-line note whose sibling said the same in one, and a `?? undefined` on a non-nullable value. The comment on the tool-picker filter now explains the clause that actually needed it (an empty option list is not a denied one) instead of narrating the filter. Left alone as pre-existing and out of scope: the inline `staleTime` literal in `useAllowedIntegrationsFromEnv`, and the Operation selector's raw label / plain `Combobox` — both byte-identical to staging and matching the convention of every sibling field in that panel. * fix(access-control): seed nothing restricted when the config is unknown Block creation is one-shot, so the withholding pattern the editor's pickers use does not transfer: withholding the predicate there meant `prepareBlockState` seeded the declared `operation`/`model` defaults unchecked, and nothing revisits a field that already holds a value — so a block added before the permission config resolved kept a model the group may deny. Both restricted fields now seed empty until the config is known; the pickers fill them the moment it resolves. A preset operation is still honoured in that window: unlike a declared default it is the user's explicit pick, and the server gates the run. * fix(access-control): make the loading rule structural, not a convention Two review bots found the same class of bug in two more places, which is the real finding: "never persist from a predicate that reads as unrestricted while the config loads" was a rule each callsite re-implemented, and the rule had already been forgotten twice. Closes both reported instances and moves the rule somewhere it cannot be forgotten again: - `useOperationAccess.resolveSeedGate` now owns the creation-time veto for both restricted fields, so `workflow.tsx` states no policy of its own — it asks for a gate and passes it on. Previously the model half of the invariant was carried by an operation-shaped object that merely happened to be absent during the same window. - The agent tool picker and both operation selectors close while the config is unknown. Every list they offer — blocks, operations, MCP and custom tools — reads as unrestricted for that beat, and each pick is a one-shot write. - A preset operation goes through the same gate as a declared default. It comes from the search index, which is itself unfiltered while loading, so it is not the informed pick it looks like. - `isPermissionLoading` is exposed from one hook, so all four surfaces read the same symbol instead of four spellings of the same condition. Also from the review passes: dropped `isModelAllowed`/`isProviderAllowed` from the public interface (consolidating onto `isModelUsable` left them with no external consumer), un-exported `resolveOperationToolId` (no non-test caller), and corrected the `isSeededValueAllowed` TSDoc, which still described the contract the previous commit replaced. Tests: replaced a case that asserted its own fixture rather than the code with coverage of the two guard branches that were genuinely untested — an empty-string and a non-string declared default must bypass the gate, since both mean "nothing was declared" rather than a value to authorize. * fix(access-control): only gate a model field the provider allowlist is about The seed gate ran `isModelUsable` on every subblock named `model`, but `getProviderFromModel` resolves chat models and falls back to `ollama` for everything else. 28 of the 44 seeded model defaults in the registry are embedding, speech, image, video or search ids — so for any group with a provider allowlist that omits Ollama, those blocks were created with an empty model. Adds `findProviderFromModel`, the non-guessing half of `getProviderFromModel`, which returns `null` where the registry declares nothing. `isModelUsable` now treats an unresolved id as not-a-provider-choice and leaves it alone, matching the rule the operation gate already follows: never guess, and let the server stay authoritative. `getProviderFromModel` delegates to it, so there is one resolution path and its ollama fallback is unchanged. This also repairs the same misjudgement where it predates the branch: the model combobox filtered its options through the identical provider check, so those 28 defaults were already being hidden from their own pickers for allowlisted groups. The dead `try/catch` around the old call went with it — `getProviderFromModel` returns a fallback rather than throwing for an unknown id. --- .../components/combobox/combobox.tsx | 32 +---- .../components/dropdown/dropdown.tsx | 62 +++++---- .../components/tool-input/tool-input.tsx | 110 +++++++++++---- .../[workspaceId]/w/[workflowId]/workflow.tsx | 26 +++- .../w/components/sidebar/sidebar.tsx | 11 +- apps/sim/hooks/use-operation-access.ts | 99 +++++++++++++ apps/sim/hooks/use-permission-config.ts | 48 +++++-- .../operation-access.test.ts | 130 ++++++++++++++++++ .../lib/permission-groups/operation-access.ts | 127 +++++++++++++++++ .../lib/workflows/blocks/canvas-sentence.ts | 11 +- apps/sim/providers/utils.test.ts | 25 ++++ apps/sim/providers/utils.ts | 42 +++--- apps/sim/stores/modals/search/store.test.ts | 5 +- apps/sim/stores/modals/search/store.ts | 9 +- apps/sim/stores/modals/search/types.ts | 9 +- apps/sim/stores/workflows/utils.test.ts | 104 +++++++++++++- apps/sim/stores/workflows/utils.ts | 39 +++++- 17 files changed, 748 insertions(+), 141 deletions(-) create mode 100644 apps/sim/hooks/use-operation-access.ts create mode 100644 apps/sim/lib/permission-groups/operation-access.test.ts create mode 100644 apps/sim/lib/permission-groups/operation-access.ts 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 58704bfd47..bea443e2f7 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 1eb22d6b5a..996fbf7f3f 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 fe30b4dc65..55920dce20 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 fdfc5a2ef8..2d035136ae 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 696fa18ef4..e800bc9155 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 0000000000..1395e20658 --- /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 0f132d1dde..0b1cb30856 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 0000000000..457cd0815d --- /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 0000000000..6856cb0697 --- /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 f88af9654f..b8979245a5 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 6ca29d1457..9deedf19aa 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 1cb25c7da2..037dc75771 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -286,26 +286,34 @@ export function getAllModelProviders(): Record { ) } +/** + * 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() + + const declared = getAllModelProviders()[normalizedModel] + if (declared) return declared + + 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: ProviderId | null = null - - 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 - } - } + let providerId = findProviderFromModel(model) if (!providerId) { logger.warn(`No provider found for model: ${model}, defaulting to ollama`) diff --git a/apps/sim/stores/modals/search/store.test.ts b/apps/sim/stores/modals/search/store.test.ts index 1d96b0a466..032bc2875b 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 337583e967..8c60ec9d15 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 e7f8c7cee3..16a8d3b482 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 0d1850b069..622feb6766 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 9075df4d95..34a5cc1a54 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,