Skip to content

Commit 47fc03c

Browse files
committed
improvement(integrations): one access verdict for every integration listing, enforced by check:integration-access
1 parent 951bf66 commit 47fc03c

22 files changed

Lines changed: 788 additions & 278 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: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useMemo, useSyncExternalStore } from 'react'
22
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
33
import { subscribeDesktopPreferences } from '@/lib/desktop'
4-
import { isBlockTypePermittedByAllowlist } from '@/lib/permission-groups/block-access'
5-
import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integration-allowlist'
4+
import { isIntegrationUsable } from '@/lib/integrations/access'
65
import { isTerminalAvailable } from '@/lib/terminal/transport'
76
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
87
import {
@@ -12,14 +11,9 @@ import {
1211
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
1312
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
1413
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
15-
import {
16-
type IntegrationDescriptor,
17-
listIntegrationsByPopularity,
18-
} from '@/blocks/integration-matcher'
19-
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
14+
import { listIntegrationsByPopularity } from '@/blocks/integration-matcher'
2015
import { useWorkspaceDashboard } from '@/hooks/queries/dashboards'
2116
import { useFolders } from '@/hooks/queries/folders'
22-
import { useIntegrationAvailability } from '@/hooks/queries/integration-availability'
2317
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
2418
import { useLogsList } from '@/hooks/queries/logs'
2519
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
@@ -28,6 +22,7 @@ import { useWorkflowTests } from '@/hooks/queries/workflow-tests'
2822
import { useWorkflows } from '@/hooks/queries/workflows'
2923
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
3024
import { useWorkspaceFilePickerOptions } from '@/hooks/queries/workspace-files'
25+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
3126

3227
/**
3328
* Placeholder id for the Browser launcher row. It never names a resource: the
@@ -100,7 +95,6 @@ interface UseAvailableResourcesOptions {
10095

10196
/** Stable identity for the disabled result, so downstream memos never bust. */
10297
const NO_RESOURCE_GROUPS: AvailableItemsByType[] = []
103-
const NO_INTEGRATIONS: readonly IntegrationDescriptor[] = []
10498

10599
const LOG_DROPDOWN_LIMIT = 50
106100

@@ -187,28 +181,22 @@ export function useAvailableResources(
187181
enabled: enabled && Boolean(workspaceId),
188182
})
189183
const integrationsIncluded = !excludeTypes?.includes('integration')
190-
const { data: permissionConfig, isPending: permissionConfigPending } = useUserPermissionConfig(
191-
enabled && integrationsIncluded && workspaceId ? workspaceId : undefined
192-
)
193-
const { data: integrationAvailability, isPending: integrationAvailabilityPending } =
194-
useIntegrationAvailability()
184+
const integrationAccess = useIntegrationAccess(workspaceId, {
185+
enabled: enabled && integrationsIncluded,
186+
})
187+
const { getAccess: getIntegrationAccess } = integrationAccess
195188

196189
/**
197-
* Integrations this workspace's policy permits: the viewer's permission group
198-
* intersected with the deployment allowlist. Fails closed — nothing is offered
199-
* until both have loaded — because the server drops a mention of an
200-
* integration either policy disallows.
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.
201192
*/
202-
const permittedIntegrations = useMemo(() => {
203-
if (!permissionConfig || !integrationAvailability) return NO_INTEGRATIONS
204-
const allowlist = intersectAccessControlAllowlists(
205-
permissionConfig.config?.allowedIntegrations ?? null,
206-
integrationAvailability.allowedIntegrations
207-
)
208-
return listIntegrationsByPopularity().filter((integration) =>
209-
isBlockTypePermittedByAllowlist(allowlist, integration.blockType)
210-
)
211-
}, [permissionConfig, integrationAvailability])
193+
const usableIntegrations = useMemo(
194+
() =>
195+
listIntegrationsByPopularity().filter((integration) =>
196+
isIntegrationUsable(getIntegrationAccess(integration.blockType))
197+
),
198+
[getIntegrationAccess]
199+
)
212200
const { data: logsData, isPending: logsPending } = useLogsList(
213201
workspaceId,
214202
LOG_DROPDOWN_FILTERS,
@@ -240,7 +228,7 @@ export function useAvailableResources(
240228
fileFoldersPending ||
241229
tasksPending ||
242230
logsPending ||
243-
(integrationsIncluded && (permissionConfigPending || integrationAvailabilityPending)))
231+
(integrationsIncluded && integrationAccess.isPending))
244232

245233
const groups = useMemo(() => {
246234
if (!enabled) return NO_RESOURCE_GROUPS
@@ -311,7 +299,7 @@ export function useAvailableResources(
311299
},
312300
{
313301
type: 'integration' as const,
314-
items: permittedIntegrations.map((integration) => ({
302+
items: usableIntegrations.map((integration) => ({
315303
id: integration.blockType,
316304
name: integration.name,
317305
iconComponent: integration.icon,
@@ -387,7 +375,7 @@ export function useAvailableResources(
387375
knowledgeBases,
388376
tasks,
389377
logs,
390-
permittedIntegrations,
378+
usableIntegrations,
391379
excludeTypes,
392380
dashboardsEnabled,
393381
testsEnabled,

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ describe('organization mention inventory', () => {
4949
{ type: 'integration', items: [{ id: 'gmail_v2', name: 'gmail_v2' }] },
5050
])
5151
})
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+
})
5262
it('keeps mention confirmation pending until every accessible workspace settles', () => {
5363
expect(
5464
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: 6 additions & 1 deletion
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
@@ -52,7 +54,8 @@ export function mergeOrganizationResourceInventories(
5254
for (const group of inventory.groups) {
5355
if (group.type === 'browser' || group.type === 'terminal') continue
5456
// Integrations are not workspace-owned, but a permission group can target
55-
// individual workspaces, so one is offered only where every workspace permits it.
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.
5659
const integrations = group.type === 'integration' ? byType.get(group.type) : undefined
5760
if (integrations) {
5861
const permittedHere = new Set(group.items.map((item) => item.id))
@@ -70,5 +73,7 @@ export function mergeOrganizationResourceInventories(
7073
structureFolders.table.push(...inventory.structureFolders.table.map(owned))
7174
structureFolders.knowledgebase.push(...inventory.structureFolders.knowledgebase.map(owned))
7275
}
76+
const integrations = byType.get('integration')
77+
if (integrations && isAnyInventoryMissing) integrations.items = []
7378
return { groups, structureFolders, isHydrating }
7479
}

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

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
resolveCredentialDisplay,
1515
resolveOAuthServiceForIntegration,
1616
} from '@/lib/integrations'
17+
import { isIntegrationPermitted } from '@/lib/integrations/access'
1718
import { credentialProviderMatchesService } from '@/lib/oauth'
1819
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
1920
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
@@ -47,6 +48,7 @@ import {
4748
import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary'
4849
import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action'
4950
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
51+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
5052
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
5153
import { usePermissionConfig } from '@/hooks/use-permission-config'
5254

@@ -65,41 +67,33 @@ interface IntegrationBlockDetailProps {
6567
}
6668

6769
/**
68-
* Integration detail page, shown only when the viewer's integration policy
69-
* permits the integration. A direct link to a restricted one renders an
70-
* access-required state instead of the connect actions, and the check fails
71-
* closed until both the permission group and the deployment allowlist have
72-
* loaded.
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.
7375
*/
7476
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
75-
const {
76-
isBlockPermitted,
77-
isBlockRequestable,
78-
isPermissionReady,
79-
isIntegrationAvailabilityReady,
80-
integrationAvailabilityError,
81-
refetchIntegrationAvailability,
82-
} = usePermissionConfig()
77+
const { getAccess, availabilityError, refetchAvailability } = useIntegrationAccess(workspaceId)
8378
const accessRequests = useWorkspaceAccessRequestFeatures()
79+
const access = getAccess(integration.type)
8480

85-
if (integrationAvailabilityError) {
81+
if (availabilityError) {
8682
return (
8783
<IntegrationAccessNotice
8884
workspaceId={workspaceId}
8985
title='Unable to check access'
90-
description={integrationAvailabilityError.message}
91-
action={<Chip onClick={() => void refetchIntegrationAvailability()}>Try again</Chip>}
86+
description={availabilityError.message}
87+
action={<Chip onClick={() => void refetchAvailability()}>Try again</Chip>}
9288
/>
9389
)
9490
}
95-
if (!isPermissionReady || !isIntegrationAvailabilityReady) {
96-
return <IntegrationBlockDetailFallback workspaceId={workspaceId} />
97-
}
98-
if (isBlockPermitted(integration.type)) {
91+
if (access === undefined) return <IntegrationBlockDetailFallback workspaceId={workspaceId} />
92+
if (isIntegrationPermitted(access)) {
9993
return <IntegrationBlockDetailContent integration={integration} workspaceId={workspaceId} />
10094
}
10195

102-
const requestable = accessRequests.data?.enabled === true && isBlockRequestable(integration.type)
96+
const requestable = access === 'requestable' && accessRequests.data?.enabled === true
10397

10498
return (
10599
<IntegrationAccessNotice

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,15 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
8585

8686
interface IntegrationsShowcaseProps {
8787
/** Tiles whose block type fails this check are left out of the grid. */
88-
isBlockPermitted: (blockType: string) => boolean
88+
isTileVisible: (blockType: string) => boolean
8989
}
9090

9191
/**
9292
* Decorative integrations grid: a notched, masked grid background populated
9393
* with a curated set of brand tiles. The bottom-right notch leaves room for an
9494
* "Explore in chat" CTA rendered by `ShowcaseWithExplore`.
9595
*/
96-
export function IntegrationsShowcase({ isBlockPermitted }: IntegrationsShowcaseProps) {
96+
export function IntegrationsShowcase({ isTileVisible }: IntegrationsShowcaseProps) {
9797
return (
9898
<div aria-hidden className='relative h-[144px] w-full overflow-hidden rounded-xl'>
9999
<div
@@ -115,7 +115,7 @@ export function IntegrationsShowcase({ isBlockPermitted }: IntegrationsShowcaseP
115115
<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)]'>
116116
{SHOWCASE_TILES.map((tile) => {
117117
const block = getBlock(tile.id)
118-
if (!block || !isBlockPermitted(tile.id)) return null
118+
if (!block || !isTileVisible(tile.id)) return null
119119
return (
120120
<div
121121
key={tile.id}

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +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'
9-
import { usePermissionConfig } from '@/hooks/use-permission-config'
10+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
1011

1112
interface ShowcaseWithExploreProps {
1213
/**
@@ -27,15 +28,13 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
2728
const params = useParams()
2829
const router = useRouter()
2930
const { chatEnabled } = useDeploymentShape()
30-
const { isBlockPermitted, isIntegrationAvailabilityReady } = usePermissionConfig()
3131
const workspaceId = (params?.workspaceId as string) || ''
32+
const { getAccess } = useIntegrationAccess(workspaceId)
3233

3334
return (
3435
<div className='relative'>
3536
<IntegrationsShowcase
36-
isBlockPermitted={(blockType) =>
37-
isIntegrationAvailabilityReady && isBlockPermitted(blockType)
38-
}
37+
isTileVisible={(blockType) => isIntegrationUsable(getAccess(blockType))}
3938
/>
4039
{chatEnabled && (
4140
<Chip

0 commit comments

Comments
 (0)