From c4684c2bf8f5a3ee5623c418f3f9afe5bb340a59 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 16:40:10 -0700 Subject: [PATCH 1/6] fix(access-control): default every operation picker to one the group allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/dropdown/dropdown.tsx | 58 ++++---- .../components/tool-input/tool-input.tsx | 96 +++++++++---- .../[workspaceId]/w/[workflowId]/workflow.tsx | 38 ++++- .../w/components/sidebar/sidebar.tsx | 11 +- apps/sim/hooks/use-operation-access.ts | 74 ++++++++++ .../operation-access.test.ts | 131 ++++++++++++++++++ .../lib/permission-groups/operation-access.ts | 110 +++++++++++++++ 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.ts | 62 ++++++++- 11 files changed, 538 insertions(+), 65 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/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 1eb22d6b5a8..c7575507a36 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,7 @@ 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 { 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,12 +12,14 @@ 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. */ const MAX_VISIBLE_MULTI_SELECT_BADGES = 2 +const EMPTY_DENIED_OPERATIONS: ReadonlySet = new Set() + /** * Dropdown option type - can be a simple string or an object with label, id, and optional icon. * Options with `hidden: true` are excluded from the picker but still resolve for label display, @@ -97,7 +100,7 @@ export const Dropdown = memo(function Dropdown({ preserveLabelCase = false, }: DropdownProps) { const activeSearchTarget = useActiveSearchTarget() - const { isToolAllowed } = usePermissionConfig() + const { getDeniedOperations, resolveDefaultOperation } = useOperationAccess() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) as [ string | string[] | null | undefined, (value: string | string[]) => void, @@ -189,26 +192,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 EMPTY_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 +226,23 @@ 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 + const selectableIds = comboboxOptions.filter((opt) => !opt.hidden).map((opt) => opt.value) + + /** + * 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) { + return resolveDefaultOperation(blockConfig, selectableIds, defaultValue) } - return firstSelectable?.value - }, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect]) + if (defaultValue !== undefined) return defaultValue + + return selectableIds[0] + }, [defaultValue, comboboxOptions, multiSelect, subBlockId, blockConfig, resolveDefaultOperation]) useEffect(() => { if (multiSelect || defaultOptionValue === undefined) { 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..d08ae8e2687 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,7 @@ import { } from '@/lib/mcp/tool-validation' import type { McpToolSchema } from '@/lib/mcp/types' import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth' +import { 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 +66,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 +86,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 +357,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' && @@ -663,12 +663,35 @@ export const ToolInput = memo(function ToolInput({ const supportsToolControl = provider ? supportsToolUsageControl(provider) : false const { filterBlocks, config: permissionConfig } = usePermissionConfig() + const { getDeniedOperations } = useOperationAccess() + + /** + * A tool block's operations minus the ones the caller's permission group + * denies, so this surface never offers — or silently defaults to — an + * operation that would be rejected at execution. + */ + const getAllowedOperationOptions = useCallback( + (block: BlockConfig | undefined) => { + const options = getOperationOptions(block) + const denied = getDeniedOperations( + block, + options.map((option) => option.id) + ) + return denied.size === 0 ? options : options.filter((option) => !denied.has(option.id)) + }, + [getDeniedOperations] + ) const customBlockOverlayVersion = useCustomBlockOverlayVersion() const toolBlocks = useMemo(() => { const allToolBlocks = getAllBlocks().filter(isAgentToolBlock) - return filterBlocks(allToolBlocks) - }, [filterBlocks, customBlockOverlayVersion]) + /* A multi-operation block whose every operation is denied has nothing the + caller can run, so it leaves the picker alongside the blocks denied + outright by `filterBlocks`. */ + return filterBlocks(allToolBlocks).filter( + (block) => !hasMultipleOperations(block) || getAllowedOperationOptions(block).length > 0 + ) + }, [filterBlocks, customBlockOverlayVersion, getAllowedOperationOptions]) const hasBackfilledRef = useRef(false) useEffect(() => { @@ -744,7 +767,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,8 +806,8 @@ 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 hasOperations = hasMultipleOperations(toolBlock) + const operationOptions = hasOperations ? getAllowedOperationOptions(toolBlock) : [] const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined const toolId = getToolIdForOperation(toolBlock.type, defaultOperation, toolBlock) @@ -821,7 +844,14 @@ export const ToolInput = memo(function ToolInput({ setOpen(false) }, - [isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue] + [ + isPreview, + disabled, + isToolAlreadySelected, + selectedTools, + setStoreValue, + getAllowedOperationOptions, + ] ) const handleAddCustomTool = useCallback( @@ -1799,7 +1829,8 @@ export const ToolInput = memo(function ToolInput({ ) : [] - const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) + const hasOperations = + !isCustomTool && !isMcpTool && hasMultipleOperations(getBlock(tool.type)) const hasParams = useSubBlocks ? displaySubBlocks.length > 0 : displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0 @@ -2059,26 +2090,39 @@ export const ToolInput = memo(function ToolInput({
{/* Operation dropdown for tools with multiple operations */} {(() => { - const hasOperations = hasMultipleOperations(tool.type) - const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] + const block = getBlock(tool.type) + const operationOptions = hasMultipleOperations(block) + ? getOperationOptions(block).filter((option) => option.id !== '') + : [] + if (operationOptions.length === 0) return null + + /* Denied operations are hidden from the picker rather than + dropped, so a tool already saved on one keeps showing its + name; the unset fallback skips to the first allowed. */ + const denied = getDeniedOperations( + block, + operationOptions.map((option) => option.id) + ) - 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} />
- ) : 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..bc86d4473cd 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 { isOperationAllowed, isReady: isOperationAccessReady } = useOperationAccess() + const addBlock = useCallback( ( id: string, @@ -888,6 +893,14 @@ const WorkflowContent = React.memo( if (parentId) blockData.parentId = parentId if (extent) blockData.extent = extent + /** + * Withheld until the permission config has resolved, since it reads as + * "nothing denied" in flight and would let a denied default through. + */ + const operationGate = isOperationAccessReady + ? (operationId: string) => isOperationAllowed(getBlock(type), operationId) + : undefined + const block = prepareBlockState({ id, type, @@ -897,6 +910,7 @@ const WorkflowContent = React.memo( parentId, extent, triggerMode, + isOperationAllowed: operationGate, }) const subBlockValues: Record> = {} @@ -914,7 +928,21 @@ const WorkflowContent = React.memo( if (!subBlockValues[id]) { subBlockValues[id] = {} } - Object.assign(subBlockValues[id], presetSubBlockValues) + /* Search and the connection picker already drop denied operations, so + this only catches one arriving by another route — a recent pick that + outlived a permission change. Dropping the key rather than the whole + preset leaves the permission-corrected default from + `prepareBlockState` in place. */ + const presetOperation = presetSubBlockValues[OPERATION_SUBBLOCK_ID] + const presetOperationDenied = + operationGate && typeof presetOperation === 'string' && !operationGate(presetOperation) + + Object.assign( + subBlockValues[id], + presetOperationDenied + ? omit(presetSubBlockValues, [OPERATION_SUBBLOCK_ID]) + : presetSubBlockValues + ) } collaborativeBatchAddBlocks( @@ -926,7 +954,13 @@ const WorkflowContent = React.memo( ) usePanelEditorStore.getState().setCurrentBlockId(id) }, - [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection] + [ + collaborativeBatchAddBlocks, + setSelectedEdges, + setPendingSelection, + isOperationAllowed, + isOperationAccessReady, + ] ) 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..93d711fda3d --- /dev/null +++ b/apps/sim/hooks/use-operation-access.ts @@ -0,0 +1,74 @@ +'use client' + +import { useMemo } from 'react' +import { + collectDeniedOperationIds, + isOperationAllowed as isOperationAllowedFor, + type OperationGateBlock, + pickDefaultOperation, +} from '@/lib/permission-groups/operation-access' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +const EMPTY_DENIED: ReadonlySet = new Set() + +export interface OperationAccess { + /** + * Whether the caller's permission config has resolved. Filtering reads + * optimistically before it does — a denied option stays visible for a beat — + * but nothing may be *persisted* until it is true. + */ + isReady: boolean + /** + * Whether the caller may run `operationId` of `block`. Answers `true` for + * everything while the config loads, so a caller persisting on the answer + * must check `isReady` first — the two withholding members below already do. + */ + isOperationAllowed: (block: OperationGateBlock | null | undefined, operationId: string) => 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. + * + * `undefined` while the config is loading, because it resolves as "nothing + * denied" in flight and a default written then would outlive the correction — + * a seeding caller only ever writes a defined value, so gating on + * `!== undefined` is the whole guard. + */ + resolveDefaultOperation: ( + block: OperationGateBlock | null | undefined, + candidates: Iterable, + preferred?: string + ) => string | undefined +} + +/** + * 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, isLoading } = usePermissionConfig() + + return useMemo(() => { + const isReady = !isLoading + return { + isReady, + isOperationAllowed: (block, operationId) => + isOperationAllowedFor(block, operationId, isToolAllowed), + getDeniedOperations: (block, operationIds) => + isReady ? collectDeniedOperationIds(block, operationIds, isToolAllowed) : EMPTY_DENIED, + resolveDefaultOperation: (block, candidates, preferred) => + isReady ? pickDefaultOperation(block, candidates, isToolAllowed, preferred) : undefined, + } + }, [isToolAllowed, isLoading]) +} 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..e8a06d4c420 --- /dev/null +++ b/apps/sim/lib/permission-groups/operation-access.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + collectDeniedOperationIds, + isOperationAllowed, + type OperationGateBlock, + pickDefaultOperation, + resolveOperationToolId, +} 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('resolveOperationToolId', () => { + it('resolves through the block tool selector', () => { + expect(resolveOperationToolId(selectorBlock, 'canvas')).toBe('slack_canvas') + }) + + it('returns the only tool when the block has no selection to make', () => { + expect(resolveOperationToolId(singleToolBlock, 'anything')).toBe('dropcontact_enrich_contact') + }) + + it('treats an operation id as a tool id when the block has no selector', () => { + expect(resolveOperationToolId(bareBlock, 'sqs_receive')).toBe('sqs_receive') + }) + + it('returns null rather than guessing when the selector throws', () => { + expect(resolveOperationToolId(selectorBlock, 'not-an-operation')).toBeNull() + }) + + it('returns null for a block with no tools', () => { + expect(resolveOperationToolId({ tools: { access: [] } }, 'send')).toBeNull() + expect(resolveOperationToolId(null, 'send')).toBeNull() + expect(resolveOperationToolId(undefined, 'send')).toBeNull() + }) +}) + +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..ab0cf57ca20 --- /dev/null +++ b/apps/sim/lib/permission-groups/operation-access.ts @@ -0,0 +1,110 @@ +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' + +/** 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 + +/** + * The tool id a block operation maps to, or `null` when it cannot be resolved + * from the operation alone. + * + * 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. + */ +export 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 { + /* Falls through rather than guessing a tool for an operation the + selector could not resolve on its own. */ + } + } + + /* 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/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.ts b/apps/sim/stores/workflows/utils.ts index 9075df4d958..0b38b120ccc 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -1,7 +1,9 @@ import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' +import { OPERATION_SUBBLOCK_ID } 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' @@ -10,6 +12,7 @@ import { createDefaultInputFormatField } from '@/lib/workflows/input-format' import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getBlock } from '@/blocks' +import type { SubBlockConfig } from '@/blocks/types' import { escapeRegExp, normalizeName } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -101,6 +104,44 @@ export interface PrepareBlockStateOptions { parentId?: string extent?: 'parent' triggerMode?: boolean + /** + * Gate for the seeded operation. A block whose declared default operation the + * creator's permission group denies is seeded with the first operation they + * can run instead — nothing revisits a field that already holds a value, so + * an unpermitted default would otherwise survive until it failed at + * execution. Omitted where the acting user's config is unknown or does not + * apply, in which case the declared default is used unchanged. + */ + isOperationAllowed?: (operationId: string) => boolean +} + +/** + * The first operation the caller may run from an operation subblock's declared + * options, skipping the ones its block hides from the picker. + */ +function firstAllowedOperation( + subBlock: SubBlockConfig, + isOperationAllowed: (operationId: string) => boolean +): string | null { + let options: unknown + try { + options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options + } catch { + return null + } + if (!Array.isArray(options)) return null + + for (const option of options) { + if (typeof option === 'string') { + if (isOperationAllowed(option)) return option + continue + } + if (isRecordLike(option) && typeof option.id === 'string' && !option.hidden) { + if (isOperationAllowed(option.id)) return option.id + } + } + + return null } /** @@ -108,7 +149,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, + isOperationAllowed, + } = options const blockConfig = getBlock(type) @@ -153,6 +204,15 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState initialValue = [] } + if ( + isOperationAllowed && + subBlock.id === OPERATION_SUBBLOCK_ID && + typeof initialValue === 'string' && + !isOperationAllowed(initialValue) + ) { + initialValue = firstAllowedOperation(subBlock, isOperationAllowed) + } + subBlocks[subBlock.id] = { id: subBlock.id, type: subBlock.type, From 42e90e9ea5a9091b6eaebb0ea26664c2b153b9d7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 18:09:09 -0700 Subject: [PATCH 2/6] fix(access-control): gate the seeded model too, and collapse the seed rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/combobox/combobox.tsx | 32 ++------ .../components/dropdown/dropdown.tsx | 14 ++-- .../components/tool-input/tool-input.tsx | 75 +++++++++-------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 26 +++--- apps/sim/hooks/use-operation-access.ts | 46 +++++------ apps/sim/hooks/use-permission-config.ts | 46 ++++++++--- .../lib/permission-groups/operation-access.ts | 12 +++ .../lib/workflows/blocks/canvas-sentence.ts | 11 +-- apps/sim/stores/workflows/utils.test.ts | 80 ++++++++++++++++++- apps/sim/stores/workflows/utils.ts | 62 +++++--------- 10 files changed, 233 insertions(+), 171 deletions(-) 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 c7575507a36..7a51f98f7ec 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,7 +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 { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' +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' @@ -18,8 +21,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** Selected-value badges shown before folding the rest into a "+N" badge. */ const MAX_VISIBLE_MULTI_SELECT_BADGES = 2 -const EMPTY_DENIED_OPERATIONS: ReadonlySet = new Set() - /** * Dropdown option type - can be a simple string or an object with label, id, and optional icon. * Options with `hidden: true` are excluded from the picker but still resolve for label display, @@ -197,7 +198,7 @@ export const Dropdown = memo(function Dropdown({ * authoritative gate regardless. */ const deniedOperationIds = useMemo(() => { - if (subBlockId !== OPERATION_SUBBLOCK_ID) return EMPTY_DENIED_OPERATIONS + if (subBlockId !== OPERATION_SUBBLOCK_ID) return NO_DENIED_OPERATIONS return getDeniedOperations( blockConfig, allOptions.map((opt) => (typeof opt === 'string' ? opt : opt.id)) @@ -226,8 +227,6 @@ export const Dropdown = memo(function Dropdown({ const defaultOptionValue = useMemo(() => { if (multiSelect) return undefined - const selectableIds = comboboxOptions.filter((opt) => !opt.hidden).map((opt) => opt.value) - /** * The operation field defaults through the permission gate, which withholds * a value until the group config has loaded. Seeding the static first @@ -236,12 +235,13 @@ export const Dropdown = memo(function Dropdown({ * 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) } if (defaultValue !== undefined) return defaultValue - return selectableIds[0] + return comboboxOptions.find((opt) => !opt.hidden)?.value }, [defaultValue, comboboxOptions, multiSelect, subBlockId, blockConfig, resolveDefaultOperation]) useEffect(() => { 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 d08ae8e2687..eeebd498308 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,7 +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 { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' +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' @@ -666,18 +669,24 @@ export const ToolInput = memo(function ToolInput({ const { getDeniedOperations } = useOperationAccess() /** - * A tool block's operations minus the ones the caller's permission group - * denies, so this surface never offers — or silently defaults to — an - * operation that would be rejected at execution. + * 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 getAllowedOperationOptions = useCallback( + const getOperationChoices = useCallback( (block: BlockConfig | undefined) => { - const options = getOperationOptions(block) - const denied = getDeniedOperations( - block, - options.map((option) => option.id) - ) - return denied.size === 0 ? options : options.filter((option) => !denied.has(option.id)) + const options = getOperationOptions(block).filter((option) => option.id !== '') + return { + options, + denied: getDeniedOperations( + block, + options.map((option) => option.id) + ), + } }, [getDeniedOperations] ) @@ -688,10 +697,12 @@ export const ToolInput = memo(function ToolInput({ /* A multi-operation block whose every operation is denied has nothing the caller can run, so it leaves the picker alongside the blocks denied outright by `filterBlocks`. */ - return filterBlocks(allToolBlocks).filter( - (block) => !hasMultipleOperations(block) || getAllowedOperationOptions(block).length > 0 - ) - }, [filterBlocks, customBlockOverlayVersion, getAllowedOperationOptions]) + 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(() => { @@ -806,9 +817,10 @@ export const ToolInput = memo(function ToolInput({ (toolBlock: (typeof toolBlocks)[0]) => { if (isPreview || disabled) return - const hasOperations = hasMultipleOperations(toolBlock) - const operationOptions = hasOperations ? getAllowedOperationOptions(toolBlock) : [] - const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined + const { options, denied } = hasMultipleOperations(toolBlock) + ? getOperationChoices(toolBlock ?? undefined) + : { 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 @@ -844,14 +856,7 @@ export const ToolInput = memo(function ToolInput({ setOpen(false) }, - [ - isPreview, - disabled, - isToolAlreadySelected, - selectedTools, - setStoreValue, - getAllowedOperationOptions, - ] + [isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue, getOperationChoices] ) const handleAddCustomTool = useCallback( @@ -1830,7 +1835,7 @@ export const ToolInput = memo(function ToolInput({ : [] const hasOperations = - !isCustomTool && !isMcpTool && hasMultipleOperations(getBlock(tool.type)) + !isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined) const hasParams = useSubBlocks ? displaySubBlocks.length > 0 : displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0 @@ -2090,19 +2095,11 @@ export const ToolInput = memo(function ToolInput({
{/* Operation dropdown for tools with multiple operations */} {(() => { - const block = getBlock(tool.type) - const operationOptions = hasMultipleOperations(block) - ? getOperationOptions(block).filter((option) => option.id !== '') - : [] - if (operationOptions.length === 0) return null - - /* Denied operations are hidden from the picker rather than - dropped, so a tool already saved on one keeps showing its - name; the unset fallback skips to the first allowed. */ - const denied = getDeniedOperations( - block, - operationOptions.map((option) => option.id) + if (!hasOperations) return null + const { options: operationOptions, denied } = getOperationChoices( + toolBlock ?? undefined ) + if (operationOptions.length === 0) return null return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index bc86d4473cd..933070a54bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -41,7 +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 { MODEL_SUBBLOCK_ID, 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' @@ -129,6 +129,7 @@ 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 { usePermissionConfig } from '@/hooks/use-permission-config' import { useCanvasModeStore } from '@/stores/canvas-mode' import { useChatStore } from '@/stores/chat/store' import { @@ -870,7 +871,8 @@ const WorkflowContent = React.memo( */ const pendingFocusBlockIdRef = useRef(null) - const { isOperationAllowed, isReady: isOperationAccessReady } = useOperationAccess() + const { resolveOperationGate } = useOperationAccess() + const { isModelUsable } = usePermissionConfig() const addBlock = useCallback( ( @@ -894,11 +896,17 @@ const WorkflowContent = React.memo( if (extent) blockData.extent = extent /** - * Withheld until the permission config has resolved, since it reads as - * "nothing denied" in flight and would let a denied default through. + * `undefined` until the permission config has resolved, so a declared + * default is never vetoed — or let through — on a guess. Blocks pre-fill + * two fields the group can restrict; both go through the same gate. */ - const operationGate = isOperationAccessReady - ? (operationId: string) => isOperationAllowed(getBlock(type), operationId) + const operationGate = resolveOperationGate(getBlock(type)) + const seedGate = operationGate + ? (subBlockId: string, value: string) => { + if (subBlockId === OPERATION_SUBBLOCK_ID) return operationGate(value) + if (subBlockId === MODEL_SUBBLOCK_ID) return isModelUsable(value) + return true + } : undefined const block = prepareBlockState({ @@ -910,7 +918,7 @@ const WorkflowContent = React.memo( parentId, extent, triggerMode, - isOperationAllowed: operationGate, + isSeededValueAllowed: seedGate, }) const subBlockValues: Record> = {} @@ -958,8 +966,8 @@ const WorkflowContent = React.memo( collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection, - isOperationAllowed, - isOperationAccessReady, + resolveOperationGate, + isModelUsable, ] ) diff --git a/apps/sim/hooks/use-operation-access.ts b/apps/sim/hooks/use-operation-access.ts index 93d711fda3d..4a8dda8726d 100644 --- a/apps/sim/hooks/use-operation-access.ts +++ b/apps/sim/hooks/use-operation-access.ts @@ -3,27 +3,14 @@ import { useMemo } from 'react' import { collectDeniedOperationIds, - isOperationAllowed as isOperationAllowedFor, + isOperationAllowed, + NO_DENIED_OPERATIONS, type OperationGateBlock, pickDefaultOperation, } from '@/lib/permission-groups/operation-access' import { usePermissionConfig } from '@/hooks/use-permission-config' -const EMPTY_DENIED: ReadonlySet = new Set() - export interface OperationAccess { - /** - * Whether the caller's permission config has resolved. Filtering reads - * optimistically before it does — a denied option stays visible for a beat — - * but nothing may be *persisted* until it is true. - */ - isReady: boolean - /** - * Whether the caller may run `operationId` of `block`. Answers `true` for - * everything while the config loads, so a caller persisting on the answer - * must check `isReady` first — the two withholding members below already do. - */ - isOperationAllowed: (block: OperationGateBlock | null | undefined, operationId: string) => 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. @@ -35,17 +22,25 @@ export interface OperationAccess { /** * The operation to seed an unset field with: `preferred` when allowed, else * the first allowed candidate. - * - * `undefined` while the config is loading, because it resolves as "nothing - * denied" in flight and a default written then would outlive the correction — - * a seeding caller only ever writes a defined value, so gating on - * `!== undefined` is the whole guard. */ 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 } /** @@ -62,13 +57,16 @@ export function useOperationAccess(): OperationAccess { return useMemo(() => { const isReady = !isLoading return { - isReady, - isOperationAllowed: (block, operationId) => - isOperationAllowedFor(block, operationId, isToolAllowed), getDeniedOperations: (block, operationIds) => - isReady ? collectDeniedOperationIds(block, operationIds, isToolAllowed) : EMPTY_DENIED, + 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, } }, [isToolAllowed, isLoading]) } diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 0f132d1dde7..9c8c09489c1 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 { getProviderFromModel } from '@/providers/utils' export interface PermissionConfigResult { config: PermissionGroupConfig @@ -34,6 +35,12 @@ export interface PermissionConfigResult { 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 prefer this + * over calling `isModelAllowed` and `isProviderAllowed` separately. + */ + isModelUsable: (model: string) => boolean isToolAllowed: (toolId: string) => boolean isInvitationsDisabled: boolean isPublicApiDisabled: boolean @@ -120,20 +127,39 @@ 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 + try { + return isProviderAllowed(getProviderFromModel(model)) + } catch { + /* A model whose provider cannot be derived is left to the server gate + rather than hidden on a parse failure. */ + return true + } } - }, [config.deniedModels]) + }, [isModelAllowed, isProviderAllowed]) + + /** + * Indexed rather than scanned: the operation gate calls this once per option + * of every block it offers, so a linear scan made the cost of a check scale + * with the length of the 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[] => { @@ -173,6 +199,7 @@ export function usePermissionConfig(): PermissionConfigResult { isBlockAllowed, isProviderAllowed, isModelAllowed, + isModelUsable, isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, @@ -187,6 +214,7 @@ export function usePermissionConfig(): PermissionConfigResult { isBlockAllowed, isProviderAllowed, isModelAllowed, + isModelUsable, isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, diff --git a/apps/sim/lib/permission-groups/operation-access.ts b/apps/sim/lib/permission-groups/operation-access.ts index ab0cf57ca20..92447bc3e8f 100644 --- a/apps/sim/lib/permission-groups/operation-access.ts +++ b/apps/sim/lib/permission-groups/operation-access.ts @@ -10,6 +10,12 @@ import type { BlockConfig } from '@/blocks/types' */ export const OPERATION_SUBBLOCK_ID = 'operation' +/** The subblock id that carries a block's model. */ +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 @@ -20,6 +26,12 @@ export type IsToolAllowed = (toolId: 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 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/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 0d1850b0695..320f14c0469 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,78 @@ 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' }, + ], + } + + 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', + }) + }) + + it('seeds every declared default when the gate allows them', () => { + expect(seededValues(() => true)).toEqual({ + operation: 'send', + model: 'claude-sonnet-5', + channel: '#general', + }) + }) + + 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 0b38b120ccc..0ebb56c3706 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -1,9 +1,7 @@ import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' -import { OPERATION_SUBBLOCK_ID } 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' @@ -12,7 +10,6 @@ import { createDefaultInputFormatField } from '@/lib/workflows/input-format' import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getBlock } from '@/blocks' -import type { SubBlockConfig } from '@/blocks/types' import { escapeRegExp, normalizeName } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -105,43 +102,20 @@ export interface PrepareBlockStateOptions { extent?: 'parent' triggerMode?: boolean /** - * Gate for the seeded operation. A block whose declared default operation the - * creator's permission group denies is seeded with the first operation they - * can run instead — nothing revisits a field that already holds a value, so - * an unpermitted default would otherwise survive until it failed at - * execution. Omitted where the acting user's config is unknown or does not - * apply, in which case the declared default is used unchanged. + * 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. + * + * Omitted where the acting user's config is unknown or does not apply, in + * which case declared defaults are seeded unchanged. */ - isOperationAllowed?: (operationId: string) => boolean -} - -/** - * The first operation the caller may run from an operation subblock's declared - * options, skipping the ones its block hides from the picker. - */ -function firstAllowedOperation( - subBlock: SubBlockConfig, - isOperationAllowed: (operationId: string) => boolean -): string | null { - let options: unknown - try { - options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options - } catch { - return null - } - if (!Array.isArray(options)) return null - - for (const option of options) { - if (typeof option === 'string') { - if (isOperationAllowed(option)) return option - continue - } - if (isRecordLike(option) && typeof option.id === 'string' && !option.hidden) { - if (isOperationAllowed(option.id)) return option.id - } - } - - return null + isSeededValueAllowed?: (subBlockId: string, value: string) => boolean } /** @@ -158,7 +132,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState parentId, extent, triggerMode = false, - isOperationAllowed, + isSeededValueAllowed, } = options const blockConfig = getBlock(type) @@ -205,12 +179,12 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState } if ( - isOperationAllowed && - subBlock.id === OPERATION_SUBBLOCK_ID && + isSeededValueAllowed && typeof initialValue === 'string' && - !isOperationAllowed(initialValue) + initialValue !== '' && + !isSeededValueAllowed(subBlock.id, initialValue) ) { - initialValue = firstAllowedOperation(subBlock, isOperationAllowed) + initialValue = null } subBlocks[subBlock.id] = { From 729f30f4a66d79330f7fdfd42cac3ade7a076fe2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 18:14:41 -0700 Subject: [PATCH 3/6] chore: trim restating comments and a dead coalesce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sub-block/components/tool-input/tool-input.tsx | 7 +++---- .../workspace/[workspaceId]/w/[workflowId]/workflow.tsx | 5 ----- apps/sim/hooks/use-permission-config.ts | 6 +----- apps/sim/lib/permission-groups/operation-access.ts | 4 +--- 4 files changed, 5 insertions(+), 17 deletions(-) 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 eeebd498308..822116616a4 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 @@ -694,9 +694,8 @@ export const ToolInput = memo(function ToolInput({ const customBlockOverlayVersion = useCustomBlockOverlayVersion() const toolBlocks = useMemo(() => { const allToolBlocks = getAllBlocks().filter(isAgentToolBlock) - /* A multi-operation block whose every operation is denied has nothing the - caller can run, so it leaves the picker alongside the blocks denied - outright by `filterBlocks`. */ + /* 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) @@ -818,7 +817,7 @@ export const ToolInput = memo(function ToolInput({ if (isPreview || disabled) return const { options, denied } = hasMultipleOperations(toolBlock) - ? getOperationChoices(toolBlock ?? undefined) + ? getOperationChoices(toolBlock) : { options: [], denied: NO_DENIED_OPERATIONS } const defaultOperation = options.find((option) => !denied.has(option.id))?.id diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 933070a54bc..7d7cdb75ca1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -895,11 +895,6 @@ const WorkflowContent = React.memo( if (parentId) blockData.parentId = parentId if (extent) blockData.extent = extent - /** - * `undefined` until the permission config has resolved, so a declared - * default is never vetoed — or let through — on a guess. Blocks pre-fill - * two fields the group can restrict; both go through the same gate. - */ const operationGate = resolveOperationGate(getBlock(type)) const seedGate = operationGate ? (subBlockId: string, value: string) => { diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 9c8c09489c1..f6725c1de5d 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -150,11 +150,7 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [isModelAllowed, isProviderAllowed]) - /** - * Indexed rather than scanned: the operation gate calls this once per option - * of every block it offers, so a linear scan made the cost of a check scale - * with the length of the denylist. - */ + /** 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(() => { diff --git a/apps/sim/lib/permission-groups/operation-access.ts b/apps/sim/lib/permission-groups/operation-access.ts index 92447bc3e8f..3bcc378e176 100644 --- a/apps/sim/lib/permission-groups/operation-access.ts +++ b/apps/sim/lib/permission-groups/operation-access.ts @@ -10,7 +10,6 @@ import type { BlockConfig } from '@/blocks/types' */ export const OPERATION_SUBBLOCK_ID = 'operation' -/** The subblock id that carries a block's model. */ export const MODEL_SUBBLOCK_ID = 'model' /** Shared empty result, so a caller's memo sees a stable identity. */ @@ -56,8 +55,7 @@ export function resolveOperationToolId( const toolId = selectTool({ operation: operationId }) if (toolId) return toolId } catch { - /* Falls through rather than guessing a tool for an operation the - selector could not resolve on its own. */ + /* Unresolvable from the operation alone; see the TSDoc above. */ } } From 46f7423968c140834dd8e27b557260b2b6fbbc3e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 18:22:27 -0700 Subject: [PATCH 4/6] fix(access-control): seed nothing restricted when the config is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 25 ++++++++++++------- apps/sim/stores/workflows/utils.test.ts | 12 +++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 7d7cdb75ca1..5c3f8d47237 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -896,13 +896,19 @@ const WorkflowContent = React.memo( if (extent) blockData.extent = extent const operationGate = resolveOperationGate(getBlock(type)) - const seedGate = operationGate - ? (subBlockId: string, value: string) => { - if (subBlockId === OPERATION_SUBBLOCK_ID) return operationGate(value) - if (subBlockId === MODEL_SUBBLOCK_ID) return isModelUsable(value) - return true - } - : undefined + + /** + * Creation is one-shot, so an unknown permission config cannot be + * answered by waiting the way the editor's pickers do — a value written + * here is never revisited. The restricted fields therefore seed empty + * until the config resolves, and the pickers fill them the moment it + * does. Seeding the declared default instead would persist it unchecked. + */ + const seedGate = (subBlockId: string, value: string) => { + if (subBlockId !== OPERATION_SUBBLOCK_ID && subBlockId !== MODEL_SUBBLOCK_ID) return true + if (!operationGate) return false + return subBlockId === OPERATION_SUBBLOCK_ID ? operationGate(value) : isModelUsable(value) + } const block = prepareBlockState({ id, @@ -934,8 +940,9 @@ const WorkflowContent = React.memo( /* Search and the connection picker already drop denied operations, so this only catches one arriving by another route — a recent pick that outlived a permission change. Dropping the key rather than the whole - preset leaves the permission-corrected default from - `prepareBlockState` in place. */ + preset leaves whatever `prepareBlockState` seeded in place. Unlike a + declared default, a preset is the user's explicit pick, so an + unknown config honours it and leaves the server to gate the run. */ const presetOperation = presetSubBlockValues[OPERATION_SUBBLOCK_ID] const presetOperationDenied = operationGate && typeof presetOperation === 'string' && !operationGate(presetOperation) diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 320f14c0469..8dbd38cf6f7 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -1126,6 +1126,18 @@ describe('prepareBlockState — permission-group seed veto', () => { expect(values.operation).toBe('send') }) + it('seeds nothing restricted when the gate answers no for an unknown config', () => { + /* Creation is one-shot: the caller vetoes both restricted fields while the + permission config is loading, since a value written here is never + revisited. Unrestricted fields still seed. */ + const values = seededValues( + (subBlockId) => subBlockId !== 'operation' && subBlockId !== 'model' + ) + expect(values.operation).toBeNull() + expect(values.model).toBeNull() + expect(values.channel).toBe('#general') + }) + it('passes the seeded value to the gate, not just the field id', () => { const seen: Array<[string, string]> = [] seededValues((subBlockId, value) => { From 75face55ca3f54433d9f1cc2f38ad56f081b462e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 18:42:55 -0700 Subject: [PATCH 5/6] fix(access-control): make the loading rule structural, not a convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/dropdown/dropdown.tsx | 6 ++- .../components/tool-input/tool-input.tsx | 16 +++++-- .../[workspaceId]/w/[workflowId]/workflow.tsx | 42 +++++-------------- apps/sim/hooks/use-operation-access.ts | 31 +++++++++++++- apps/sim/hooks/use-permission-config.ts | 12 ++---- .../operation-access.test.ts | 27 ++++++------ .../lib/permission-groups/operation-access.ts | 9 +++- apps/sim/stores/workflows/utils.test.ts | 36 ++++++++++------ apps/sim/stores/workflows/utils.ts | 9 ++-- 9 files changed, 110 insertions(+), 78 deletions(-) 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 7a51f98f7ec..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 @@ -101,7 +101,7 @@ export const Dropdown = memo(function Dropdown({ preserveLabelCase = false, }: DropdownProps) { const activeSearchTarget = useActiveSearchTarget() - const { getDeniedOperations, resolveDefaultOperation } = useOperationAccess() + const { getDeniedOperations, resolveDefaultOperation, isPermissionLoading } = useOperationAccess() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) as [ string | string[] | null | undefined, (value: string | string[]) => void, @@ -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 822116616a4..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 @@ -665,7 +665,11 @@ 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() /** @@ -1704,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} @@ -2115,7 +2123,9 @@ export const ToolInput = memo(function ToolInput({ } 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} />
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 5c3f8d47237..2d035136aeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -41,7 +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 { MODEL_SUBBLOCK_ID, OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' +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' @@ -129,7 +129,6 @@ 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 { usePermissionConfig } from '@/hooks/use-permission-config' import { useCanvasModeStore } from '@/stores/canvas-mode' import { useChatStore } from '@/stores/chat/store' import { @@ -871,8 +870,7 @@ const WorkflowContent = React.memo( */ const pendingFocusBlockIdRef = useRef(null) - const { resolveOperationGate } = useOperationAccess() - const { isModelUsable } = usePermissionConfig() + const { resolveSeedGate } = useOperationAccess() const addBlock = useCallback( ( @@ -895,20 +893,7 @@ const WorkflowContent = React.memo( if (parentId) blockData.parentId = parentId if (extent) blockData.extent = extent - const operationGate = resolveOperationGate(getBlock(type)) - - /** - * Creation is one-shot, so an unknown permission config cannot be - * answered by waiting the way the editor's pickers do — a value written - * here is never revisited. The restricted fields therefore seed empty - * until the config resolves, and the pickers fill them the moment it - * does. Seeding the declared default instead would persist it unchecked. - */ - const seedGate = (subBlockId: string, value: string) => { - if (subBlockId !== OPERATION_SUBBLOCK_ID && subBlockId !== MODEL_SUBBLOCK_ID) return true - if (!operationGate) return false - return subBlockId === OPERATION_SUBBLOCK_ID ? operationGate(value) : isModelUsable(value) - } + const seedGate = resolveSeedGate(getBlock(type)) const block = prepareBlockState({ id, @@ -937,15 +922,14 @@ const WorkflowContent = React.memo( if (!subBlockValues[id]) { subBlockValues[id] = {} } - /* Search and the connection picker already drop denied operations, so - this only catches one arriving by another route — a recent pick that - outlived a permission change. Dropping the key rather than the whole - preset leaves whatever `prepareBlockState` seeded in place. Unlike a - declared default, a preset is the user's explicit pick, so an - unknown config honours it and leaves the server to gate the run. */ + /* 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 = - operationGate && typeof presetOperation === 'string' && !operationGate(presetOperation) + typeof presetOperation === 'string' && !seedGate(OPERATION_SUBBLOCK_ID, presetOperation) Object.assign( subBlockValues[id], @@ -964,13 +948,7 @@ const WorkflowContent = React.memo( ) usePanelEditorStore.getState().setCurrentBlockId(id) }, - [ - collaborativeBatchAddBlocks, - setSelectedEdges, - setPendingSelection, - resolveOperationGate, - isModelUsable, - ] + [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection, resolveSeedGate] ) const { activeBlockIds, pendingBlocks, isDebugging, isExecuting } = useExecutionStore( diff --git a/apps/sim/hooks/use-operation-access.ts b/apps/sim/hooks/use-operation-access.ts index 4a8dda8726d..1395e20658c 100644 --- a/apps/sim/hooks/use-operation-access.ts +++ b/apps/sim/hooks/use-operation-access.ts @@ -4,13 +4,22 @@ 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. @@ -41,6 +50,16 @@ export interface OperationAccess { 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 } /** @@ -52,11 +71,12 @@ export interface OperationAccess { * canvas search, block creation — agrees. */ export function useOperationAccess(): OperationAccess { - const { isToolAllowed, isLoading } = usePermissionConfig() + const { isToolAllowed, isModelUsable, isLoading } = usePermissionConfig() return useMemo(() => { const isReady = !isLoading return { + isPermissionLoading: isLoading, getDeniedOperations: (block, operationIds) => isReady ? collectDeniedOperationIds(block, operationIds, isToolAllowed) @@ -67,6 +87,13 @@ export function useOperationAccess(): OperationAccess { 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, isLoading]) + }, [isToolAllowed, isModelUsable, isLoading]) } diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index f6725c1de5d..c42812e8e63 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -33,12 +33,10 @@ 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 prefer this - * over calling `isModelAllowed` and `isProviderAllowed` separately. + * 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 @@ -193,8 +191,6 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isProviderAllowed, - isModelAllowed, isModelUsable, isToolAllowed, isInvitationsDisabled, @@ -208,8 +204,6 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isProviderAllowed, - isModelAllowed, isModelUsable, isToolAllowed, isInvitationsDisabled, diff --git a/apps/sim/lib/permission-groups/operation-access.test.ts b/apps/sim/lib/permission-groups/operation-access.test.ts index e8a06d4c420..457cd0815dc 100644 --- a/apps/sim/lib/permission-groups/operation-access.test.ts +++ b/apps/sim/lib/permission-groups/operation-access.test.ts @@ -7,7 +7,6 @@ import { isOperationAllowed, type OperationGateBlock, pickDefaultOperation, - resolveOperationToolId, } from '@/lib/permission-groups/operation-access' /** A block that resolves its tool from the operation, like most integrations. */ @@ -45,27 +44,27 @@ const deny = (...toolIds: string[]) => { return (toolId: string) => !denied.has(toolId) } -describe('resolveOperationToolId', () => { +describe('operation-to-tool resolution', () => { it('resolves through the block tool selector', () => { - expect(resolveOperationToolId(selectorBlock, 'canvas')).toBe('slack_canvas') + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_canvas'))).toBe(false) + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_message'))).toBe(true) }) - it('returns the only tool when the block has no selection to make', () => { - expect(resolveOperationToolId(singleToolBlock, 'anything')).toBe('dropcontact_enrich_contact') + 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(resolveOperationToolId(bareBlock, 'sqs_receive')).toBe('sqs_receive') - }) - - it('returns null rather than guessing when the selector throws', () => { - expect(resolveOperationToolId(selectorBlock, 'not-an-operation')).toBeNull() + expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_receive'))).toBe(false) + expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_send'))).toBe(true) }) - it('returns null for a block with no tools', () => { - expect(resolveOperationToolId({ tools: { access: [] } }, 'send')).toBeNull() - expect(resolveOperationToolId(null, 'send')).toBeNull() - expect(resolveOperationToolId(undefined, 'send')).toBeNull() + 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) }) }) diff --git a/apps/sim/lib/permission-groups/operation-access.ts b/apps/sim/lib/permission-groups/operation-access.ts index 3bcc378e176..6856cb06976 100644 --- a/apps/sim/lib/permission-groups/operation-access.ts +++ b/apps/sim/lib/permission-groups/operation-access.ts @@ -21,6 +21,13 @@ 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. @@ -38,7 +45,7 @@ export type IsToolAllowed = (toolId: string) => boolean * way, so a `null` only ever costs a denied option staying visible, never a * permitted option disappearing. */ -export function resolveOperationToolId( +function resolveOperationToolId( block: OperationGateBlock | null | undefined, operationId: string ): string | null { diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 8dbd38cf6f7..622feb67662 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -1076,6 +1076,8 @@ describe('prepareBlockState — permission-group seed veto', () => { { 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: [] }, ], } @@ -1102,6 +1104,8 @@ describe('prepareBlockState — permission-group seed veto', () => { operation: 'send', model: 'claude-sonnet-5', channel: '#general', + blank: '', + headers: [], }) }) @@ -1110,9 +1114,29 @@ describe('prepareBlockState — permission-group seed veto', () => { 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() @@ -1126,18 +1150,6 @@ describe('prepareBlockState — permission-group seed veto', () => { expect(values.operation).toBe('send') }) - it('seeds nothing restricted when the gate answers no for an unknown config', () => { - /* Creation is one-shot: the caller vetoes both restricted fields while the - permission config is loading, since a value written here is never - revisited. Unrestricted fields still seed. */ - const values = seededValues( - (subBlockId) => subBlockId !== 'operation' && subBlockId !== 'model' - ) - expect(values.operation).toBeNull() - expect(values.model).toBeNull() - expect(values.channel).toBe('#general') - }) - it('passes the seeded value to the gate, not just the field id', () => { const seen: Array<[string, string]> = [] seededValues((subBlockId, value) => { diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 0ebb56c3706..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' @@ -112,10 +113,12 @@ export interface PrepareBlockStateOptions { * that knows how to make it. Substituting here instead would also drift from * `getDefaultBlockName`, which names the block after its *declared* default. * - * Omitted where the acting user's config is unknown or does not apply, in - * which case declared defaults are seeded unchanged. + * 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?: (subBlockId: string, value: string) => boolean + isSeededValueAllowed?: SeedValueGate } /** From 32b8c8931b4a906958a3bb5c7b9bd48cfbb35b4b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 19:07:14 -0700 Subject: [PATCH 6/6] fix(access-control): only gate a model field the provider allowlist is about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/hooks/use-permission-config.ts | 16 +++++------ apps/sim/providers/utils.test.ts | 25 ++++++++++++++++ apps/sim/providers/utils.ts | 38 +++++++++++++++---------- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index c42812e8e63..0b1cb308568 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -24,7 +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 { getProviderFromModel } from '@/providers/utils' +import { findProviderFromModel } from '@/providers/utils' export interface PermissionConfigResult { config: PermissionGroupConfig @@ -138,13 +138,13 @@ export function usePermissionConfig(): PermissionConfigResult { const isModelUsable = useMemo(() => { return (model: string) => { if (!isModelAllowed(model)) return false - try { - return isProviderAllowed(getProviderFromModel(model)) - } catch { - /* A model whose provider cannot be derived is left to the server gate - rather than hidden on a parse failure. */ - return true - } + 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) } }, [isModelAllowed, isProviderAllowed]) 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'