diff --git a/AGENTS.md b/AGENTS.md index acb8bc4cca7..f866dc555d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,8 @@ Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, star - **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). - **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 `` references). +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. + Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`. ## Tables diff --git a/CLAUDE.md b/CLAUDE.md index de99d148379..981af4c0214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,8 @@ Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, star - **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). - **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 `` references). +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. + Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`. ## Tables diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index 1542e8672b8..1d7098f1a62 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { isIntegrationUsable } from '@/lib/integrations/access' import type { MentionMenuData } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store' import { listIntegrations } from '@/blocks/integration-matcher' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' @@ -7,6 +8,7 @@ import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFilePickerOptions } from '@/hooks/queries/workspace-files' +import { useIntegrationAccess } from '@/hooks/use-integration-access' import { mentionIcon } from './mention-icon' import type { MentionItem } from './types' @@ -44,18 +46,21 @@ export function useMarkdownMentions( const knowledgeBases = useKnowledgeBasesQuery(ws, { enabled: active }) const workflows = useWorkflows(ws) const skills = useSkills(wsStr) + const { getAccess: getIntegrationAccess } = useIntegrationAccess(workspaceId, { enabled: active }) - // The integration registry is static — materialize it once rather than on every resource refetch. + // Rebuilt only when the access policy changes, never on a resource refetch. const integrationItems = useMemo(() => { if (!active) return [] - return listIntegrations().map((integration) => ({ - kind: 'integration', - id: integration.blockType, - label: integration.name, - group: 'Integrations', - icon: mentionIcon('integration', integration.blockType), - })) - }, [active]) + return listIntegrations() + .filter((integration) => isIntegrationUsable(getIntegrationAccess(integration.blockType))) + .map((integration) => ({ + kind: 'integration', + id: integration.blockType, + label: integration.name, + group: 'Integrations', + icon: mentionIcon('integration', integration.blockType), + })) + }, [active, getIntegrationAccess]) const items = useMemo(() => { if (!active) return NO_MENTION_ITEMS diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts index 194939e45f9..b3270da2944 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts @@ -1,6 +1,7 @@ import { useMemo, useSyncExternalStore } from 'react' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { subscribeDesktopPreferences } from '@/lib/desktop' +import { isIntegrationUsable } from '@/lib/integrations/access' import { isTerminalAvailable } from '@/lib/terminal/transport' import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' import { @@ -21,6 +22,7 @@ import { useWorkflowTests } from '@/hooks/queries/workflow-tests' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFilePickerOptions } from '@/hooks/queries/workspace-files' +import { useIntegrationAccess } from '@/hooks/use-integration-access' /** * Placeholder id for the Browser launcher row. It never names a resource: the @@ -178,6 +180,23 @@ export function useAvailableResources( const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, { enabled: enabled && Boolean(workspaceId), }) + const integrationsIncluded = !excludeTypes?.includes('integration') + const integrationAccess = useIntegrationAccess(workspaceId, { + enabled: enabled && integrationsIncluded, + }) + const { getAccess: getIntegrationAccess } = integrationAccess + + /** + * Only integrations the workspace can use now: the server drops a mention of + * one its policy restricts or whose OAuth this deployment has not configured. + */ + const usableIntegrations = useMemo( + () => + listIntegrationsByPopularity().filter((integration) => + isIntegrationUsable(getIntegrationAccess(integration.blockType)) + ), + [getIntegrationAccess] + ) const { data: logsData, isPending: logsPending } = useLogsList( workspaceId, LOG_DROPDOWN_FILTERS, @@ -208,7 +227,8 @@ export function useAvailableResources( (!excludeTypes?.includes('knowledgebase') && knowledgeBaseFoldersPending))) || fileFoldersPending || tasksPending || - logsPending) + logsPending || + (integrationsIncluded && integrationAccess.isPending)) const groups = useMemo(() => { if (!enabled) return NO_RESOURCE_GROUPS @@ -279,7 +299,7 @@ export function useAvailableResources( }, { type: 'integration' as const, - items: listIntegrationsByPopularity().map((integration) => ({ + items: usableIntegrations.map((integration) => ({ id: integration.blockType, name: integration.name, iconComponent: integration.icon, @@ -355,6 +375,7 @@ export function useAvailableResources( knowledgeBases, tasks, logs, + usableIntegrations, excludeTypes, dashboardsEnabled, testsEnabled, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.test.ts index 6078f260c35..b518a54f723 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.test.ts @@ -33,6 +33,32 @@ describe('organization mention inventory', () => { expect(result.groups[1].items).toEqual([{ id: 'slack', name: 'Slack' }]) expect(result.isHydrating).toBe(false) }) + it('offers an integration only when every workspace policy permits it', () => { + const withIntegrations = (ids: string[]) => ({ + ...inventory, + groups: [{ type: 'integration' as const, items: ids.map((id) => ({ id, name: id })) }], + }) + const result = mergeOrganizationResourceInventories( + [ + { id: 'sales', name: 'Sales' }, + { id: 'finance', name: 'Finance' }, + ], + { sales: withIntegrations(['slack', 'gmail_v2']), finance: withIntegrations(['gmail_v2']) } + ) + expect(result.groups).toEqual([ + { type: 'integration', items: [{ id: 'gmail_v2', name: 'gmail_v2' }] }, + ]) + }) + it('offers no integration while a workspace policy has not arrived', () => { + const result = mergeOrganizationResourceInventories( + [ + { id: 'sales', name: 'Sales' }, + { id: 'finance', name: 'Finance' }, + ], + { sales: inventory } + ) + expect(result.groups.find((group) => group.type === 'integration')?.items ?? []).toEqual([]) + }) it('keeps mention confirmation pending until every accessible workspace settles', () => { expect( mergeOrganizationResourceInventories([{ id: 'sales', name: 'Sales' }], {}).isHydrating diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx index 1890891edfb..e828752d7f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx @@ -37,10 +37,12 @@ export function mergeOrganizationResourceInventories( const byType = new Map() const structureFolders: AvailableResources['structureFolders'] = { table: [], knowledgebase: [] } let isHydrating = false + let isAnyInventoryMissing = false for (const workspace of workspaces) { const inventory = inventories[workspace.id] if (!inventory) { isHydrating = true + isAnyInventoryMissing = true continue } isHydrating ||= inventory.isHydrating @@ -51,8 +53,15 @@ export function mergeOrganizationResourceInventories( }) for (const group of inventory.groups) { if (group.type === 'browser' || group.type === 'terminal') continue - // Integration definitions are global and do not belong to a workspace. - if (group.type === 'integration' && byType.has(group.type)) continue + // Integrations are not workspace-owned, but a permission group can target + // individual workspaces, so one is offered only where every workspace permits it, + // and a workspace whose inventory has not arrived permits nothing yet. + const integrations = group.type === 'integration' ? byType.get(group.type) : undefined + if (integrations) { + const permittedHere = new Set(group.items.map((item) => item.id)) + integrations.items = integrations.items.filter((item) => permittedHere.has(item.id)) + continue + } let target = byType.get(group.type) if (!target) { target = { type: group.type, items: [] } @@ -64,5 +73,7 @@ export function mergeOrganizationResourceInventories( structureFolders.table.push(...inventory.structureFolders.table.map(owned)) structureFolders.knowledgebase.push(...inventory.structureFolders.knowledgebase.map(owned)) } + const integrations = byType.get('integration') + if (integrations && isAnyInventoryMissing) integrations.items = [] return { groups, structureFolders, isHydrating } } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index af31043f573..2393fbd0e29 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -1,10 +1,11 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { @@ -13,9 +14,11 @@ import { resolveCredentialDisplay, resolveOAuthServiceForIntegration, } from '@/lib/integrations' +import { isIntegrationPermitted } from '@/lib/integrations/access' import { credentialProviderMatchesService } from '@/lib/oauth' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile' +import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' import { ConnectPersonalTokenModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal' @@ -42,7 +45,10 @@ import { getTemplatesForBlock, type ScopedBlockTemplate, } from '@/blocks/registry' +import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { useIntegrationAccess } from '@/hooks/use-integration-access' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -60,7 +66,84 @@ interface IntegrationBlockDetailProps { workspaceId: string } +/** + * Integration detail page, shown only when the viewer's access policy permits + * the integration (an unconfigured one included, which the page presents as + * unavailable). A direct link to a restricted one renders an access-required + * state instead of the connect actions, and nothing renders until the policy + * has loaded. + */ export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) { + const { getAccess, availabilityError, refetchAvailability } = useIntegrationAccess(workspaceId) + const accessRequests = useWorkspaceAccessRequestFeatures() + const access = getAccess(integration.type) + + if (availabilityError) { + return ( + void refetchAvailability()}>Try again} + /> + ) + } + if (access === undefined) return + if (isIntegrationPermitted(access)) { + return + } + + const requestable = access === 'requestable' && accessRequests.data?.enabled === true + + return ( + + ) : undefined + } + /> + ) +} + +interface IntegrationAccessNoticeProps { + workspaceId: string + title: string + description: string + action?: ReactNode +} + +function IntegrationAccessNotice({ + workspaceId, + title, + description, + action, +}: IntegrationAccessNoticeProps) { + return ( +
+
+ + Integrations + +
+ +
+ ) +} + +function IntegrationBlockDetailContent({ integration, workspaceId }: IntegrationBlockDetailProps) { const scrollContainerRef = useRef(null) useOAuthReturnRouter() const router = useRouter() diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index 04993a39a24..a958b56ec78 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -83,12 +83,17 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ ) } +interface IntegrationsShowcaseProps { + /** Tiles whose block type fails this check are left out of the grid. */ + isTileVisible: (blockType: string) => boolean +} + /** * Decorative integrations grid: a notched, masked grid background populated * with a curated set of brand tiles. The bottom-right notch leaves room for an * "Explore in chat" CTA rendered by `ShowcaseWithExplore`. */ -export function IntegrationsShowcase() { +export function IntegrationsShowcase({ isTileVisible }: IntegrationsShowcaseProps) { return (
{SHOWCASE_TILES.map((tile) => { const block = getBlock(tile.id) - if (!block) return null + if (!block || !isTileVisible(tile.id)) return null return (
- + isIntegrationUsable(getAccess(blockType))} + /> {chatEnabled && ( unavailable?: boolean + /** Set for an integration the viewer's policy restricts but lets them request. */ + onRequestAccess?: () => void } function IntegrationItem({ @@ -81,7 +92,9 @@ function IntegrationItem({ description, icon: Icon, unavailable = false, + onRequestAccess, }: IntegrationItemProps) { + const restricted = onRequestAccess !== undefined return ( + ) : undefined + } + href={restricted ? undefined : `/workspace/${workspaceId}/integrations/${slug}`} + onClick={onRequestAccess} + clickLabel={restricted ? `Request access to ${name}` : `Open ${name}`} + navigable={!unavailable && !restricted} disabled={unavailable} /> ) @@ -150,10 +169,20 @@ function IntegrationsContent() { const scrollContainerRef = useRef(null) const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const { integrationAvailability } = usePermissionConfig() - - const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] = - useQueryStates(integrationsParsers, integrationsUrlKeys) + const { + getAccess, + isReady: isAccessReady, + isAvailabilityFetching, + availabilityError, + refetchAvailability, + } = useIntegrationAccess(workspaceId) + const accessRequestsEnabled = useWorkspaceAccessRequestFeatures().data?.enabled === true + const [requestedIntegration, setRequestedIntegration] = useState(null) + + const [{ category: urlCategory, search: urlSearchTerm }, setIntegrationFilters] = useQueryStates( + integrationsParsers, + integrationsUrlKeys + ) /** * The input is controlled directly by the instant nuqs value; only the URL @@ -180,10 +209,22 @@ function IntegrationsContent() { [credentials] ) + /** + * A credential is listed while one integration it authenticates is usable + * here; one tied to no catalog integration is not governed by the allowlist. + * Nothing is listed until the access policy has loaded. + */ const connectedItems = useMemo(() => { + if (!isAccessReady) return [] return oauthCredentials.flatMap((credential) => { const display = resolveCredentialDisplay(credential) if (!display.service || !display.icon) return [] + const isUsable = + display.coveredIntegrations.length === 0 || + display.coveredIntegrations.some((integration) => + isIntegrationPermitted(getAccess(integration.type)) + ) + if (!isUsable) return [] return [ { credential, @@ -203,7 +244,36 @@ function IntegrationsContent() { }, ] }) - }, [oauthCredentials]) + }, [oauthCredentials, isAccessReady, getAccess]) + + /** + * Usable and unconfigured integrations are listed, the latter as unavailable; + * a requestable one is listed as a locked row while access requests are on. + * Nothing is listed until the access policy has loaded. + */ + const isListed = useCallback( + (blockType: string) => { + const access = getAccess(blockType) + return isIntegrationPermitted(access) || (access === 'requestable' && accessRequestsEnabled) + }, + [getAccess, accessRequestsEnabled] + ) + + const listedFeatured = useMemo( + () => FEATURED_INTEGRATIONS.filter((integration) => isListed(integration.type)), + [isListed] + ) + + const listedCategorySections = useMemo( + () => + ALL_CATEGORY_SECTIONS.flatMap((section) => { + const integrations = section.integrations.filter((integration) => + isListed(integration.type) + ) + return integrations.length > 0 ? [{ label: section.label, integrations }] : [] + }), + [isListed] + ) const setSelectedCategory = useCallback( (category: string) => { @@ -215,10 +285,21 @@ function IntegrationsContent() { const categoryOptions = [ ALL_CATEGORY, ...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []), - FEATURED_LABEL, - ...ALL_CATEGORY_SECTIONS.map((section) => section.label), + ...(listedFeatured.length > 0 ? [FEATURED_LABEL] : []), + ...listedCategorySections.map((section) => section.label), ] + /** + * A category the viewer's integration policy leaves empty is not offered, so a + * link or a policy change that points at one falls back to All. Connected stays + * selected while its rows load so the page does not flash All first. + */ + const selectedCategory = + categoryOptions.includes(urlCategory) || + (urlCategory === CONNECTED_LABEL && (credentialsLoading || !isAccessReady)) + ? urlCategory + : ALL_CATEGORY + const isAllCategorySelected = selectedCategory === ALL_CATEGORY const isFeaturedSelected = selectedCategory === FEATURED_LABEL const isConnectedSelected = selectedCategory === CONNECTED_LABEL @@ -234,7 +315,7 @@ function IntegrationsContent() { integration.description.toLowerCase().includes(normalizedSearch) if (isFeaturedSelected) { - const items = FEATURED_INTEGRATIONS.filter(matchesSearch) + const items = listedFeatured.filter(matchesSearch) return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : [] } @@ -243,27 +324,22 @@ function IntegrationsContent() { // Featured is a curated home-row pin: hide it during search so results // are not duplicated between the Featured section and the category list. - const featured = normalizedSearch ? [] : FEATURED_INTEGRATIONS.filter(matchesCategory) + const featured = normalizedSearch ? [] : listedFeatured.filter(matchesCategory) const featuredSection = featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : [] - if (isAllCategorySelected) { - const rest = ALL_CATEGORY_SECTIONS.map((section) => ({ + const categorySections = listedCategorySections + .filter((section) => isAllCategorySelected || section.label === selectedCategory) + .map((section) => ({ label: section.label, integrations: section.integrations.filter(matchesSearch), - })).filter((section) => section.integrations.length > 0) - return [...featuredSection, ...rest] - } + })) + .filter((section) => section.integrations.length > 0) - const integrations = INTEGRATIONS.filter(matchesCategory) - .filter(matchesSearch) - .sort((a, b) => a.name.localeCompare(b.name)) - - return [ - ...featuredSection, - ...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []), - ] + return [...featuredSection, ...categorySections] }, [ + listedFeatured, + listedCategorySections, isAllCategorySelected, isConnectedSelected, isFeaturedSelected, @@ -298,6 +374,7 @@ function IntegrationsContent() { ]) const showNoResults = + isAccessReady && Boolean(urlSearchTerm.trim() || !isAllCategorySelected) && filteredCategorySections.length === 0 && visibleConnectedItems.length === 0 @@ -305,6 +382,15 @@ function IntegrationsContent() { return (
+ {requestedIntegration && accessRequestsEnabled && ( + setRequestedIntegration(null)} + /> + )}
)} + {availabilityError && ( + void refetchAvailability()} + /> + )} + {filteredCategorySections.map((section) => ( {section.integrations.map((integration) => { const Icon = blockTypeToIconMap[integration.type] if (!Icon) return null - const availability = integrationAvailability.get(integration.type.toLowerCase()) - const deploymentUnavailable = - availability?.state === 'unavailable' || availability?.state === 'misconfigured' + const access = getAccess(integration.type) return ( setRequestedIntegration(integration) + : undefined + } /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts index 1d82af1f806..e03f9f2d199 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts @@ -40,19 +40,20 @@ const INTEGRATION_BASES: readonly { }) /** - * Builds the full integration catalog as search items for a given workspace. - * OAuth integrations link directly to the detail page with `?connect=oauth` so - * the connect modal auto-opens (via the detail page's `useEffect` on - * `CONNECT_QUERY_PARAM`). Non-OAuth integrations link to the plain detail page. + * Builds the integrations the viewer can use as search items for a given + * workspace. `isUsable` is required, not defaulted, so a caller cannot list the + * whole catalog by omission; pass the `useIntegrationAccess` verdict. OAuth + * integrations link to the detail page with `?connect=oauth` so the connect + * modal auto-opens; non-OAuth integrations link to the plain detail page. */ export function buildIntegrationSearchItems( workspaceId: string, - isBlockAllowed: (blockType: string) => boolean = () => true, + isUsable: (blockType: string) => boolean, getConnectMode: ( blockType: string ) => (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE] | null = () => CONNECT_MODE.oauth ): IntegrationSearchItem[] { - return INTEGRATION_BASES.filter((base) => isBlockAllowed(base.blockType)).map((base) => { + return INTEGRATION_BASES.filter((base) => isUsable(base.blockType)).map((base) => { const connectMode = base.authType === 'oauth' ? getConnectMode(base.blockType) : null const connectSuffix = connectMode ? `?${CONNECT_QUERY_PARAM}=${connectMode}` : '' return { 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 96a6788d874..9b39c99e91e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -52,6 +52,7 @@ import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' +import { isIntegrationUsable } from '@/lib/integrations/access' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' import { captureEvent } from '@/lib/posthog/client' import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file' @@ -144,6 +145,7 @@ import { useUserProfile } from '@/hooks/queries/user-profile' import { useUpdateWorkflow } from '@/hooks/queries/workflows' import type { Workspace } from '@/hooks/queries/workspace' import { useContextMenu } from '@/hooks/use-context-menu' +import { useIntegrationAccess } from '@/hooks/use-integration-access' import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -419,10 +421,10 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor const { config: permissionConfig, filterBlocks, - isBlockAllowed, isToolAllowed, integrationAvailability, } = usePermissionConfig() + const { getAccess: getIntegrationAccess } = useIntegrationAccess(workspaceId) const accessRequests = useWorkspaceAccessRequestFeatures() const accessRequestsEnabled = accessRequests.data?.enabled === true const { getSettingsHref, navigateToSettings } = useSettingsNavigation() @@ -1136,14 +1138,22 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor () => permissionConfig.hideIntegrationsTab ? [] - : buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType) => { - const availability = integrationAvailability.get(blockType.toLowerCase()) - if (!availability) return CONNECT_MODE.oauth - if (availability?.oauthAvailable) return CONNECT_MODE.oauth - if (availability?.state === 'limited') return CONNECT_MODE.serviceAccount - return null - }), - [workspaceId, permissionConfig.hideIntegrationsTab, isBlockAllowed, integrationAvailability] + : buildIntegrationSearchItems( + workspaceId, + (blockType) => isIntegrationUsable(getIntegrationAccess(blockType)), + (blockType) => { + const availability = integrationAvailability.get(blockType.toLowerCase()) + if (!availability || availability.oauthAvailable) return CONNECT_MODE.oauth + if (availability.state === 'limited') return CONNECT_MODE.serviceAccount + return null + } + ), + [ + workspaceId, + permissionConfig.hideIntegrationsTab, + getIntegrationAccess, + integrationAvailability, + ] ) const searchModalConnectedAccounts = useMemo( diff --git a/apps/sim/hooks/queries/integration-availability.ts b/apps/sim/hooks/queries/integration-availability.ts index 1a71669580c..5e5c31dc102 100644 --- a/apps/sim/hooks/queries/integration-availability.ts +++ b/apps/sim/hooks/queries/integration-availability.ts @@ -6,10 +6,11 @@ import { integrationAvailabilityKeys, } from '@/hooks/queries/utils/integration-availability-keys' -export function useIntegrationAvailability() { +export function useIntegrationAvailability(options?: { enabled?: boolean }) { return useQuery({ queryKey: integrationAvailabilityKeys.environments(), queryFn: ({ signal }) => requestJson(getAllowedIntegrationsContract, { signal }), staleTime: INTEGRATION_AVAILABILITY_STALE_TIME, + enabled: options?.enabled ?? true, }) } diff --git a/apps/sim/hooks/use-integration-access.ts b/apps/sim/hooks/use-integration-access.ts new file mode 100644 index 00000000000..51f228b0dea --- /dev/null +++ b/apps/sim/hooks/use-integration-access.ts @@ -0,0 +1,169 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { + createIntegrationAccessPolicy, + type IntegrationAccess, + type IntegrationAccessPolicy, + resolveIntegrationAccess, +} from '@/lib/integrations/access' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' +import { overlayVisibility } from '@/blocks/visibility/context' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' +import { useIntegrationAvailability } from '@/hooks/queries/integration-availability' + +interface IntegrationAccessOptions { + /** `false` until a lazily opened surface opens; nothing is fetched meanwhile. */ + enabled?: boolean +} + +interface IntegrationAccessStatus { + /** Whether both policies have loaded for a workspace. */ + isReady: boolean + /** + * Whether a verdict is still coming. Unlike `!isReady`, a policy request that + * failed has settled, so a caller waiting on this never waits forever. + */ + isPending: boolean + isAvailabilityFetching: boolean + availabilityError: Error | null + refetchAvailability: ReturnType['refetch'] +} + +interface IntegrationAccessResult extends IntegrationAccessStatus { + /** + * The viewer's access to an integration in the workspace, or `undefined` + * until `isReady`, so a surface listing integrations fails closed by + * construction. + */ + getAccess: (blockType: string) => IntegrationAccess | undefined +} + +export interface IntegrationAccessPolicyState extends IntegrationAccessStatus { + /** The policy as loaded so far: an unloaded side reads as unrestricted. */ + policy: IntegrationAccessPolicy + oauthServiceAvailability: ReadonlyMap + isAvailabilityLoading: boolean + isAvailabilityReady: boolean +} + +/** + * The integration access policy as loaded so far, which fails open while it + * loads. Only `usePermissionConfig` reads it, for the workflow toolbar and tool + * pickers that have always failed open; `check:integration-access` refuses it + * anywhere else. Every other surface reads {@link useIntegrationAccess}. + */ +export function useIntegrationAccessPolicy( + workspaceId: string | undefined, + options?: IntegrationAccessOptions +): IntegrationAccessPolicyState { + const enabled = options?.enabled ?? true + const blockOverlayVersion = useCustomBlockOverlayVersion() + const credentialGroupsEnabled = useOptionalWorkspaceHostContext()?.features?.credentialGroups + const { + data: permissionData, + isSuccess: isPermissionReady, + isPending: isPermissionPending, + } = useUserPermissionConfig(enabled ? workspaceId : undefined) + const { + data: availabilityData, + isPending: isAvailabilityPending, + isLoading: isAvailabilityLoading, + isFetching: isAvailabilityFetching, + isSuccess: isAvailabilityReady, + error: availabilityError, + refetch: refetchAvailability, + } = useIntegrationAvailability({ enabled }) + + const policy = useMemo( + () => + createIntegrationAccessPolicy({ + groupAllowlist: permissionData?.config?.allowedIntegrations ?? null, + deploymentAllowlist: availabilityData?.allowedIntegrations ?? null, + availability: availabilityData?.integrationAvailability ?? [], + visibility: overlayVisibility(), + credentialGroupsEnabled: credentialGroupsEnabled === true, + }), + [permissionData?.config, availabilityData, credentialGroupsEnabled, blockOverlayVersion] + ) + + const oauthServiceAvailability = useMemo( + () => + new Map( + (availabilityData?.oauthServiceAvailability ?? []).map(({ providerId, available }) => [ + providerId.toLowerCase(), + available, + ]) + ), + [availabilityData?.oauthServiceAvailability] + ) + + const isActive = enabled && Boolean(workspaceId) + const isReady = isActive && isPermissionReady && isAvailabilityReady + const isPending = isActive && (isPermissionPending || isAvailabilityPending) + + return useMemo( + () => ({ + policy, + isReady, + isPending, + oauthServiceAvailability, + isAvailabilityLoading, + isAvailabilityFetching, + isAvailabilityReady, + availabilityError, + refetchAvailability, + }), + [ + policy, + isReady, + isPending, + oauthServiceAvailability, + isAvailabilityLoading, + isAvailabilityFetching, + isAvailabilityReady, + availabilityError, + refetchAvailability, + ] + ) +} + +/** + * The single source of integration access for one workspace: its permission + * group intersected with the deployment allowlist, then deployment OAuth + * configuration. Takes the workspace explicitly, because a permission group can + * target individual workspaces and an organization-level surface asks about + * several. Without a workspace there is no verdict. `check:integration-access` + * keeps every surface that lists integrations on it. + */ +export function useIntegrationAccess( + workspaceId: string | undefined, + options?: IntegrationAccessOptions +): IntegrationAccessResult { + const { + policy, + isReady, + isPending, + isAvailabilityFetching, + availabilityError, + refetchAvailability, + } = useIntegrationAccessPolicy(workspaceId, options) + + const getAccess = useCallback( + (blockType: string) => (isReady ? resolveIntegrationAccess(blockType, policy) : undefined), + [isReady, policy] + ) + + return useMemo( + () => ({ + getAccess, + isReady, + isPending, + isAvailabilityFetching, + availabilityError, + refetchAvailability, + }), + [getAccess, isReady, isPending, isAvailabilityFetching, availabilityError, refetchAvailability] + ) +} diff --git a/apps/sim/hooks/use-permission-config.test.tsx b/apps/sim/hooks/use-permission-config.test.tsx index 0edf6956149..5b399762571 100644 --- a/apps/sim/hooks/use-permission-config.test.tsx +++ b/apps/sim/hooks/use-permission-config.test.tsx @@ -36,6 +36,7 @@ vi.mock('@/lib/permission-groups/operation-access', () => ({ import type { GetAllowedIntegrationsResponse } from '@/lib/api/contracts/common' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { integrationAvailabilityKeys } from '@/hooks/queries/utils/integration-availability-keys' +import { useIntegrationAccess } from '@/hooks/use-integration-access' import { type PermissionConfigResult, usePermissionConfig } from '@/hooks/use-permission-config' const mockRequestJson = apiClientRequestMockFns.mockRequestJson @@ -52,9 +53,11 @@ describe('usePermissionConfig deployment readiness', () => { let root: Root let queryClient: QueryClient let current: PermissionConfigResult + let access: ReturnType function Probe() { current = usePermissionConfig() + access = useIntegrationAccess('workspace-1') return null } @@ -89,6 +92,7 @@ describe('usePermissionConfig deployment readiness', () => { mockUseUserPermissionConfig.mockReturnValue({ data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] } }, isLoading: false, + isSuccess: true, }) queryClient.setQueryData(integrationAvailabilityKeys.environments(), { ...AVAILABILITY, @@ -101,10 +105,23 @@ describe('usePermissionConfig deployment readiness', () => { expect(current!.isBlockRequestable('credential_group')).toBe(false) }) + it('withholds an access verdict until the permission group loads, while the legacy gate fails open', () => { + mockUseUserPermissionConfig.mockReturnValue({ + data: undefined, + isLoading: true, + isSuccess: false, + }) + queryClient.setQueryData(integrationAvailabilityKeys.environments(), AVAILABILITY) + render() + expect(access.getAccess('slack_v2')).toBeUndefined() + expect(current.isBlockAllowed('slack_v2')).toBe(true) + }) + it('does not offer a request before deployment availability resolves', () => { mockUseUserPermissionConfig.mockReturnValue({ data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] } }, isLoading: false, + isSuccess: true, }) mockRequestJson.mockReturnValue(new Promise(() => {})) render() diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 1ea386a0d11..06291749e6d 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -4,26 +4,18 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' import type { IntegrationAvailabilityResponse } from '@/lib/api/contracts/common' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { - isDeploymentGatedIntegrationType, - resolveIntegrationAvailabilityStateForVisibility, -} from '@/lib/integrations/availability' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolveIntegrationAccess } from '@/lib/integrations/access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, } from '@/lib/permission-groups/fields' -import { - intersectAccessControlAllowlists, - resolveAccessControlBlockType, -} from '@/lib/permission-groups/integration-allowlist' import { createModelAccessGate } from '@/lib/permission-groups/model-access' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' -import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' -import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' -import { overlayVisibility } from '@/blocks/visibility/context' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' -import { useIntegrationAvailability } from '@/hooks/queries/integration-availability' +import { + type IntegrationAccessPolicyState, + useIntegrationAccessPolicy, +} from '@/hooks/use-integration-access' export interface PermissionConfigResult { config: PermissionGroupConfig @@ -34,6 +26,11 @@ export interface PermissionConfigResult { isInPermissionGroup: boolean filterBlocks: (blocks: T[]) => T[] filterProviders: (providerIds: string[]) => string[] + /** + * Whether a block's integration access is `available`. Fails open while the + * policies load, as the workflow toolbar and tool pickers always have; a + * surface listing integrations reads `useIntegrationAccess` instead. + */ isBlockAllowed: (blockType: string) => boolean /** Presentation-only hint; request creation revalidates the deployed public catalog. */ isBlockRequestable: (blockType: string) => boolean @@ -52,14 +49,12 @@ export interface PermissionConfigResult { isIntegrationAvailabilityFetching: boolean isIntegrationAvailabilityReady: boolean integrationAvailabilityError: Error | null - refetchIntegrationAvailability: ReturnType['refetch'] + refetchIntegrationAvailability: IntegrationAccessPolicyState['refetchAvailability'] } export function usePermissionConfig(): PermissionConfigResult { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined - const blockOverlayVersion = useCustomBlockOverlayVersion() - const hostContext = useOptionalWorkspaceHostContext() const { data: permissionData, @@ -69,15 +64,17 @@ export function usePermissionConfig(): PermissionConfigResult { refetch: refetchPermissionConfig, } = useUserPermissionConfig(workspaceId) const { - data: envAllowlistData, - isLoading: isEnvAllowlistLoading, - isFetching: isIntegrationAvailabilityFetching, - isSuccess: isIntegrationAvailabilityReady, - error: integrationAvailabilityError, - refetch: refetchIntegrationAvailability, - } = useIntegrationAvailability() - - const isLoading = isPermissionLoading || isEnvAllowlistLoading + policy, + isReady: isIntegrationAccessReady, + oauthServiceAvailability, + isAvailabilityLoading: isIntegrationAvailabilityLoading, + isAvailabilityFetching: isIntegrationAvailabilityFetching, + isAvailabilityReady: isIntegrationAvailabilityReady, + availabilityError: integrationAvailabilityError, + refetchAvailability: refetchIntegrationAvailability, + } = useIntegrationAccessPolicy(workspaceId) + + const isLoading = isPermissionLoading || isIntegrationAvailabilityLoading const config = useMemo(() => { if (!permissionData?.config) { @@ -89,104 +86,26 @@ export function usePermissionConfig(): PermissionConfigResult { const isInPermissionGroup = !!permissionData?.permissionGroupId /** - * Both sides of the membership test are judged as the current block, so a - * policy naming a retired id — `ALLOWED_INTEGRATIONS=slack` — still permits - * the successor the editor offers. - * - * Each policy is canonicalized *before* the two are intersected, not after: a - * group naming `slack` and an env allowlist naming `slack_v2` intersect to - * nothing textually, hiding an integration both policies allow. This is the - * same helper the server gates use — `mergeEnvAllowlist` for the config the - * catalog reads, `allowedIntegrationTypes` for the block and selector gates — - * so what this hook shows and what the server permits cannot disagree. + * The effective allowlist (group ∩ deployment) in the resolved vocabulary, so + * a policy naming a retired id still permits its successor. Server gates + * intersect with the same helper, so what this hook shows and what the server + * permits cannot disagree. */ - const allowedAccessControlTypes = useMemo( - () => - intersectAccessControlAllowlists( - config.allowedIntegrations, - envAllowlistData?.allowedIntegrations ?? null - ), - [config.allowedIntegrations, envAllowlistData] - ) - const mergedAllowedIntegrations = useMemo( - () => (allowedAccessControlTypes === null ? null : [...allowedAccessControlTypes]), - [allowedAccessControlTypes] + () => (policy.allowlist === null ? null : [...policy.allowlist]), + [policy.allowlist] ) - const integrationAvailability = useMemo(() => { - const visibility = overlayVisibility() - return new Map( - (envAllowlistData?.integrationAvailability ?? []).map((availability) => [ - availability.type.toLowerCase(), - { - ...availability, - state: resolveIntegrationAvailabilityStateForVisibility(availability, visibility), - }, - ]) - ) - }, [envAllowlistData?.integrationAvailability, blockOverlayVersion]) - - const oauthServiceAvailability = useMemo( - () => - new Map( - (envAllowlistData?.oauthServiceAvailability ?? []).map(({ providerId, available }) => [ - providerId.toLowerCase(), - available, - ]) - ), - [envAllowlistData?.oauthServiceAvailability] + const isBlockAllowed = useMemo( + () => (blockType: string) => resolveIntegrationAccess(blockType, policy) === 'available', + [policy] ) - const isBlockAllowed = useMemo(() => { - return (blockType: string) => { - const normalizedBlockType = blockType.toLowerCase() - if (normalizedBlockType === 'credential_group' && !hostContext?.features?.credentialGroups) { - return false - } - const availability = integrationAvailability.get(normalizedBlockType) - if ( - isDeploymentGatedIntegrationType(normalizedBlockType) && - availability && - (availability.state === 'unavailable' || availability.state === 'misconfigured') - ) { - return false - } - if (isBlockTypeAccessControlExempt(blockType)) return true - if (allowedAccessControlTypes === null) return true - return allowedAccessControlTypes.has(resolveAccessControlBlockType(normalizedBlockType)) - } - }, [hostContext?.features?.credentialGroups, integrationAvailability, allowedAccessControlTypes]) - - const isBlockRequestable = useMemo(() => { - const deploymentAllowlist = intersectAccessControlAllowlists( - null, - envAllowlistData?.allowedIntegrations ?? null - ) - return (blockType: string): boolean => { - if (isLoading || !isIntegrationAvailabilityReady || isBlockAllowed(blockType)) return false - if (isBlockTypeAccessControlExempt(blockType)) return false - if (blockType === 'credential_group' && !hostContext?.features?.credentialGroups) return false - const availability = integrationAvailability.get(blockType.toLowerCase()) - if ( - isDeploymentGatedIntegrationType(blockType) && - availability && - (availability.state === 'unavailable' || availability.state === 'misconfigured') - ) - return false - return ( - deploymentAllowlist === null || - deploymentAllowlist.has(resolveAccessControlBlockType(blockType)) - ) - } - }, [ - envAllowlistData, - isLoading, - isIntegrationAvailabilityReady, - isBlockAllowed, - hostContext?.features?.credentialGroups, - integrationAvailability, - ]) + const isBlockRequestable = useMemo( + () => (blockType: string) => + isIntegrationAccessReady && resolveIntegrationAccess(blockType, policy) === 'requestable', + [isIntegrationAccessReady, policy] + ) const isModelUsable = useMemo( () => @@ -246,9 +165,9 @@ export function usePermissionConfig(): PermissionConfigResult { isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, - integrationAvailability, + integrationAvailability: policy.availability, oauthServiceAvailability, - isIntegrationAvailabilityLoading: isEnvAllowlistLoading, + isIntegrationAvailabilityLoading, isIntegrationAvailabilityFetching, isIntegrationAvailabilityReady, integrationAvailabilityError, @@ -269,9 +188,9 @@ export function usePermissionConfig(): PermissionConfigResult { isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, - integrationAvailability, + policy.availability, oauthServiceAvailability, - isEnvAllowlistLoading, + isIntegrationAvailabilityLoading, isIntegrationAvailabilityFetching, isIntegrationAvailabilityReady, integrationAvailabilityError, diff --git a/apps/sim/lib/integrations/access.test.ts b/apps/sim/lib/integrations/access.test.ts new file mode 100644 index 00000000000..0237447beb8 --- /dev/null +++ b/apps/sim/lib/integrations/access.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { IntegrationAvailabilityResponse } from '@/lib/api/contracts/common' +import { + createIntegrationAccessPolicy, + type IntegrationAccess, + resolveIntegrationAccess, +} from '@/lib/integrations/access' + +function accessOf( + blockType: string, + policy: { + groupAllowlist?: string[] | null + deploymentAllowlist?: string[] | null + availability?: IntegrationAvailabilityResponse[] + credentialGroupsEnabled?: boolean + } +): IntegrationAccess { + return resolveIntegrationAccess( + blockType, + createIntegrationAccessPolicy({ + groupAllowlist: policy.groupAllowlist ?? null, + deploymentAllowlist: policy.deploymentAllowlist ?? null, + availability: policy.availability ?? [], + visibility: null, + credentialGroupsEnabled: policy.credentialGroupsEnabled ?? true, + }) + ) +} + +const AIRTABLE_UNCONFIGURED: IntegrationAvailabilityResponse = { + type: 'airtable', + state: 'unavailable', + oauthAvailable: false, +} + +describe('resolveIntegrationAccess', () => { + it('makes a group restriction requestable only within the deployment allowlist', () => { + const policy = { groupAllowlist: [], deploymentAllowlist: ['gmail_v2'] } + expect(accessOf('gmail_v2', policy)).toBe('requestable') + expect(accessOf('slack_v2', policy)).toBe('denied') + }) + + it('denies, rather than reports unconfigured, an integration the policy restricts', () => { + const policy = { groupAllowlist: ['slack_v2'], availability: [AIRTABLE_UNCONFIGURED] } + expect(accessOf('airtable', policy)).toBe('denied') + }) + + it('reports a permitted integration whose OAuth the deployment lacks as unconfigured', () => { + expect(accessOf('airtable', { availability: [AIRTABLE_UNCONFIGURED] })).toBe('unconfigured') + }) + + it('judges a retired id in either policy as its successor', () => { + expect(accessOf('slack_v2', { groupAllowlist: ['slack'] })).toBe('available') + expect(accessOf('slack_v2', { groupAllowlist: [], deploymentAllowlist: ['slack'] })).toBe( + 'requestable' + ) + }) + + it('keeps the universal entry point available under an empty allowlist', () => { + expect(accessOf('start_trigger', { groupAllowlist: [] })).toBe('available') + }) + + it('denies credential groups where the feature is off, even when unrestricted', () => { + expect(accessOf('credential_group', { credentialGroupsEnabled: false })).toBe('denied') + }) +}) diff --git a/apps/sim/lib/integrations/access.ts b/apps/sim/lib/integrations/access.ts new file mode 100644 index 00000000000..05ff186a12b --- /dev/null +++ b/apps/sim/lib/integrations/access.ts @@ -0,0 +1,108 @@ +import type { IntegrationAvailabilityResponse } from '@/lib/api/contracts/common' +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import { + isDeploymentGatedIntegrationType, + resolveIntegrationAvailabilityStateForVisibility, +} from '@/lib/integrations/availability' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + intersectAccessControlAllowlists, + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' + +/** + * What a viewer can do with an integration in one workspace — the single + * answer every surface that lists integrations renders from. + * + * - `available`: usable. + * - `unconfigured`: the policy permits it, but this deployment has not + * configured its OAuth client. The catalog shows it as unavailable; pickers + * leave it out, since the server drops a mention of it. + * - `requestable`: the permission group restricts it within what the deployment + * allows, so an access request can lift the restriction. + * - `denied`: restricted with no request path. + */ +export type IntegrationAccess = 'available' | 'unconfigured' | 'requestable' | 'denied' + +/** + * Whether a picker or `@` menu may offer an integration: usable now. No verdict + * yet (`undefined`) is never usable, so a listing fails closed while loading. + */ +export function isIntegrationUsable(access: IntegrationAccess | undefined): boolean { + return access === 'available' +} + +/** Whether the policy permits an integration, whether or not the deployment configured it. */ +export function isIntegrationPermitted(access: IntegrationAccess | undefined): boolean { + return access === 'available' || access === 'unconfigured' +} + +/** The inputs one access decision reads, in the resolved allowlist vocabulary. */ +export interface IntegrationAccessPolicy { + /** The permission group intersected with the deployment allowlist; `null` is unrestricted. */ + allowlist: ReadonlySet | null + /** The deployment allowlist alone: the ceiling an access request can lift a restriction to. */ + deploymentAllowlist: ReadonlySet | null + /** Deployment availability by lowercased block type, projected through the viewer's visibility. */ + availability: ReadonlyMap + credentialGroupsEnabled: boolean +} + +interface IntegrationAccessPolicyInput { + groupAllowlist: readonly string[] | null + deploymentAllowlist: readonly string[] | null + availability: readonly IntegrationAvailabilityResponse[] + visibility: BlockVisibilityState | null + credentialGroupsEnabled: boolean +} + +export function createIntegrationAccessPolicy({ + groupAllowlist, + deploymentAllowlist, + availability, + visibility, + credentialGroupsEnabled, +}: IntegrationAccessPolicyInput): IntegrationAccessPolicy { + return { + allowlist: intersectAccessControlAllowlists(groupAllowlist, deploymentAllowlist), + deploymentAllowlist: toAccessControlAllowlist(deploymentAllowlist), + availability: new Map( + availability.map((entry) => [ + entry.type.toLowerCase(), + { ...entry, state: resolveIntegrationAvailabilityStateForVisibility(entry, visibility) }, + ]) + ), + credentialGroupsEnabled, + } +} + +/** + * The policy is judged before deployment configuration, so an integration the + * permission group restricts is never advertised as merely unconfigured. + */ +export function resolveIntegrationAccess( + blockType: string, + policy: IntegrationAccessPolicy +): IntegrationAccess { + const normalizedBlockType = blockType.toLowerCase() + if (normalizedBlockType === 'credential_group' && !policy.credentialGroupsEnabled) { + return 'denied' + } + const state = policy.availability.get(normalizedBlockType)?.state + const unconfigured = + isDeploymentGatedIntegrationType(normalizedBlockType) && + (state === 'unavailable' || state === 'misconfigured') + + if (isPermittedBy(policy.allowlist, blockType)) { + return unconfigured ? 'unconfigured' : 'available' + } + if (unconfigured) return 'denied' + return isPermittedBy(policy.deploymentAllowlist, blockType) ? 'requestable' : 'denied' +} + +/** Membership in a resolved allowlist, where `null` is unrestricted and exempt blocks always pass. */ +function isPermittedBy(allowlist: ReadonlySet | null, blockType: string): boolean { + if (allowlist === null || isBlockTypeAccessControlExempt(blockType)) return true + return allowlist.has(resolveAccessControlBlockType(blockType.toLowerCase())) +} diff --git a/package.json b/package.json index 2a685a31e40..653b1662c77 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts", "check:actorless-executor-operations": "bun run scripts/check-actorless-executor-operations.ts", "check:permission-group-enforcement": "bun run scripts/check-permission-group-enforcement.ts", + "check:integration-access": "bun run scripts/check-integration-access.ts", "check:capability-subject": "bun run scripts/check-capability-subject.ts", "check:principal-kind-parity": "bun run scripts/check-principal-kind-parity.ts", "check:application-graph": "bun run scripts/check-application-graph.ts", diff --git a/scripts/check-integration-access.test.ts b/scripts/check-integration-access.test.ts new file mode 100644 index 00000000000..080bebffdcc --- /dev/null +++ b/scripts/check-integration-access.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { findIntegrationAccessViolations } from './check-integration-access' + +const GATE = `import { useIntegrationAccess } from '@/hooks/use-integration-access' +const { getAccess } = useIntegrationAccess(workspaceId)` + +function check(sources: Record, exempt: Record = {}) { + return findIntegrationAccessViolations( + Object.entries(sources).map(([path, source]) => ({ path, source })), + exempt + ) +} + +describe('findIntegrationAccessViolations', () => { + it('flags a module that lists a raw catalog without the access gate', () => { + const source = "import { listIntegrations } from '@/blocks/integration-matcher'" + expect(check({ 'app/picker.ts': source })).toEqual([ + expect.objectContaining({ path: 'app/picker.ts', names: ['listIntegrations'] }), + ]) + }) + + it('flags a renamed or multi-line raw import', () => { + const source = `import { + blockTypeToIconMap, + INTEGRATIONS as ALL_INTEGRATIONS, +} from '@/lib/integrations'` + expect(check({ 'app/list.tsx': source })).toEqual([ + expect.objectContaining({ path: 'app/list.tsx', names: ['INTEGRATIONS'] }), + ]) + }) + + it('flags a raw catalog read through a namespace import', () => { + const source = `import * as metadata from '@sim/deployment-config/integration-metadata' +export const types = metadata.INTEGRATION_METADATA.map((entry) => entry.type)` + expect(check({ 'ee/tile.tsx': source })).toEqual([ + expect.objectContaining({ path: 'ee/tile.tsx', names: ['INTEGRATION_METADATA'] }), + ]) + }) + + it('treats a namespace used other than by member access as reading every export', () => { + const destructured = `import * as matcher from '@/blocks/integration-matcher' +const { listIntegrations } = matcher` + const indexed = `import * as matcher from '@/blocks/integration-matcher' +export const all = matcher['listIntegrations']()` + const policy = `import * as access from '@/hooks/use-integration-access' +const { useIntegrationAccessPolicy } = access` + expect(check({ 'app/a.ts': destructured, 'app/b.ts': indexed, 'app/c.ts': policy })).toEqual([ + expect.objectContaining({ + path: 'app/a.ts', + names: ['listIntegrations', 'listIntegrationsByPopularity'], + }), + expect.objectContaining({ + path: 'app/b.ts', + names: ['listIntegrations', 'listIntegrationsByPopularity'], + }), + expect.objectContaining({ path: 'app/c.ts', names: ['useIntegrationAccessPolicy'] }), + ]) + }) + + it('flags a raw listing that imports the gate without calling it', () => { + const source = `import { useIntegrationAccess } from '@/hooks/use-integration-access' +import { listIntegrations } from '@/blocks/integration-matcher'` + expect(check({ 'app/picker.ts': source })).toHaveLength(1) + }) + + it('accepts the gate called through a namespace or a renamed binding', () => { + const namespaced = `import * as access from '@/hooks/use-integration-access' +import { INTEGRATIONS } from '@/lib/integrations' +const { getAccess } = access.useIntegrationAccess(workspaceId)` + const renamed = `import { useIntegrationAccess as useAccess } from '@/hooks/use-integration-access' +import { INTEGRATIONS } from '@/lib/integrations' +const { getAccess } = useAccess(workspaceId)` + expect(check({ 'app/a.tsx': namespaced, 'app/b.tsx': renamed })).toEqual([]) + }) + + it('accepts a raw catalog read alongside the access gate', () => { + const source = `${GATE}\nimport { INTEGRATIONS } from '@/lib/integrations'` + expect(check({ 'app/catalog.tsx': source })).toEqual([]) + }) + + it('ignores type-only imports and non-catalog names from the same module', () => { + const source = `import type { INTEGRATIONS } from '@/lib/integrations' +import { blockTypeToIconMap, type Integration } from '@/lib/integrations' +import { type listIntegrations } from '@/blocks/integration-matcher'` + expect(check({ 'app/icons.tsx': source })).toEqual([]) + }) + + it('accepts an exempt file or directory', () => { + const source = "import { INTEGRATIONS } from '@/lib/integrations'" + expect( + check( + { 'app/sitemap.ts': source, 'app/(landing)/integrations/page.tsx': source }, + { 'app/sitemap.ts': 'public', 'app/(landing)/': 'public' } + ) + ).toEqual([]) + }) + + it('refuses the fail-open policy through a namespace import', () => { + const source = `import * as access from '@/hooks/use-integration-access' +const { policy } = access.useIntegrationAccessPolicy(workspaceId)` + expect(check({ 'app/picker.tsx': source })).toEqual([ + expect.objectContaining({ path: 'app/picker.tsx', names: ['useIntegrationAccessPolicy'] }), + ]) + }) + + it('refuses the fail-open access policy outside the permission config hook', () => { + const source = "import { useIntegrationAccessPolicy } from '@/hooks/use-integration-access'" + expect(check({ 'app/picker.tsx': source, 'hooks/use-permission-config.ts': source })).toEqual([ + expect.objectContaining({ path: 'app/picker.tsx', names: ['useIntegrationAccessPolicy'] }), + ]) + }) + + it('reports an exemption that no longer covers a raw catalog import', () => { + expect(check({ 'app/sitemap.ts': 'export {}' }, { 'app/sitemap.ts': 'public' })).toEqual([ + expect.objectContaining({ path: 'app/sitemap.ts', stale: true }), + ]) + }) +}) diff --git a/scripts/check-integration-access.ts b/scripts/check-integration-access.ts new file mode 100644 index 00000000000..20a97d901a4 --- /dev/null +++ b/scripts/check-integration-access.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env bun +/** + * Keeps every surface that lists integrations on `useIntegrationAccess`. + * + * The integration catalogs are plain module exports, so a new picker that maps + * over one starts with every integration and is correct only if its author + * remembers to filter. A missed filter lists integrations the viewer's + * permission group denies, or whose OAuth the deployment never configured, and + * the server silently drops any mention of one. `useIntegrationAccess` is the + * one verdict those surfaces share; this audit makes reaching a raw catalog + * without it a build failure. + * + * The rule: a scanned module that reads a raw catalog must also call + * `useIntegrationAccess`, or appear in {@link EXEMPT} with the reason it may see + * every integration (a lookup by id, a public page, a builder whose caller + * gates). An exemption that no longer covers a raw import is reported too, so + * the list cannot outlive its reason. `useIntegrationAccessPolicy`, which fails + * open while the policy loads, is refused outside {@link FAIL_OPEN_READER}, so + * no listing can reach around the gate through it. + * + * Scope: client-facing modules under {@link SCAN_ROOTS}, minus API routes, + * `*.server.ts` and tests. `lib/` and `blocks/` are not scanned: they define + * and derive the catalogs, and no module there renders a list to a viewer. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { escapeRegExp } from '@sim/utils/string' + +const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../apps/sim') +const SCAN_ROOTS = ['app', 'components', 'hooks', 'stores', 'ee'] as const +const GATE_MODULE = '@/hooks/use-integration-access' +const GATE_NAME = 'useIntegrationAccess' +const FAIL_OPEN_NAME = 'useIntegrationAccessPolicy' +/** The one module allowed the fail-open policy, for the gates that have always failed open. */ +const FAIL_OPEN_READER = 'hooks/use-permission-config.ts' + +/** Each module that exports a whole integration catalog, and the exports that are one. */ +const RAW_CATALOGS: Readonly> = { + '@/lib/integrations': ['INTEGRATIONS'], + '@/blocks/integration-matcher': ['listIntegrations', 'listIntegrationsByPopularity'], + '@sim/deployment-config/integration-metadata': ['INTEGRATION_METADATA'], +} + +/** Modules, or directories ending in `/`, allowed every integration, relative to `apps/sim`. */ +const EXEMPT: Readonly> = { + 'app/(landing)/': 'public marketing site: lists the catalog to visitors, who have no policy', + 'app/sitemap.ts': 'public sitemap of the marketing integration pages', + 'app/workspace/[workspaceId]/integrations/[block]/page.tsx': + 'resolves the route slug to one integration; IntegrationBlockDetail gates it', + 'app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx': + 'maps integration names to slugs to rewrite prompt text; lists nothing', + 'app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts': + 'builder whose required `isUsable` the sidebar supplies from useIntegrationAccess', + 'ee/credential-groups/components/credential-group-provider-tile.tsx': + 'looks up one integration by type for its tile', +} + +interface SourceFile { + /** Relative to `apps/sim`, `/`-separated. */ + path: string + source: string +} + +interface IntegrationAccessViolation { + path: string + /** The raw catalog exports, or the fail-open policy, read without leave; empty for a stale exemption. */ + names: string[] + stale: boolean +} + +const NAMED_IMPORT = /import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g +const NAMESPACE_IMPORT = /import\s+\*\s+as\s+([\w$]+)\s+from\s*['"]([^'"]+)['"]/g + +/** One export of a module, and the expression the importing module reaches it through. */ +interface ExportReference { + exported: string + /** + * The local binding, or `namespace.member` for a member read; absent when a + * namespace escapes member access, where the read cannot be traced. + */ + local?: string +} + +/** + * The exports of `from` a module reaches as values, through named or namespace + * imports. A namespace used other than as `namespace.member` — destructured, + * indexed, or passed along — is taken to read every one of `exports`, so an + * untraceable read is never mistaken for none. + */ +function exportReferences( + source: string, + from: string, + exports: readonly string[] +): ExportReference[] { + const references: ExportReference[] = [] + for (const [, typeOnly, specifiers, module] of source.matchAll(NAMED_IMPORT)) { + if (module !== from || typeOnly) continue + for (const specifier of specifiers.split(',')) { + const [exported, local = exported] = specifier.trim().split(/\s+as\s+/) + if (exported && !exported.startsWith('type ')) references.push({ exported, local }) + } + } + for (const [declaration, namespace, module] of source.matchAll(NAMESPACE_IMPORT)) { + if (module !== from) continue + const binding = `(? ({ exported }))) + } + } + return references +} + +function isCalled(source: string, local: string | undefined): boolean { + if (local === undefined) return false + return new RegExp(`(?() + for (const [module, catalog] of Object.entries(RAW_CATALOGS)) { + for (const { exported } of exportReferences(source, module, catalog)) { + if (catalog.includes(exported)) names.add(exported) + } + } + return [...names] +} + +function exemptionFor(path: string, exempt: Readonly>): string | undefined { + return Object.keys(exempt).find((entry) => + entry.endsWith('/') ? path.startsWith(entry) : path === entry + ) +} + +export function findIntegrationAccessViolations( + files: readonly SourceFile[], + exempt: Readonly> +): IntegrationAccessViolation[] { + const violations: IntegrationAccessViolation[] = [] + const usedExemptions = new Set() + for (const { path, source } of files) { + const gateReferences = exportReferences(source, GATE_MODULE, [GATE_NAME, FAIL_OPEN_NAME]) + const readsFailOpen = gateReferences.some(({ exported }) => exported === FAIL_OPEN_NAME) + if (readsFailOpen && path !== FAIL_OPEN_READER) { + violations.push({ path, names: [FAIL_OPEN_NAME], stale: false }) + } + const names = rawCatalogReads(source) + if (names.length === 0) continue + const exemption = exemptionFor(path, exempt) + if (exemption) { + usedExemptions.add(exemption) + continue + } + const callsGate = gateReferences.some( + ({ exported, local }) => exported === GATE_NAME && isCalled(source, local) + ) + if (!callsGate) violations.push({ path, names, stale: false }) + } + for (const entry of Object.keys(exempt)) { + if (!usedExemptions.has(entry)) violations.push({ path: entry, names: [], stale: true }) + } + return violations +} + +function isScanned(path: string): boolean { + return ( + /\.tsx?$/.test(path) && + !/\.test\.tsx?$/.test(path) && + !path.endsWith('.server.ts') && + !path.startsWith('app/api/') + ) +} + +function collectSources(): SourceFile[] { + return SCAN_ROOTS.flatMap((root) => + readdirSync(join(APP_ROOT, root), { recursive: true, encoding: 'utf8' }) + .map((entry) => + relative(APP_ROOT, join(APP_ROOT, root, entry)) + .split('\\') + .join('/') + ) + .filter(isScanned) + .map((path) => ({ path, source: readFileSync(join(APP_ROOT, path), 'utf8') })) + ) +} + +function main() { + const violations = findIntegrationAccessViolations(collectSources(), EXEMPT) + if (violations.length === 0) { + console.log('✓ every integration listing calls useIntegrationAccess') + return + } + for (const violation of violations) { + if (violation.stale) { + console.error( + `✗ ${violation.path}: exempt in check-integration-access.ts but reads no raw catalog — remove the entry` + ) + } else if (violation.names.includes(FAIL_OPEN_NAME)) { + console.error( + `✗ apps/sim/${violation.path}: reads ${FAIL_OPEN_NAME}, which fails open while loading — only ${FAIL_OPEN_READER} may` + ) + } else { + console.error( + `✗ apps/sim/${violation.path}: reads ${violation.names.join(', ')} without calling ${GATE_NAME}` + ) + } + } + console.error( + `\nList integrations through ${GATE_NAME} (${GATE_MODULE}), which fails closed until the viewer's policy loads. A module that may see every integration belongs in EXEMPT with its reason.` + ) + process.exit(1) +} + +if (import.meta.main) main()