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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -5742,6 +5742,7 @@
"block_not_found",
"invalid_block_type",
"block_not_allowed",
"model_not_allowed",
"block_locked",
"tool_not_allowed",
"invalid_edge_target",
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/ee/access-control/utils/permission-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@/lib/core/config/env-flags'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { createToolAccessGate } from '@/lib/permission-groups/operation-access'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
Expand Down Expand Up @@ -745,7 +746,7 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis
}
}

if (toolId && config?.deniedTools?.includes(toolId)) {
if (toolId && !createToolAccessGate(config?.deniedTools)(toolId)) {
logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId })
throw new ToolNotAllowedError(toolId)
}
Expand Down
48 changes: 13 additions & 35 deletions apps/sim/hooks/use-permission-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
} from '@/lib/integrations/availability'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { createModelAccessGate } from '@/lib/permission-groups/model-access'
import { createToolAccessGate } from '@/lib/permission-groups/operation-access'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
Expand All @@ -24,7 +26,6 @@ import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/p
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { overlayVisibility } from '@/blocks/visibility/context'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { findProviderFromModel } from '@/providers/utils'

export interface PermissionConfigResult {
config: PermissionGroupConfig
Expand Down Expand Up @@ -120,42 +121,19 @@ export function usePermissionConfig(): PermissionConfigResult {
}
}, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations])

const isProviderAllowed = useMemo(() => {
return (providerId: string) => {
if (config.allowedModelProviders === null) return true
return config.allowedModelProviders.includes(providerId)
}
}, [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 isModelUsable = useMemo(
() =>
createModelAccessGate({
deniedModels: config.deniedModels,
allowedModelProviders: config.allowedModelProviders,
}),
[config.deniedModels, config.allowedModelProviders]
)

const isModelAllowed = useMemo(() => {
return (model: string) => !deniedModelSet.has(model.toLowerCase())
}, [deniedModelSet])

const isModelUsable = useMemo(() => {
return (model: string) => {
if (!isModelAllowed(model)) return false
const providerId = findProviderFromModel(model)
/* Only chat models resolve to a provider. A `model` field holding an
embedding, speech, image or video id is not a provider choice, so the
provider allowlist has nothing to say about it — judging it anyway
would read every such id as Ollama and reject it. */
if (!providerId) return true
return isProviderAllowed(providerId)
}
}, [isModelAllowed, isProviderAllowed])

/** Indexed so the per-tool check stays O(1) over a long denylist. */
const deniedToolSet = useMemo(() => new Set(config.deniedTools), [config.deniedTools])

const isToolAllowed = useMemo(() => {
return (toolId: string) => !deniedToolSet.has(toolId)
}, [deniedToolSet])
const isToolAllowed = useMemo(
() => createToolAccessGate(config.deniedTools),
[config.deniedTools]
)

const filterBlocks = useMemo(() => {
return <T extends { type: string }>(blocks: T[]): T[] => {
Expand Down
35 changes: 32 additions & 3 deletions apps/sim/lib/copilot/chat/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,11 @@ vi.mock('@/lib/copilot/block-visibility', () => ({
vi.mock('@/lib/copilot/integration-tools', () => ({
filterExposedIntegrationTools: vi.fn(
(
tools: Array<{ blockType: string; service: string }>,
tools: Array<{ toolId: string; blockType: string; service: string }>,
_vis: unknown,
isOwnerAllowed: (owner: { blockType: string; service: string }) => boolean
) => tools.filter((tool) => isOwnerAllowed(tool))
isOwnerAllowed: (owner: { blockType: string; service: string }) => boolean,
isToolAllowed: (toolId: string) => boolean = () => true
) => tools.filter((tool) => isToolAllowed(tool.toolId) && isOwnerAllowed(tool))
),
getExposedIntegrationTools: vi.fn(() => [
{
Expand Down Expand Up @@ -298,6 +299,34 @@ describe('buildIntegrationToolSchemas', () => {
expect(second[0].input_schema).not.toHaveProperty('mutated')
expect(second[0].outputs).not.toHaveProperty('mutated')
})

it('rebuilds instead of serving a cache entry from the previous policy', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null, deniedTools: [] })

const before = await buildIntegrationToolSchemas(
'user-policy',
undefined,
{ schemaSurface: 'copilot' },
'workspace-policy'
)
expect(before.map((tool) => tool.name)).toContain('gmail_send')

// An admin denies the tool. The viewer and surface are unchanged, so only
// the policy component of the key can force a rebuild.
mockGetUserPermissionConfig.mockResolvedValue({
allowedIntegrations: null,
deniedTools: ['gmail_send'],
})

const after = await buildIntegrationToolSchemas(
'user-policy',
undefined,
{ schemaSurface: 'copilot' },
'workspace-policy'
)
expect(after.map((tool) => tool.name)).not.toContain('gmail_send')
})
})

describe('buildCopilotRequestPayload', () => {
Expand Down
64 changes: 26 additions & 38 deletions apps/sim/lib/copilot/chat/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,18 @@ import { isPaid } from '@/lib/billing/plan-helpers'
import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility'
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
import {
filterExposedIntegrationTools,
getExposedIntegrationTools,
} from '@/lib/copilot/integration-tools'
type IntegrationGateConfig,
integrationGateSignature,
projectIntegrationToolsForViewer,
} from '@/lib/copilot/integration-tool-projection'
import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools'
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
import {
getAllowedIntegrationsFromEnv,
isDocSandboxEnabled,
isHosted,
} from '@/lib/core/config/env-flags'
import {
isIntegrationDeploymentAvailableForVisibility,
isOAuthServiceDeploymentAvailable,
} from '@/lib/integrations/availability.server'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags'
import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server'
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils'
import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key'
Expand Down Expand Up @@ -118,11 +111,14 @@ function getIntegrationToolSchemaCacheKey(
userId: string,
workspaceId: string | undefined,
schemaSurface: string,
visSignature: string
visSignature: string,
gateSignature: string
): string {
// The visibility signature keys the entry to the viewer's gated projection —
// two users in one workspace with different preview reveals must not share.
return JSON.stringify([userId, workspaceId ?? null, schemaSurface, visSignature])
// The gate signature does the same for permission-group policy, so an admin's
// change takes effect on the next build rather than when the entry expires.
return JSON.stringify([userId, workspaceId ?? null, schemaSurface, visSignature, gateSignature])
}

function cloneToolSchemas(toolSchemas: ToolSchema[]): ToolSchema[] {
Expand Down Expand Up @@ -159,11 +155,20 @@ export async function buildIntegrationToolSchemas(
): Promise<ToolSchema[]> {
const schemaSurface = options.schemaSurface ?? 'copilot'
const vis = await getBlockVisibilityForCopilot(userId, workspaceId)
// Resolved before the key, not inside the cached build, so the entry is keyed
// to the policy it was produced under. The read this adds is cheap next to
// what the entry caches: a user-tool schema per exposed integration tool.
let permissionConfig: IntegrationGateConfig | null = null
if (workspaceId) {
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
permissionConfig = await getUserPermissionConfig(userId, workspaceId)
}
const cacheKey = getIntegrationToolSchemaCacheKey(
userId,
workspaceId,
schemaSurface,
visibilitySignature(vis)
visibilitySignature(vis),
integrationGateSignature(permissionConfig)
)
const cached = integrationToolSchemaCache.get(cacheKey)
if (cached) {
Expand All @@ -175,7 +180,8 @@ export async function buildIntegrationToolSchemas(
messageId,
{ schemaSurface },
workspaceId,
vis
vis,
permissionConfig
).catch((error) => {
integrationToolSchemaCache.delete(cacheKey)
throw error
Expand All @@ -193,22 +199,11 @@ async function buildIntegrationToolSchemasUncached(
messageId: string | undefined,
options: Required<BuildIntegrationToolSchemasOptions>,
workspaceId?: string,
vis: BlockVisibilityState | null = null
vis: BlockVisibilityState | null = null,
permissionConfig: IntegrationGateConfig | null = null
): Promise<ToolSchema[]> {
const reqLogger = logger.withMetadata({ messageId })
const integrationTools: ToolSchema[] = []
let allowedIntegrations = getAllowedIntegrationsFromEnv()
if (workspaceId) {
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
const permissionConfig = await getUserPermissionConfig(userId, workspaceId)
allowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
allowedIntegrations
)
}
const allowedIntegrationTypes = allowedIntegrations
? new Set(allowedIntegrations.map((integration) => integration.toLowerCase()))
: null

try {
const { createUserToolSchema } = await import('@/tools/params')
Expand All @@ -224,14 +219,7 @@ async function buildIntegrationToolSchemasUncached(
})
}

const exposedTools = filterExposedIntegrationTools(
getExposedIntegrationTools(),
vis,
(owner) =>
isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) &&
(allowedIntegrationTypes === null ||
allowedIntegrationTypes.has(owner.blockType.toLowerCase()))
)
const { tools: exposedTools } = projectIntegrationToolsForViewer(vis, permissionConfig)
for (const { toolId, config: toolConfig, service, operation } of exposedTools) {
try {
const userSchema = createUserToolSchema(toolConfig, {
Expand Down
Loading
Loading