Skip to content

Commit 7514ffc

Browse files
committed
improvement(access-control): wire tool and model permissions into copilot editing
Permission groups already supported a per-tool denylist (deniedTools) and model restrictions, but only the canvas honored them. The copilot edit path gated on block type alone, so Sim could build workflows using tools and models the user was not allowed to run — the executor refused them at run time instead. Enforce both at authoring time, and stop advertising what the viewer cannot use.
1 parent e3b3b48 commit 7514ffc

25 files changed

Lines changed: 1341 additions & 154 deletions

apps/docs/openapi-v2-workflows.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5742,6 +5742,7 @@
57425742
"block_not_found",
57435743
"invalid_block_type",
57445744
"block_not_allowed",
5745+
"model_not_allowed",
57455746
"block_locked",
57465747
"tool_not_allowed",
57475748
"invalid_edge_target",

apps/sim/ee/access-control/utils/permission-check.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/lib/core/config/env-flags'
1414
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
1515
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
16+
import { createToolAccessGate } from '@/lib/permission-groups/operation-access'
1617
import {
1718
DEFAULT_PERMISSION_GROUP_CONFIG,
1819
type PermissionGroupConfig,
@@ -745,7 +746,7 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis
745746
}
746747
}
747748

748-
if (toolId && config?.deniedTools?.includes(toolId)) {
749+
if (toolId && !createToolAccessGate(config?.deniedTools)(toolId)) {
749750
logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId })
750751
throw new ToolNotAllowedError(toolId)
751752
}

apps/sim/hooks/use-permission-config.ts

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
} from '@/lib/integrations/availability'
1717
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
1818
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
19+
import { createModelAccessGate } from '@/lib/permission-groups/model-access'
20+
import { createToolAccessGate } from '@/lib/permission-groups/operation-access'
1921
import {
2022
DEFAULT_PERMISSION_GROUP_CONFIG,
2123
type PermissionGroupConfig,
@@ -24,7 +26,6 @@ import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/p
2426
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
2527
import { overlayVisibility } from '@/blocks/visibility/context'
2628
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
27-
import { findProviderFromModel } from '@/providers/utils'
2829

2930
export interface PermissionConfigResult {
3031
config: PermissionGroupConfig
@@ -120,42 +121,19 @@ export function usePermissionConfig(): PermissionConfigResult {
120121
}
121122
}, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations])
122123

123-
const isProviderAllowed = useMemo(() => {
124-
return (providerId: string) => {
125-
if (config.allowedModelProviders === null) return true
126-
return config.allowedModelProviders.includes(providerId)
127-
}
128-
}, [config.allowedModelProviders])
129-
130-
/** Indexed so the per-model check stays O(1) over a long denylist. */
131-
const deniedModelSet = useMemo(
132-
() => new Set(config.deniedModels.map((denied) => denied.toLowerCase())),
133-
[config.deniedModels]
124+
const isModelUsable = useMemo(
125+
() =>
126+
createModelAccessGate({
127+
deniedModels: config.deniedModels,
128+
allowedModelProviders: config.allowedModelProviders,
129+
}),
130+
[config.deniedModels, config.allowedModelProviders]
134131
)
135132

136-
const isModelAllowed = useMemo(() => {
137-
return (model: string) => !deniedModelSet.has(model.toLowerCase())
138-
}, [deniedModelSet])
139-
140-
const isModelUsable = useMemo(() => {
141-
return (model: string) => {
142-
if (!isModelAllowed(model)) return false
143-
const providerId = findProviderFromModel(model)
144-
/* Only chat models resolve to a provider. A `model` field holding an
145-
embedding, speech, image or video id is not a provider choice, so the
146-
provider allowlist has nothing to say about it — judging it anyway
147-
would read every such id as Ollama and reject it. */
148-
if (!providerId) return true
149-
return isProviderAllowed(providerId)
150-
}
151-
}, [isModelAllowed, isProviderAllowed])
152-
153-
/** Indexed so the per-tool check stays O(1) over a long denylist. */
154-
const deniedToolSet = useMemo(() => new Set(config.deniedTools), [config.deniedTools])
155-
156-
const isToolAllowed = useMemo(() => {
157-
return (toolId: string) => !deniedToolSet.has(toolId)
158-
}, [deniedToolSet])
133+
const isToolAllowed = useMemo(
134+
() => createToolAccessGate(config.deniedTools),
135+
[config.deniedTools]
136+
)
159137

160138
const filterBlocks = useMemo(() => {
161139
return <T extends { type: string }>(blocks: T[]): T[] => {

apps/sim/lib/copilot/chat/payload.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,11 @@ vi.mock('@/lib/copilot/block-visibility', () => ({
7676
vi.mock('@/lib/copilot/integration-tools', () => ({
7777
filterExposedIntegrationTools: vi.fn(
7878
(
79-
tools: Array<{ blockType: string; service: string }>,
79+
tools: Array<{ toolId: string; blockType: string; service: string }>,
8080
_vis: unknown,
81-
isOwnerAllowed: (owner: { blockType: string; service: string }) => boolean
82-
) => tools.filter((tool) => isOwnerAllowed(tool))
81+
isOwnerAllowed: (owner: { blockType: string; service: string }) => boolean,
82+
isToolAllowed: (toolId: string) => boolean = () => true
83+
) => tools.filter((tool) => isToolAllowed(tool.toolId) && isOwnerAllowed(tool))
8384
),
8485
getExposedIntegrationTools: vi.fn(() => [
8586
{

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,17 @@ import { isPaid } from '@/lib/billing/plan-helpers'
88
import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility'
99
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
1010
import {
11-
filterExposedIntegrationTools,
12-
getExposedIntegrationTools,
13-
} from '@/lib/copilot/integration-tools'
11+
type IntegrationGateConfig,
12+
projectIntegrationToolsForViewer,
13+
} from '@/lib/copilot/integration-tool-projection'
1414
import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools'
1515
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
1616
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
1717
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
1818
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
1919
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
20-
import {
21-
getAllowedIntegrationsFromEnv,
22-
isDocSandboxEnabled,
23-
isHosted,
24-
} from '@/lib/core/config/env-flags'
25-
import {
26-
isIntegrationDeploymentAvailableForVisibility,
27-
isOAuthServiceDeploymentAvailable,
28-
} from '@/lib/integrations/availability.server'
29-
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
20+
import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags'
21+
import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server'
3022
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
3123
import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils'
3224
import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key'
@@ -197,18 +189,11 @@ async function buildIntegrationToolSchemasUncached(
197189
): Promise<ToolSchema[]> {
198190
const reqLogger = logger.withMetadata({ messageId })
199191
const integrationTools: ToolSchema[] = []
200-
let allowedIntegrations = getAllowedIntegrationsFromEnv()
192+
let permissionConfig: IntegrationGateConfig | null = null
201193
if (workspaceId) {
202194
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
203-
const permissionConfig = await getUserPermissionConfig(userId, workspaceId)
204-
allowedIntegrations = intersectIntegrationAllowlists(
205-
permissionConfig?.allowedIntegrations ?? null,
206-
allowedIntegrations
207-
)
195+
permissionConfig = await getUserPermissionConfig(userId, workspaceId)
208196
}
209-
const allowedIntegrationTypes = allowedIntegrations
210-
? new Set(allowedIntegrations.map((integration) => integration.toLowerCase()))
211-
: null
212197

213198
try {
214199
const { createUserToolSchema } = await import('@/tools/params')
@@ -224,14 +209,7 @@ async function buildIntegrationToolSchemasUncached(
224209
})
225210
}
226211

227-
const exposedTools = filterExposedIntegrationTools(
228-
getExposedIntegrationTools(),
229-
vis,
230-
(owner) =>
231-
isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) &&
232-
(allowedIntegrationTypes === null ||
233-
allowedIntegrationTypes.has(owner.blockType.toLowerCase()))
234-
)
212+
const { tools: exposedTools } = projectIntegrationToolsForViewer(vis, permissionConfig)
235213
for (const { toolId, config: toolConfig, service, operation } of exposedTools) {
236214
try {
237215
const userSchema = createUserToolSchema(toolConfig, {
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/blocks/registry-maps', () => ({
7+
BLOCK_REGISTRY: {
8+
slack: {
9+
type: 'slack',
10+
tools: {
11+
access: ['slack_message_v1', 'slack_canvas_v1'],
12+
config: {
13+
tool: ({ operation }: { operation?: string }) =>
14+
operation === 'canvas' ? 'slack_canvas_v1' : 'slack_message_v1',
15+
},
16+
},
17+
subBlocks: [
18+
{
19+
id: 'operation',
20+
type: 'dropdown',
21+
options: [
22+
{ label: 'Send Message', id: 'send' },
23+
{ label: 'Create Canvas', id: 'canvas' },
24+
],
25+
},
26+
],
27+
},
28+
gmail: {
29+
type: 'gmail',
30+
tools: { access: ['gmail_send_v1'] },
31+
subBlocks: [],
32+
},
33+
/**
34+
* Multi-tool block with no operation selector: its operation ids ARE its
35+
* tool ids, so there are no dropdown options to filter.
36+
*/
37+
sqs: {
38+
type: 'sqs',
39+
tools: { access: ['sqs_send_v1', 'sqs_receive_v1'] },
40+
subBlocks: [],
41+
},
42+
},
43+
}))
44+
45+
vi.mock('@/tools/registry', () => ({
46+
tools: {
47+
slack_message_v1: { name: 'Send Message' },
48+
slack_canvas_v1: { name: 'Create Canvas' },
49+
gmail_send_v1: { name: 'Send Email' },
50+
sqs_send_v1: { name: 'Send' },
51+
sqs_receive_v1: { name: 'Receive' },
52+
},
53+
}))
54+
55+
vi.mock('@/lib/core/config/env-flags', () => ({
56+
getAllowedIntegrationsFromEnv: () => null,
57+
}))
58+
59+
vi.mock('@/lib/integrations/availability.server', () => ({
60+
isIntegrationDeploymentAvailableForVisibility: () => true,
61+
}))
62+
63+
import {
64+
projectIntegrationToolsForViewer,
65+
resolveDeniedBlockOperations,
66+
} from '@/lib/copilot/integration-tool-projection'
67+
import { resetExposedIntegrationToolsCache } from '@/lib/copilot/integration-tools'
68+
69+
function toolIds(config: Parameters<typeof projectIntegrationToolsForViewer>[1]): string[] {
70+
return projectIntegrationToolsForViewer(null, config)
71+
.tools.map((tool) => tool.toolId)
72+
.sort()
73+
}
74+
75+
describe('projectIntegrationToolsForViewer', () => {
76+
beforeEach(() => {
77+
resetExposedIntegrationToolsCache()
78+
})
79+
80+
it('exposes everything to a viewer with no permission group', () => {
81+
expect(toolIds(null)).toEqual([
82+
'gmail_send_v1',
83+
'slack_canvas_v1',
84+
'slack_message_v1',
85+
'sqs_receive_v1',
86+
'sqs_send_v1',
87+
])
88+
})
89+
90+
it('withholds a tool the group denies while keeping its siblings', () => {
91+
expect(toolIds({ allowedIntegrations: null, deniedTools: ['slack_canvas_v1'] })).toEqual([
92+
'gmail_send_v1',
93+
'slack_message_v1',
94+
'sqs_receive_v1',
95+
'sqs_send_v1',
96+
])
97+
})
98+
99+
it('applies the block allowlist and the tool denylist together', () => {
100+
expect(toolIds({ allowedIntegrations: ['slack'], deniedTools: ['slack_canvas_v1'] })).toEqual([
101+
'slack_message_v1',
102+
])
103+
})
104+
105+
it('reports the allowed block types and the tool gate it applied', () => {
106+
const projection = projectIntegrationToolsForViewer(null, {
107+
allowedIntegrations: ['Slack'],
108+
deniedTools: ['slack_canvas_v1'],
109+
})
110+
111+
expect(projection.allowedBlockTypes).toEqual(new Set(['slack']))
112+
expect(projection.isToolAllowed('slack_canvas_v1')).toBe(false)
113+
expect(projection.isToolAllowed('slack_message_v1')).toBe(true)
114+
})
115+
116+
it('leaves the gate unrestricted when the group denies nothing', () => {
117+
const projection = projectIntegrationToolsForViewer(null, {
118+
allowedIntegrations: null,
119+
deniedTools: [],
120+
})
121+
122+
expect(projection.allowedBlockTypes).toBeNull()
123+
expect(projection.isToolAllowed('slack_canvas_v1')).toBe(true)
124+
})
125+
})
126+
127+
describe('resolveDeniedBlockOperations', () => {
128+
const allow = (denied: string[]) => (toolId: string) => !denied.includes(toolId)
129+
130+
it('does no work when the group denies nothing', () => {
131+
const resolved = resolveDeniedBlockOperations([], allow([]))
132+
133+
expect(resolved.needsProjection.size).toBe(0)
134+
expect(resolved.fullyDenied.size).toBe(0)
135+
})
136+
137+
it('reports the operation ids to withhold from a partly denied block', () => {
138+
const denied = ['slack_canvas_v1']
139+
const resolved = resolveDeniedBlockOperations(denied, allow(denied))
140+
141+
expect(resolved.needsProjection.get('slack')).toEqual(new Set(['canvas']))
142+
expect(resolved.fullyDenied.has('slack')).toBe(false)
143+
})
144+
145+
it('withholds a block whose every operation is denied', () => {
146+
const denied = ['slack_message_v1', 'slack_canvas_v1']
147+
const resolved = resolveDeniedBlockOperations(denied, allow(denied))
148+
149+
expect(resolved.fullyDenied.has('slack')).toBe(true)
150+
expect(resolved.needsProjection.has('slack')).toBe(false)
151+
})
152+
153+
it('withholds a single-tool block whose only tool is denied', () => {
154+
const denied = ['gmail_send_v1']
155+
const resolved = resolveDeniedBlockOperations(denied, allow(denied))
156+
157+
expect(resolved.fullyDenied.has('gmail')).toBe(true)
158+
})
159+
160+
it('reprojects a selector-less block so its tool list drops the denied id', () => {
161+
const denied = ['sqs_receive_v1']
162+
const resolved = resolveDeniedBlockOperations(denied, allow(denied))
163+
164+
expect(resolved.needsProjection.get('sqs')).toEqual(new Set())
165+
expect(resolved.fullyDenied.has('sqs')).toBe(false)
166+
})
167+
168+
it('ignores blocks that own no denied tool', () => {
169+
const denied = ['slack_canvas_v1']
170+
const resolved = resolveDeniedBlockOperations(denied, allow(denied))
171+
172+
expect(resolved.needsProjection.has('gmail')).toBe(false)
173+
expect(resolved.needsProjection.has('sqs')).toBe(false)
174+
})
175+
})

0 commit comments

Comments
 (0)