Skip to content

Commit 2698438

Browse files
authored
fix(integrations): one integration-access verdict across the catalog, @ menus and search, enforced by an audit (#9457)
* fix(integrations): hide integrations the permission group disallows * fix(integrations): fall back from emptied categories and fail closed on detail gate * fix(integrations): fail closed on the catalog until the deployment allowlist loads * feat(integrations): offer requestable integrations in the catalog and gate @ mentions by policy * improvement(integrations): one access verdict for every integration listing, enforced by check:integration-access * fix(integrations): require the access hook to be called and read namespace imports in check:integration-access * fix(integrations): treat an untraceable namespace read as reading every export in check:integration-access
1 parent a0f47b1 commit 2698438

21 files changed

Lines changed: 1075 additions & 187 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, star
127127
- **Tool IDs are `snake_case`** (`service_action`), registered in `tools/registry.ts`; blocks register in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`, alphabetically).
128128
- **Type coercions go in `tools.config.params`** (runs at execution, after variable resolution), never in `tools.config.tool` (runs at serialization, where `Number()` destroys dynamic `<Block.output>` references).
129129

130+
A surface that lists integrations to a viewer (a catalog, picker, or `@` menu) reads `useIntegrationAccess` (`hooks/use-integration-access.ts`): the one verdict across permission group, deployment allowlist, and deployment OAuth configuration, which fails closed until the viewer's policy loads. Never map over a raw catalog (`INTEGRATIONS`, `listIntegrations()`, `INTEGRATION_METADATA`) there; `check:integration-access` fails the build.
131+
130132
Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`.
131133

132134
## Tables

‎CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, star
127127
- **Tool IDs are `snake_case`** (`service_action`), registered in `tools/registry.ts`; blocks register in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`, alphabetically).
128128
- **Type coercions go in `tools.config.params`** (runs at execution, after variable resolution), never in `tools.config.tool` (runs at serialization, where `Number()` destroys dynamic `<Block.output>` references).
129129

130+
A surface that lists integrations to a viewer (a catalog, picker, or `@` menu) reads `useIntegrationAccess` (`hooks/use-integration-access.ts`): the one verdict across permission group, deployment allowlist, and deployment OAuth configuration, which fails closed until the viewer's policy loads. Never map over a raw catalog (`INTEGRATIONS`, `listIntegrations()`, `INTEGRATION_METADATA`) there; `check:integration-access` fails the build.
131+
130132
Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`.
131133

132134
## Tables

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useMemo } from 'react'
2+
import { isIntegrationUsable } from '@/lib/integrations/access'
23
import type { MentionMenuData } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store'
34
import { listIntegrations } from '@/blocks/integration-matcher'
45
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
@@ -7,6 +8,7 @@ import { useTablesList } from '@/hooks/queries/tables'
78
import { useWorkflows } from '@/hooks/queries/workflows'
89
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
910
import { useWorkspaceFilePickerOptions } from '@/hooks/queries/workspace-files'
11+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
1012
import { mentionIcon } from './mention-icon'
1113
import type { MentionItem } from './types'
1214

@@ -44,18 +46,21 @@ export function useMarkdownMentions(
4446
const knowledgeBases = useKnowledgeBasesQuery(ws, { enabled: active })
4547
const workflows = useWorkflows(ws)
4648
const skills = useSkills(wsStr)
49+
const { getAccess: getIntegrationAccess } = useIntegrationAccess(workspaceId, { enabled: active })
4750

48-
// The integration registry is static — materialize it once rather than on every resource refetch.
51+
// Rebuilt only when the access policy changes, never on a resource refetch.
4952
const integrationItems = useMemo<MentionItem[]>(() => {
5053
if (!active) return []
51-
return listIntegrations().map((integration) => ({
52-
kind: 'integration',
53-
id: integration.blockType,
54-
label: integration.name,
55-
group: 'Integrations',
56-
icon: mentionIcon('integration', integration.blockType),
57-
}))
58-
}, [active])
54+
return listIntegrations()
55+
.filter((integration) => isIntegrationUsable(getIntegrationAccess(integration.blockType)))
56+
.map((integration) => ({
57+
kind: 'integration',
58+
id: integration.blockType,
59+
label: integration.name,
60+
group: 'Integrations',
61+
icon: mentionIcon('integration', integration.blockType),
62+
}))
63+
}, [active, getIntegrationAccess])
5964

6065
const items = useMemo(() => {
6166
if (!active) return NO_MENTION_ITEMS

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useMemo, useSyncExternalStore } from 'react'
22
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
33
import { subscribeDesktopPreferences } from '@/lib/desktop'
4+
import { isIntegrationUsable } from '@/lib/integrations/access'
45
import { isTerminalAvailable } from '@/lib/terminal/transport'
56
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
67
import {
@@ -21,6 +22,7 @@ import { useWorkflowTests } from '@/hooks/queries/workflow-tests'
2122
import { useWorkflows } from '@/hooks/queries/workflows'
2223
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
2324
import { useWorkspaceFilePickerOptions } from '@/hooks/queries/workspace-files'
25+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
2426

2527
/**
2628
* Placeholder id for the Browser launcher row. It never names a resource: the
@@ -178,6 +180,23 @@ export function useAvailableResources(
178180
const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, {
179181
enabled: enabled && Boolean(workspaceId),
180182
})
183+
const integrationsIncluded = !excludeTypes?.includes('integration')
184+
const integrationAccess = useIntegrationAccess(workspaceId, {
185+
enabled: enabled && integrationsIncluded,
186+
})
187+
const { getAccess: getIntegrationAccess } = integrationAccess
188+
189+
/**
190+
* Only integrations the workspace can use now: the server drops a mention of
191+
* one its policy restricts or whose OAuth this deployment has not configured.
192+
*/
193+
const usableIntegrations = useMemo(
194+
() =>
195+
listIntegrationsByPopularity().filter((integration) =>
196+
isIntegrationUsable(getIntegrationAccess(integration.blockType))
197+
),
198+
[getIntegrationAccess]
199+
)
181200
const { data: logsData, isPending: logsPending } = useLogsList(
182201
workspaceId,
183202
LOG_DROPDOWN_FILTERS,
@@ -208,7 +227,8 @@ export function useAvailableResources(
208227
(!excludeTypes?.includes('knowledgebase') && knowledgeBaseFoldersPending))) ||
209228
fileFoldersPending ||
210229
tasksPending ||
211-
logsPending)
230+
logsPending ||
231+
(integrationsIncluded && integrationAccess.isPending))
212232

213233
const groups = useMemo(() => {
214234
if (!enabled) return NO_RESOURCE_GROUPS
@@ -279,7 +299,7 @@ export function useAvailableResources(
279299
},
280300
{
281301
type: 'integration' as const,
282-
items: listIntegrationsByPopularity().map((integration) => ({
302+
items: usableIntegrations.map((integration) => ({
283303
id: integration.blockType,
284304
name: integration.name,
285305
iconComponent: integration.icon,
@@ -355,6 +375,7 @@ export function useAvailableResources(
355375
knowledgeBases,
356376
tasks,
357377
logs,
378+
usableIntegrations,
358379
excludeTypes,
359380
dashboardsEnabled,
360381
testsEnabled,

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ describe('organization mention inventory', () => {
3333
expect(result.groups[1].items).toEqual([{ id: 'slack', name: 'Slack' }])
3434
expect(result.isHydrating).toBe(false)
3535
})
36+
it('offers an integration only when every workspace policy permits it', () => {
37+
const withIntegrations = (ids: string[]) => ({
38+
...inventory,
39+
groups: [{ type: 'integration' as const, items: ids.map((id) => ({ id, name: id })) }],
40+
})
41+
const result = mergeOrganizationResourceInventories(
42+
[
43+
{ id: 'sales', name: 'Sales' },
44+
{ id: 'finance', name: 'Finance' },
45+
],
46+
{ sales: withIntegrations(['slack', 'gmail_v2']), finance: withIntegrations(['gmail_v2']) }
47+
)
48+
expect(result.groups).toEqual([
49+
{ type: 'integration', items: [{ id: 'gmail_v2', name: 'gmail_v2' }] },
50+
])
51+
})
52+
it('offers no integration while a workspace policy has not arrived', () => {
53+
const result = mergeOrganizationResourceInventories(
54+
[
55+
{ id: 'sales', name: 'Sales' },
56+
{ id: 'finance', name: 'Finance' },
57+
],
58+
{ sales: inventory }
59+
)
60+
expect(result.groups.find((group) => group.type === 'integration')?.items ?? []).toEqual([])
61+
})
3662
it('keeps mention confirmation pending until every accessible workspace settles', () => {
3763
expect(
3864
mergeOrganizationResourceInventories([{ id: 'sales', name: 'Sales' }], {}).isHydrating

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ export function mergeOrganizationResourceInventories(
3737
const byType = new Map<string, AvailableResources['groups'][number]>()
3838
const structureFolders: AvailableResources['structureFolders'] = { table: [], knowledgebase: [] }
3939
let isHydrating = false
40+
let isAnyInventoryMissing = false
4041
for (const workspace of workspaces) {
4142
const inventory = inventories[workspace.id]
4243
if (!inventory) {
4344
isHydrating = true
45+
isAnyInventoryMissing = true
4446
continue
4547
}
4648
isHydrating ||= inventory.isHydrating
@@ -51,8 +53,15 @@ export function mergeOrganizationResourceInventories(
5153
})
5254
for (const group of inventory.groups) {
5355
if (group.type === 'browser' || group.type === 'terminal') continue
54-
// Integration definitions are global and do not belong to a workspace.
55-
if (group.type === 'integration' && byType.has(group.type)) continue
56+
// Integrations are not workspace-owned, but a permission group can target
57+
// individual workspaces, so one is offered only where every workspace permits it,
58+
// and a workspace whose inventory has not arrived permits nothing yet.
59+
const integrations = group.type === 'integration' ? byType.get(group.type) : undefined
60+
if (integrations) {
61+
const permittedHere = new Set(group.items.map((item) => item.id))
62+
integrations.items = integrations.items.filter((item) => permittedHere.has(item.id))
63+
continue
64+
}
5665
let target = byType.get(group.type)
5766
if (!target) {
5867
target = { type: group.type, items: [] }
@@ -64,5 +73,7 @@ export function mergeOrganizationResourceInventories(
6473
structureFolders.table.push(...inventory.structureFolders.table.map(owned))
6574
structureFolders.knowledgebase.push(...inventory.structureFolders.knowledgebase.map(owned))
6675
}
76+
const integrations = byType.get('integration')
77+
if (integrations && isAnyInventoryMissing) integrations.items = []
6778
return { groups, structureFolders, isHydrating }
6879
}

‎apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
'use client'
22

3-
import { useEffect, useMemo, useRef, useState } from 'react'
3+
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
44
import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
55
import { ArrowLeft, Plus } from '@sim/emcn/icons'
66
import { useRouter } from 'next/navigation'
77
import { useQueryState } from 'nuqs'
8+
import { EmptyState } from '@/components/empty-state/empty-state'
89
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
910
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
1011
import {
@@ -13,9 +14,11 @@ import {
1314
resolveCredentialDisplay,
1415
resolveOAuthServiceForIntegration,
1516
} from '@/lib/integrations'
17+
import { isIntegrationPermitted } from '@/lib/integrations/access'
1618
import { credentialProviderMatchesService } from '@/lib/oauth'
1719
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
1820
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
21+
import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback'
1922
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
2023
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
2124
import { ConnectPersonalTokenModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal'
@@ -42,7 +45,10 @@ import {
4245
getTemplatesForBlock,
4346
type ScopedBlockTemplate,
4447
} from '@/blocks/registry'
48+
import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary'
49+
import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action'
4550
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
51+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
4652
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
4753
import { usePermissionConfig } from '@/hooks/use-permission-config'
4854

@@ -60,7 +66,84 @@ interface IntegrationBlockDetailProps {
6066
workspaceId: string
6167
}
6268

69+
/**
70+
* Integration detail page, shown only when the viewer's access policy permits
71+
* the integration (an unconfigured one included, which the page presents as
72+
* unavailable). A direct link to a restricted one renders an access-required
73+
* state instead of the connect actions, and nothing renders until the policy
74+
* has loaded.
75+
*/
6376
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
77+
const { getAccess, availabilityError, refetchAvailability } = useIntegrationAccess(workspaceId)
78+
const accessRequests = useWorkspaceAccessRequestFeatures()
79+
const access = getAccess(integration.type)
80+
81+
if (availabilityError) {
82+
return (
83+
<IntegrationAccessNotice
84+
workspaceId={workspaceId}
85+
title='Unable to check access'
86+
description={availabilityError.message}
87+
action={<Chip onClick={() => void refetchAvailability()}>Try again</Chip>}
88+
/>
89+
)
90+
}
91+
if (access === undefined) return <IntegrationBlockDetailFallback workspaceId={workspaceId} />
92+
if (isIntegrationPermitted(access)) {
93+
return <IntegrationBlockDetailContent integration={integration} workspaceId={workspaceId} />
94+
}
95+
96+
const requestable = access === 'requestable' && accessRequests.data?.enabled === true
97+
98+
return (
99+
<IntegrationAccessNotice
100+
workspaceId={workspaceId}
101+
title='Access required'
102+
description={
103+
requestable
104+
? 'Ask an administrator for access.'
105+
: 'Your organization restricts this integration.'
106+
}
107+
action={
108+
requestable ? (
109+
<RequestAccessAction
110+
scope={{ kind: 'workspace', workspaceId }}
111+
target={{ kind: 'integration', id: integration.type }}
112+
label={integration.name}
113+
variant='primary'
114+
/>
115+
) : undefined
116+
}
117+
/>
118+
)
119+
}
120+
121+
interface IntegrationAccessNoticeProps {
122+
workspaceId: string
123+
title: string
124+
description: string
125+
action?: ReactNode
126+
}
127+
128+
function IntegrationAccessNotice({
129+
workspaceId,
130+
title,
131+
description,
132+
action,
133+
}: IntegrationAccessNoticeProps) {
134+
return (
135+
<div className='flex h-full flex-col bg-[var(--bg)]'>
136+
<div className={PAGE_HEADER_BAR}>
137+
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
138+
Integrations
139+
</ChipLink>
140+
</div>
141+
<EmptyState title={title} description={description} action={action} />
142+
</div>
143+
)
144+
}
145+
146+
function IntegrationBlockDetailContent({ integration, workspaceId }: IntegrationBlockDetailProps) {
64147
const scrollContainerRef = useRef<HTMLDivElement>(null)
65148
useOAuthReturnRouter()
66149
const router = useRouter()

‎apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,17 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
8383
)
8484
}
8585

86+
interface IntegrationsShowcaseProps {
87+
/** Tiles whose block type fails this check are left out of the grid. */
88+
isTileVisible: (blockType: string) => boolean
89+
}
90+
8691
/**
8792
* Decorative integrations grid: a notched, masked grid background populated
8893
* with a curated set of brand tiles. The bottom-right notch leaves room for an
8994
* "Explore in chat" CTA rendered by `ShowcaseWithExplore`.
9095
*/
91-
export function IntegrationsShowcase() {
96+
export function IntegrationsShowcase({ isTileVisible }: IntegrationsShowcaseProps) {
9297
return (
9398
<div aria-hidden className='relative h-[144px] w-full overflow-hidden rounded-xl'>
9499
<div
@@ -110,7 +115,7 @@ export function IntegrationsShowcase() {
110115
<div className='absolute inset-0 grid translate-x-[0.5px] translate-y-[0.5px] grid-cols-[repeat(auto-fill,48px)] grid-rows-[repeat(auto-fill,48px)]'>
111116
{SHOWCASE_TILES.map((tile) => {
112117
const block = getBlock(tile.id)
113-
if (!block) return null
118+
if (!block || !isTileVisible(tile.id)) return null
114119
return (
115120
<div
116121
key={tile.id}

‎apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { Chip } from '@sim/emcn'
44
import { ArrowRight } from '@sim/emcn/icons'
55
import { useParams, useRouter } from 'next/navigation'
66
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
7+
import { isIntegrationUsable } from '@/lib/integrations/access'
78
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
89
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
10+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
911

1012
interface ShowcaseWithExploreProps {
1113
/**
@@ -27,10 +29,13 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
2729
const router = useRouter()
2830
const { chatEnabled } = useDeploymentShape()
2931
const workspaceId = (params?.workspaceId as string) || ''
32+
const { getAccess } = useIntegrationAccess(workspaceId)
3033

3134
return (
3235
<div className='relative'>
33-
<IntegrationsShowcase />
36+
<IntegrationsShowcase
37+
isTileVisible={(blockType) => isIntegrationUsable(getAccess(blockType))}
38+
/>
3439
{chatEnabled && (
3540
<Chip
3641
active

0 commit comments

Comments
 (0)