From ed05d9cb65ad02299336a305bddd34d1553157fa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 16:03:29 -0700 Subject: [PATCH 1/7] fix(integrations): hide integrations the permission group disallows --- .../[block]/integration-block-detail.tsx | 49 +++++++++++++++++++ .../integrations-showcase.tsx | 9 +++- .../showcase-with-explore.tsx | 4 +- .../integrations/integrations.tsx | 47 +++++++++++------- apps/sim/hooks/use-permission-config.test.tsx | 15 ++++++ apps/sim/hooks/use-permission-config.ts | 24 +++++++-- 6 files changed, 123 insertions(+), 25 deletions(-) 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..9bb5f17f6e7 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 @@ -5,6 +5,7 @@ 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 { @@ -16,6 +17,7 @@ import { 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,6 +44,8 @@ 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 { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -60,7 +64,52 @@ interface IntegrationBlockDetailProps { workspaceId: string } +/** + * Integration detail page, shown only when the viewer's integration policy + * permits the integration. A direct link to a restricted one renders an + * access-required state instead of the connect actions. + */ export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) { + const { isLoading, isBlockPermitted, isBlockRequestable } = usePermissionConfig() + const accessRequests = useWorkspaceAccessRequestFeatures() + + if (isLoading) return + if (isBlockPermitted(integration.type)) { + return + } + + const requestable = accessRequests.data?.enabled === true && isBlockRequestable(integration.type) + + return ( +
+
+ + Integrations + +
+ + ) : undefined + } + /> +
+ ) +} + +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..0bc6a13f781 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. */ + isBlockPermitted: (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({ isBlockPermitted }: IntegrationsShowcaseProps) { return (
{SHOWCASE_TILES.map((tile) => { const block = getBlock(tile.id) - if (!block) return null + if (!block || !isBlockPermitted(tile.id)) return null return (
- + {chatEnabled && ( (null) const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const { integrationAvailability } = usePermissionConfig() + const { integrationAvailability, isBlockPermitted } = usePermissionConfig() const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] = useQueryStates(integrationsParsers, integrationsUrlKeys) @@ -205,6 +205,22 @@ function IntegrationsContent() { }) }, [oauthCredentials]) + const permittedFeatured = useMemo( + () => FEATURED_INTEGRATIONS.filter((integration) => isBlockPermitted(integration.type)), + [isBlockPermitted] + ) + + const permittedCategorySections = useMemo( + () => + ALL_CATEGORY_SECTIONS.flatMap((section) => { + const integrations = section.integrations.filter((integration) => + isBlockPermitted(integration.type) + ) + return integrations.length > 0 ? [{ label: section.label, integrations }] : [] + }), + [isBlockPermitted] + ) + const setSelectedCategory = useCallback( (category: string) => { setIntegrationFilters({ category }) @@ -215,8 +231,8 @@ function IntegrationsContent() { const categoryOptions = [ ALL_CATEGORY, ...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []), - FEATURED_LABEL, - ...ALL_CATEGORY_SECTIONS.map((section) => section.label), + ...(permittedFeatured.length > 0 ? [FEATURED_LABEL] : []), + ...permittedCategorySections.map((section) => section.label), ] const isAllCategorySelected = selectedCategory === ALL_CATEGORY @@ -234,7 +250,7 @@ function IntegrationsContent() { integration.description.toLowerCase().includes(normalizedSearch) if (isFeaturedSelected) { - const items = FEATURED_INTEGRATIONS.filter(matchesSearch) + const items = permittedFeatured.filter(matchesSearch) return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : [] } @@ -243,27 +259,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 ? [] : permittedFeatured.filter(matchesCategory) const featuredSection = featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : [] - if (isAllCategorySelected) { - const rest = ALL_CATEGORY_SECTIONS.map((section) => ({ + const categorySections = permittedCategorySections + .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] - } - - const integrations = INTEGRATIONS.filter(matchesCategory) - .filter(matchesSearch) - .sort((a, b) => a.name.localeCompare(b.name)) + })) + .filter((section) => section.integrations.length > 0) - return [ - ...featuredSection, - ...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []), - ] + return [...featuredSection, ...categorySections] }, [ + permittedFeatured, + permittedCategorySections, isAllCategorySelected, isConnectedSelected, isFeaturedSelected, diff --git a/apps/sim/hooks/use-permission-config.test.tsx b/apps/sim/hooks/use-permission-config.test.tsx index 0edf6956149..2ce1b2fa18f 100644 --- a/apps/sim/hooks/use-permission-config.test.tsx +++ b/apps/sim/hooks/use-permission-config.test.tsx @@ -101,6 +101,21 @@ describe('usePermissionConfig deployment readiness', () => { expect(current!.isBlockRequestable('credential_group')).toBe(false) }) + it('judges policy permission independently of deployment OAuth readiness', () => { + mockUseUserPermissionConfig.mockReturnValue({ + data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['airtable'] } }, + isLoading: false, + }) + queryClient.setQueryData(integrationAvailabilityKeys.environments(), { + ...AVAILABILITY, + integrationAvailability: [{ type: 'airtable', state: 'unavailable', oauthAvailable: false }], + }) + render() + expect(current!.isBlockAllowed('airtable')).toBe(false) + expect(current!.isBlockPermitted('airtable')).toBe(true) + expect(current!.isBlockPermitted('slack')).toBe(false) + }) + it('does not offer a request before deployment availability resolves', () => { mockUseUserPermissionConfig.mockReturnValue({ data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] } }, diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 1ea386a0d11..6d97ef0332c 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -35,6 +35,14 @@ export interface PermissionConfigResult { filterBlocks: (blocks: T[]) => T[] filterProviders: (providerIds: string[]) => string[] isBlockAllowed: (blockType: string) => boolean + /** + * Whether the integration policy (permission group allowlist intersected with + * the deployment's `ALLOWED_INTEGRATIONS`) permits a block, regardless of + * whether this deployment has the block's OAuth client configured. Surfaces + * that show an unconfigured integration as unavailable, rather than hiding + * it, filter by this instead of {@link isBlockAllowed}. + */ + isBlockPermitted: (blockType: string) => boolean /** Presentation-only hint; request creation revalidates the deployed public catalog. */ isBlockRequestable: (blockType: string) => boolean /** @@ -138,6 +146,14 @@ export function usePermissionConfig(): PermissionConfigResult { [envAllowlistData?.oauthServiceAvailability] ) + const isBlockPermitted = useMemo(() => { + return (blockType: string) => { + if (isBlockTypeAccessControlExempt(blockType)) return true + if (allowedAccessControlTypes === null) return true + return allowedAccessControlTypes.has(resolveAccessControlBlockType(blockType.toLowerCase())) + } + }, [allowedAccessControlTypes]) + const isBlockAllowed = useMemo(() => { return (blockType: string) => { const normalizedBlockType = blockType.toLowerCase() @@ -152,11 +168,9 @@ export function usePermissionConfig(): PermissionConfigResult { ) { return false } - if (isBlockTypeAccessControlExempt(blockType)) return true - if (allowedAccessControlTypes === null) return true - return allowedAccessControlTypes.has(resolveAccessControlBlockType(normalizedBlockType)) + return isBlockPermitted(blockType) } - }, [hostContext?.features?.credentialGroups, integrationAvailability, allowedAccessControlTypes]) + }, [hostContext?.features?.credentialGroups, integrationAvailability, isBlockPermitted]) const isBlockRequestable = useMemo(() => { const deploymentAllowlist = intersectAccessControlAllowlists( @@ -241,6 +255,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, + isBlockPermitted, isBlockRequestable, isModelUsable, isToolAllowed, @@ -264,6 +279,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, + isBlockPermitted, isBlockRequestable, isModelUsable, isToolAllowed, From 5f7d6c9c0ca77f7165a095e96fc373d384b3ae3a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 16:12:19 -0700 Subject: [PATCH 2/7] fix(integrations): fall back from emptied categories and fail closed on detail gate --- .../[block]/integration-block-detail.tsx | 82 ++++++++++++++----- .../integrations/integrations.tsx | 16 +++- apps/sim/hooks/use-permission-config.test.tsx | 6 +- 3 files changed, 77 insertions(+), 27 deletions(-) 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 9bb5f17f6e7..d60e2b2b3d8 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,6 +1,6 @@ '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' @@ -67,19 +67,74 @@ interface IntegrationBlockDetailProps { /** * Integration detail page, shown only when the viewer's integration policy * permits the integration. A direct link to a restricted one renders an - * access-required state instead of the connect actions. + * access-required state instead of the connect actions, and the check fails + * closed until the deployment allowlist has loaded. */ export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) { - const { isLoading, isBlockPermitted, isBlockRequestable } = usePermissionConfig() + const { + isBlockPermitted, + isBlockRequestable, + isIntegrationAvailabilityReady, + integrationAvailabilityError, + refetchIntegrationAvailability, + } = usePermissionConfig() const accessRequests = useWorkspaceAccessRequestFeatures() - if (isLoading) return + if (integrationAvailabilityError) { + return ( + void refetchIntegrationAvailability()}>Try again} + /> + ) + } + if (!isIntegrationAvailabilityReady) { + return + } if (isBlockPermitted(integration.type)) { return } const requestable = accessRequests.data?.enabled === true && isBlockRequestable(integration.type) + return ( + + ) : undefined + } + /> + ) +} + +interface IntegrationAccessNoticeProps { + workspaceId: string + title: string + description: string + action?: ReactNode +} + +function IntegrationAccessNotice({ + workspaceId, + title, + description, + action, +}: IntegrationAccessNoticeProps) { return (
@@ -87,24 +142,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration Integrations
- - ) : undefined - } - /> +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 91b82f92d82..6fbff7ca975 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -152,8 +152,10 @@ function IntegrationsContent() { const workspaceId = (params?.workspaceId as string) || '' const { integrationAvailability, isBlockPermitted } = usePermissionConfig() - const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] = - useQueryStates(integrationsParsers, integrationsUrlKeys) + const [{ category: urlCategory, search: urlSearchTerm }, setIntegrationFilters] = useQueryStates( + integrationsParsers, + integrationsUrlKeys + ) /** * The input is controlled directly by the instant nuqs value; only the URL @@ -235,6 +237,16 @@ function IntegrationsContent() { ...permittedCategorySections.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 credentials load so the page does not flash All first. + */ + const selectedCategory = + categoryOptions.includes(urlCategory) || (urlCategory === CONNECTED_LABEL && credentialsLoading) + ? urlCategory + : ALL_CATEGORY + const isAllCategorySelected = selectedCategory === ALL_CATEGORY const isFeaturedSelected = selectedCategory === FEATURED_LABEL const isConnectedSelected = selectedCategory === CONNECTED_LABEL diff --git a/apps/sim/hooks/use-permission-config.test.tsx b/apps/sim/hooks/use-permission-config.test.tsx index 2ce1b2fa18f..e34cb917494 100644 --- a/apps/sim/hooks/use-permission-config.test.tsx +++ b/apps/sim/hooks/use-permission-config.test.tsx @@ -111,9 +111,9 @@ describe('usePermissionConfig deployment readiness', () => { integrationAvailability: [{ type: 'airtable', state: 'unavailable', oauthAvailable: false }], }) render() - expect(current!.isBlockAllowed('airtable')).toBe(false) - expect(current!.isBlockPermitted('airtable')).toBe(true) - expect(current!.isBlockPermitted('slack')).toBe(false) + expect(current.isBlockAllowed('airtable')).toBe(false) + expect(current.isBlockPermitted('airtable')).toBe(true) + expect(current.isBlockPermitted('slack')).toBe(false) }) it('does not offer a request before deployment availability resolves', () => { From 379bd5abbf059c1c48c4b1d24678f1abae975405 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 16:19:53 -0700 Subject: [PATCH 3/7] fix(integrations): fail closed on the catalog until the deployment allowlist loads --- .../[block]/integration-block-detail.tsx | 6 ++- .../showcase-with-explore.tsx | 8 ++- .../integrations/integrations.tsx | 53 +++++++++++++++---- 3 files changed, 52 insertions(+), 15 deletions(-) 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 d60e2b2b3d8..f660a9821b9 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 @@ -68,12 +68,14 @@ interface IntegrationBlockDetailProps { * Integration detail page, shown only when the viewer's integration policy * permits the integration. A direct link to a restricted one renders an * access-required state instead of the connect actions, and the check fails - * closed until the deployment allowlist has loaded. + * closed until both the permission group and the deployment allowlist have + * loaded. */ export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) { const { isBlockPermitted, isBlockRequestable, + isPermissionReady, isIntegrationAvailabilityReady, integrationAvailabilityError, refetchIntegrationAvailability, @@ -90,7 +92,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration /> ) } - if (!isIntegrationAvailabilityReady) { + if (!isPermissionReady || !isIntegrationAvailabilityReady) { return } if (isBlockPermitted(integration.type)) { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx index d96e62729b2..c9bfa3a6b83 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx @@ -27,12 +27,16 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) { const params = useParams() const router = useRouter() const { chatEnabled } = useDeploymentShape() - const { isBlockPermitted } = usePermissionConfig() + const { isBlockPermitted, isIntegrationAvailabilityReady } = usePermissionConfig() const workspaceId = (params?.workspaceId as string) || '' return (
- + + isIntegrationAvailabilityReady && isBlockPermitted(blockType) + } + /> {chatEnabled && ( (null) const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const { integrationAvailability, isBlockPermitted } = usePermissionConfig() + const { + integrationAvailability, + isBlockPermitted, + isIntegrationAvailabilityReady, + isIntegrationAvailabilityFetching, + integrationAvailabilityError, + refetchIntegrationAvailability, + } = usePermissionConfig() const [{ category: urlCategory, search: urlSearchTerm }, setIntegrationFilters] = useQueryStates( integrationsParsers, @@ -207,20 +217,30 @@ function IntegrationsContent() { }) }, [oauthCredentials]) + /** + * The catalog lists nothing until the deployment allowlist has loaded, so a + * pending or failed availability request never shows an integration the + * deployment's `ALLOWED_INTEGRATIONS` excludes. + */ const permittedFeatured = useMemo( - () => FEATURED_INTEGRATIONS.filter((integration) => isBlockPermitted(integration.type)), - [isBlockPermitted] + () => + isIntegrationAvailabilityReady + ? FEATURED_INTEGRATIONS.filter((integration) => isBlockPermitted(integration.type)) + : [], + [isIntegrationAvailabilityReady, isBlockPermitted] ) const permittedCategorySections = useMemo( () => - ALL_CATEGORY_SECTIONS.flatMap((section) => { - const integrations = section.integrations.filter((integration) => - isBlockPermitted(integration.type) - ) - return integrations.length > 0 ? [{ label: section.label, integrations }] : [] - }), - [isBlockPermitted] + isIntegrationAvailabilityReady + ? ALL_CATEGORY_SECTIONS.flatMap((section) => { + const integrations = section.integrations.filter((integration) => + isBlockPermitted(integration.type) + ) + return integrations.length > 0 ? [{ label: section.label, integrations }] : [] + }) + : [], + [isIntegrationAvailabilityReady, isBlockPermitted] ) const setSelectedCategory = useCallback( @@ -321,6 +341,7 @@ function IntegrationsContent() { ]) const showNoResults = + isIntegrationAvailabilityReady && Boolean(urlSearchTerm.trim() || !isAllCategorySelected) && filteredCategorySections.length === 0 && visibleConnectedItems.length === 0 @@ -380,6 +401,16 @@ function IntegrationsContent() { )} + {integrationAvailabilityError && ( + void refetchIntegrationAvailability()} + /> + )} + {filteredCategorySections.map((section) => ( {section.integrations.map((integration) => { From 951bf66cd34659432472766d49f111628cc67756 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 16:31:07 -0700 Subject: [PATCH 4/7] feat(integrations): offer requestable integrations in the catalog and gate @ mentions by policy --- .../available-resources.ts | 39 +++++++- .../organization-resource-inventory.test.ts | 16 +++ .../organization-resource-inventory.tsx | 10 +- .../integrations/integrations.tsx | 98 +++++++++++++------ apps/sim/hooks/use-permission-config.ts | 17 ++-- .../sim/lib/permission-groups/block-access.ts | 13 +++ 6 files changed, 149 insertions(+), 44 deletions(-) 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..9bd68ff2039 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,8 @@ import { useMemo, useSyncExternalStore } from 'react' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { subscribeDesktopPreferences } from '@/lib/desktop' +import { isBlockTypePermittedByAllowlist } from '@/lib/permission-groups/block-access' +import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integration-allowlist' 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 { @@ -10,9 +12,14 @@ import { import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' -import { listIntegrationsByPopularity } from '@/blocks/integration-matcher' +import { + type IntegrationDescriptor, + listIntegrationsByPopularity, +} from '@/blocks/integration-matcher' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { useWorkspaceDashboard } from '@/hooks/queries/dashboards' import { useFolders } from '@/hooks/queries/folders' +import { useIntegrationAvailability } from '@/hooks/queries/integration-availability' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useLogsList } from '@/hooks/queries/logs' import { useMothershipChats } from '@/hooks/queries/mothership-chats' @@ -93,6 +100,7 @@ interface UseAvailableResourcesOptions { /** Stable identity for the disabled result, so downstream memos never bust. */ const NO_RESOURCE_GROUPS: AvailableItemsByType[] = [] +const NO_INTEGRATIONS: readonly IntegrationDescriptor[] = [] const LOG_DROPDOWN_LIMIT = 50 @@ -178,6 +186,29 @@ export function useAvailableResources( const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, { enabled: enabled && Boolean(workspaceId), }) + const integrationsIncluded = !excludeTypes?.includes('integration') + const { data: permissionConfig, isPending: permissionConfigPending } = useUserPermissionConfig( + enabled && integrationsIncluded && workspaceId ? workspaceId : undefined + ) + const { data: integrationAvailability, isPending: integrationAvailabilityPending } = + useIntegrationAvailability() + + /** + * Integrations this workspace's policy permits: the viewer's permission group + * intersected with the deployment allowlist. Fails closed — nothing is offered + * until both have loaded — because the server drops a mention of an + * integration either policy disallows. + */ + const permittedIntegrations = useMemo(() => { + if (!permissionConfig || !integrationAvailability) return NO_INTEGRATIONS + const allowlist = intersectAccessControlAllowlists( + permissionConfig.config?.allowedIntegrations ?? null, + integrationAvailability.allowedIntegrations + ) + return listIntegrationsByPopularity().filter((integration) => + isBlockTypePermittedByAllowlist(allowlist, integration.blockType) + ) + }, [permissionConfig, integrationAvailability]) const { data: logsData, isPending: logsPending } = useLogsList( workspaceId, LOG_DROPDOWN_FILTERS, @@ -208,7 +239,8 @@ export function useAvailableResources( (!excludeTypes?.includes('knowledgebase') && knowledgeBaseFoldersPending))) || fileFoldersPending || tasksPending || - logsPending) + logsPending || + (integrationsIncluded && (permissionConfigPending || integrationAvailabilityPending))) const groups = useMemo(() => { if (!enabled) return NO_RESOURCE_GROUPS @@ -279,7 +311,7 @@ export function useAvailableResources( }, { type: 'integration' as const, - items: listIntegrationsByPopularity().map((integration) => ({ + items: permittedIntegrations.map((integration) => ({ id: integration.blockType, name: integration.name, iconComponent: integration.icon, @@ -355,6 +387,7 @@ export function useAvailableResources( knowledgeBases, tasks, logs, + permittedIntegrations, 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..87437badee1 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,22 @@ 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('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..4d13e162878 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 @@ -51,8 +51,14 @@ 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. + 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: [] } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 8e66fd34d2d..b70b6abd6b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ComponentType, useCallback, useMemo, useRef } from 'react' +import { type ComponentType, useCallback, useMemo, useRef, useState } from 'react' import { ChevronDown, ChipInput, @@ -11,6 +11,7 @@ import { DropdownMenuTrigger, Search, } from '@sim/emcn' +import { Lock } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' import { @@ -37,7 +38,11 @@ import { SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' +import { + PermissionAccessBoundary, + useWorkspaceAccessRequestFeatures, +} from '@/ee/access-requests/components/permission-access-boundary' +import { RequestAccessModal } from '@/ee/access-requests/components/request-access-action' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -74,6 +79,8 @@ interface IntegrationItemProps { description?: string | null icon: ComponentType<{ className?: string }> unavailable?: boolean + /** Set for an integration the viewer's policy restricts but lets them request. */ + onRequestAccess?: () => void } function IntegrationItem({ @@ -84,7 +91,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} /> ) @@ -156,11 +171,14 @@ function IntegrationsContent() { const { integrationAvailability, isBlockPermitted, + isBlockRequestable, isIntegrationAvailabilityReady, isIntegrationAvailabilityFetching, integrationAvailabilityError, refetchIntegrationAvailability, } = usePermissionConfig() + const accessRequestsEnabled = useWorkspaceAccessRequestFeatures().data?.enabled === true + const [requestedIntegration, setRequestedIntegration] = useState(null) const [{ category: urlCategory, search: urlSearchTerm }, setIntegrationFilters] = useQueryStates( integrationsParsers, @@ -218,29 +236,33 @@ function IntegrationsContent() { }, [oauthCredentials]) /** - * The catalog lists nothing until the deployment allowlist has loaded, so a - * pending or failed availability request never shows an integration the - * deployment's `ALLOWED_INTEGRATIONS` excludes. + * The catalog lists what the viewer's integration policy permits, plus — when + * access requests are on — what it restricts but lets them request, which + * renders as a locked row. It lists nothing until the deployment allowlist has + * loaded, so a pending or failed availability request never shows an + * integration the deployment's `ALLOWED_INTEGRATIONS` excludes. */ - const permittedFeatured = useMemo( - () => - isIntegrationAvailabilityReady - ? FEATURED_INTEGRATIONS.filter((integration) => isBlockPermitted(integration.type)) - : [], - [isIntegrationAvailabilityReady, isBlockPermitted] + const isListed = useMemo( + () => (blockType: string) => + isIntegrationAvailabilityReady && + (isBlockPermitted(blockType) || (accessRequestsEnabled && isBlockRequestable(blockType))), + [isIntegrationAvailabilityReady, isBlockPermitted, accessRequestsEnabled, isBlockRequestable] + ) + + const listedFeatured = useMemo( + () => FEATURED_INTEGRATIONS.filter((integration) => isListed(integration.type)), + [isListed] ) - const permittedCategorySections = useMemo( + const listedCategorySections = useMemo( () => - isIntegrationAvailabilityReady - ? ALL_CATEGORY_SECTIONS.flatMap((section) => { - const integrations = section.integrations.filter((integration) => - isBlockPermitted(integration.type) - ) - return integrations.length > 0 ? [{ label: section.label, integrations }] : [] - }) - : [], - [isIntegrationAvailabilityReady, isBlockPermitted] + 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( @@ -253,8 +275,8 @@ function IntegrationsContent() { const categoryOptions = [ ALL_CATEGORY, ...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []), - ...(permittedFeatured.length > 0 ? [FEATURED_LABEL] : []), - ...permittedCategorySections.map((section) => section.label), + ...(listedFeatured.length > 0 ? [FEATURED_LABEL] : []), + ...listedCategorySections.map((section) => section.label), ] /** @@ -282,7 +304,7 @@ function IntegrationsContent() { integration.description.toLowerCase().includes(normalizedSearch) if (isFeaturedSelected) { - const items = permittedFeatured.filter(matchesSearch) + const items = listedFeatured.filter(matchesSearch) return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : [] } @@ -291,11 +313,11 @@ 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 ? [] : permittedFeatured.filter(matchesCategory) + const featured = normalizedSearch ? [] : listedFeatured.filter(matchesCategory) const featuredSection = featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : [] - const categorySections = permittedCategorySections + const categorySections = listedCategorySections .filter((section) => isAllCategorySelected || section.label === selectedCategory) .map((section) => ({ label: section.label, @@ -305,8 +327,8 @@ function IntegrationsContent() { return [...featuredSection, ...categorySections] }, [ - permittedFeatured, - permittedCategorySections, + listedFeatured, + listedCategorySections, isAllCategorySelected, isConnectedSelected, isFeaturedSelected, @@ -349,6 +371,15 @@ function IntegrationsContent() { return (
+ {requestedIntegration && accessRequestsEnabled && ( + setRequestedIntegration(null)} + /> + )}
setRequestedIntegration(integration) + } /> ) })} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 6d97ef0332c..c852d45f776 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -8,7 +8,10 @@ import { isDeploymentGatedIntegrationType, resolveIntegrationAvailabilityStateForVisibility, } from '@/lib/integrations/availability' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + isBlockTypeAccessControlExempt, + isBlockTypePermittedByAllowlist, +} from '@/lib/permission-groups/block-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -146,13 +149,11 @@ export function usePermissionConfig(): PermissionConfigResult { [envAllowlistData?.oauthServiceAvailability] ) - const isBlockPermitted = useMemo(() => { - return (blockType: string) => { - if (isBlockTypeAccessControlExempt(blockType)) return true - if (allowedAccessControlTypes === null) return true - return allowedAccessControlTypes.has(resolveAccessControlBlockType(blockType.toLowerCase())) - } - }, [allowedAccessControlTypes]) + const isBlockPermitted = useMemo( + () => (blockType: string) => + isBlockTypePermittedByAllowlist(allowedAccessControlTypes, blockType), + [allowedAccessControlTypes] + ) const isBlockAllowed = useMemo(() => { return (blockType: string) => { diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index e18a2068ae1..55def56dbeb 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -43,6 +43,19 @@ export function isBlockTypeAccessControlExempt(blockType: string): boolean { return successor === blockType || successor === UNIVERSAL_ENTRY_POINT } +/** + * Whether a resolved integration allowlist permits `blockType`. The allowlist is + * in the successor-resolved vocabulary `toAccessControlAllowlist` and + * `intersectAccessControlAllowlists` produce; `null` is unrestricted. + */ +export function isBlockTypePermittedByAllowlist( + allowlist: ReadonlySet | null, + blockType: string +): boolean { + if (allowlist === null || isBlockTypeAccessControlExempt(blockType)) return true + return allowlist.has(resolveAccessControlBlockType(blockType.toLowerCase())) +} + /** * Whether `blockType` is a row in the Access Control editor's allowlist * universe — the set the editor materializes an allowlist from and compares From 47fc03c872adfccb803c38cfb072c9b96de519d0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 16:53:55 -0700 Subject: [PATCH 5/7] improvement(integrations): one access verdict for every integration listing, enforced by check:integration-access --- AGENTS.md | 2 + CLAUDE.md | 2 + .../mention/use-markdown-mentions.ts | 23 ++- .../available-resources.ts | 50 ++--- .../organization-resource-inventory.test.ts | 10 + .../organization-resource-inventory.tsx | 7 +- .../[block]/integration-block-detail.tsx | 36 ++-- .../integrations-showcase.tsx | 6 +- .../showcase-with-explore.tsx | 9 +- .../integrations/integrations.tsx | 77 ++++---- .../search-modal/integration-search-items.ts | 13 +- .../w/components/sidebar/sidebar.tsx | 28 ++- .../hooks/queries/integration-availability.ts | 3 +- apps/sim/hooks/use-integration-access.ts | 169 +++++++++++++++++ apps/sim/hooks/use-permission-config.test.tsx | 22 ++- apps/sim/hooks/use-permission-config.ts | 172 ++++------------- apps/sim/lib/integrations/access.test.ts | 66 +++++++ apps/sim/lib/integrations/access.ts | 108 +++++++++++ .../sim/lib/permission-groups/block-access.ts | 13 -- package.json | 1 + scripts/check-integration-access.test.ts | 70 +++++++ scripts/check-integration-access.ts | 179 ++++++++++++++++++ 22 files changed, 788 insertions(+), 278 deletions(-) create mode 100644 apps/sim/hooks/use-integration-access.ts create mode 100644 apps/sim/lib/integrations/access.test.ts create mode 100644 apps/sim/lib/integrations/access.ts create mode 100644 scripts/check-integration-access.test.ts create mode 100644 scripts/check-integration-access.ts 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 9bd68ff2039..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,8 +1,7 @@ import { useMemo, useSyncExternalStore } from 'react' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { subscribeDesktopPreferences } from '@/lib/desktop' -import { isBlockTypePermittedByAllowlist } from '@/lib/permission-groups/block-access' -import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integration-allowlist' +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 { @@ -12,14 +11,9 @@ import { import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' -import { - type IntegrationDescriptor, - listIntegrationsByPopularity, -} from '@/blocks/integration-matcher' -import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' +import { listIntegrationsByPopularity } from '@/blocks/integration-matcher' import { useWorkspaceDashboard } from '@/hooks/queries/dashboards' import { useFolders } from '@/hooks/queries/folders' -import { useIntegrationAvailability } from '@/hooks/queries/integration-availability' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useLogsList } from '@/hooks/queries/logs' import { useMothershipChats } from '@/hooks/queries/mothership-chats' @@ -28,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 @@ -100,7 +95,6 @@ interface UseAvailableResourcesOptions { /** Stable identity for the disabled result, so downstream memos never bust. */ const NO_RESOURCE_GROUPS: AvailableItemsByType[] = [] -const NO_INTEGRATIONS: readonly IntegrationDescriptor[] = [] const LOG_DROPDOWN_LIMIT = 50 @@ -187,28 +181,22 @@ export function useAvailableResources( enabled: enabled && Boolean(workspaceId), }) const integrationsIncluded = !excludeTypes?.includes('integration') - const { data: permissionConfig, isPending: permissionConfigPending } = useUserPermissionConfig( - enabled && integrationsIncluded && workspaceId ? workspaceId : undefined - ) - const { data: integrationAvailability, isPending: integrationAvailabilityPending } = - useIntegrationAvailability() + const integrationAccess = useIntegrationAccess(workspaceId, { + enabled: enabled && integrationsIncluded, + }) + const { getAccess: getIntegrationAccess } = integrationAccess /** - * Integrations this workspace's policy permits: the viewer's permission group - * intersected with the deployment allowlist. Fails closed — nothing is offered - * until both have loaded — because the server drops a mention of an - * integration either policy disallows. + * 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 permittedIntegrations = useMemo(() => { - if (!permissionConfig || !integrationAvailability) return NO_INTEGRATIONS - const allowlist = intersectAccessControlAllowlists( - permissionConfig.config?.allowedIntegrations ?? null, - integrationAvailability.allowedIntegrations - ) - return listIntegrationsByPopularity().filter((integration) => - isBlockTypePermittedByAllowlist(allowlist, integration.blockType) - ) - }, [permissionConfig, integrationAvailability]) + const usableIntegrations = useMemo( + () => + listIntegrationsByPopularity().filter((integration) => + isIntegrationUsable(getIntegrationAccess(integration.blockType)) + ), + [getIntegrationAccess] + ) const { data: logsData, isPending: logsPending } = useLogsList( workspaceId, LOG_DROPDOWN_FILTERS, @@ -240,7 +228,7 @@ export function useAvailableResources( fileFoldersPending || tasksPending || logsPending || - (integrationsIncluded && (permissionConfigPending || integrationAvailabilityPending))) + (integrationsIncluded && integrationAccess.isPending)) const groups = useMemo(() => { if (!enabled) return NO_RESOURCE_GROUPS @@ -311,7 +299,7 @@ export function useAvailableResources( }, { type: 'integration' as const, - items: permittedIntegrations.map((integration) => ({ + items: usableIntegrations.map((integration) => ({ id: integration.blockType, name: integration.name, iconComponent: integration.icon, @@ -387,7 +375,7 @@ export function useAvailableResources( knowledgeBases, tasks, logs, - permittedIntegrations, + 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 87437badee1..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 @@ -49,6 +49,16 @@ describe('organization mention inventory', () => { { 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 4d13e162878..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 @@ -52,7 +54,8 @@ export function mergeOrganizationResourceInventories( for (const group of inventory.groups) { if (group.type === 'browser' || group.type === 'terminal') continue // Integrations are not workspace-owned, but a permission group can target - // individual workspaces, so one is offered only where every workspace permits it. + // 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)) @@ -70,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 f660a9821b9..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 @@ -14,6 +14,7 @@ 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' @@ -47,6 +48,7 @@ import { 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' @@ -65,41 +67,33 @@ interface IntegrationBlockDetailProps { } /** - * Integration detail page, shown only when the viewer's integration policy - * permits the integration. A direct link to a restricted one renders an - * access-required state instead of the connect actions, and the check fails - * closed until both the permission group and the deployment allowlist have - * loaded. + * 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 { - isBlockPermitted, - isBlockRequestable, - isPermissionReady, - isIntegrationAvailabilityReady, - integrationAvailabilityError, - refetchIntegrationAvailability, - } = usePermissionConfig() + const { getAccess, availabilityError, refetchAvailability } = useIntegrationAccess(workspaceId) const accessRequests = useWorkspaceAccessRequestFeatures() + const access = getAccess(integration.type) - if (integrationAvailabilityError) { + if (availabilityError) { return ( void refetchIntegrationAvailability()}>Try again} + description={availabilityError.message} + action={ void refetchAvailability()}>Try again} /> ) } - if (!isPermissionReady || !isIntegrationAvailabilityReady) { - return - } - if (isBlockPermitted(integration.type)) { + if (access === undefined) return + if (isIntegrationPermitted(access)) { return } - const requestable = accessRequests.data?.enabled === true && isBlockRequestable(integration.type) + const requestable = access === 'requestable' && accessRequests.data?.enabled === true return ( boolean + isTileVisible: (blockType: string) => boolean } /** @@ -93,7 +93,7 @@ interface IntegrationsShowcaseProps { * 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({ isBlockPermitted }: IntegrationsShowcaseProps) { +export function IntegrationsShowcase({ isTileVisible }: IntegrationsShowcaseProps) { return (
{SHOWCASE_TILES.map((tile) => { const block = getBlock(tile.id) - if (!block || !isBlockPermitted(tile.id)) return null + if (!block || !isTileVisible(tile.id)) return null return (
- isIntegrationAvailabilityReady && isBlockPermitted(blockType) - } + isTileVisible={(blockType) => isIntegrationUsable(getAccess(blockType))} /> {chatEnabled && ( (null) @@ -210,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, @@ -233,20 +244,19 @@ function IntegrationsContent() { }, ] }) - }, [oauthCredentials]) + }, [oauthCredentials, isAccessReady, getAccess]) /** - * The catalog lists what the viewer's integration policy permits, plus — when - * access requests are on — what it restricts but lets them request, which - * renders as a locked row. It lists nothing until the deployment allowlist has - * loaded, so a pending or failed availability request never shows an - * integration the deployment's `ALLOWED_INTEGRATIONS` excludes. + * 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 = useMemo( - () => (blockType: string) => - isIntegrationAvailabilityReady && - (isBlockPermitted(blockType) || (accessRequestsEnabled && isBlockRequestable(blockType))), - [isIntegrationAvailabilityReady, isBlockPermitted, accessRequestsEnabled, isBlockRequestable] + const isListed = useCallback( + (blockType: string) => { + const access = getAccess(blockType) + return isIntegrationPermitted(access) || (access === 'requestable' && accessRequestsEnabled) + }, + [getAccess, accessRequestsEnabled] ) const listedFeatured = useMemo( @@ -282,10 +292,11 @@ function IntegrationsContent() { /** * 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 credentials load so the page does not flash All first. + * selected while its rows load so the page does not flash All first. */ const selectedCategory = - categoryOptions.includes(urlCategory) || (urlCategory === CONNECTED_LABEL && credentialsLoading) + categoryOptions.includes(urlCategory) || + (urlCategory === CONNECTED_LABEL && (credentialsLoading || !isAccessReady)) ? urlCategory : ALL_CATEGORY @@ -363,7 +374,7 @@ function IntegrationsContent() { ]) const showNoResults = - isIntegrationAvailabilityReady && + isAccessReady && Boolean(urlSearchTerm.trim() || !isAllCategorySelected) && filteredCategorySections.length === 0 && visibleConnectedItems.length === 0 @@ -432,13 +443,13 @@ function IntegrationsContent() { )} - {integrationAvailabilityError && ( + {availabilityError && ( void refetchIntegrationAvailability()} + isRetrying={isAvailabilityFetching} + onRetry={() => void refetchAvailability()} /> )} @@ -447,9 +458,7 @@ function IntegrationsContent() { {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) + access === 'requestable' + ? () => 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 e34cb917494..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,25 +105,23 @@ describe('usePermissionConfig deployment readiness', () => { expect(current!.isBlockRequestable('credential_group')).toBe(false) }) - it('judges policy permission independently of deployment OAuth readiness', () => { + it('withholds an access verdict until the permission group loads, while the legacy gate fails open', () => { mockUseUserPermissionConfig.mockReturnValue({ - data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['airtable'] } }, - isLoading: false, - }) - queryClient.setQueryData(integrationAvailabilityKeys.environments(), { - ...AVAILABILITY, - integrationAvailability: [{ type: 'airtable', state: 'unavailable', oauthAvailable: false }], + data: undefined, + isLoading: true, + isSuccess: false, }) + queryClient.setQueryData(integrationAvailabilityKeys.environments(), AVAILABILITY) render() - expect(current.isBlockAllowed('airtable')).toBe(false) - expect(current.isBlockPermitted('airtable')).toBe(true) - expect(current.isBlockPermitted('slack')).toBe(false) + 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 c852d45f776..06291749e6d 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -4,29 +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, - isBlockTypePermittedByAllowlist, -} 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 @@ -37,15 +26,12 @@ export interface PermissionConfigResult { isInPermissionGroup: boolean filterBlocks: (blocks: T[]) => T[] filterProviders: (providerIds: string[]) => string[] - isBlockAllowed: (blockType: string) => boolean /** - * Whether the integration policy (permission group allowlist intersected with - * the deployment's `ALLOWED_INTEGRATIONS`) permits a block, regardless of - * whether this deployment has the block's OAuth client configured. Surfaces - * that show an unconfigured integration as unavailable, rather than hiding - * it, filter by this instead of {@link isBlockAllowed}. + * 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. */ - isBlockPermitted: (blockType: string) => boolean + isBlockAllowed: (blockType: string) => boolean /** Presentation-only hint; request creation revalidates the deployed public catalog. */ isBlockRequestable: (blockType: string) => boolean /** @@ -63,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, @@ -80,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) { @@ -100,109 +86,27 @@ 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 isBlockPermitted = useMemo( + const isBlockRequestable = useMemo( () => (blockType: string) => - isBlockTypePermittedByAllowlist(allowedAccessControlTypes, blockType), - [allowedAccessControlTypes] + isIntegrationAccessReady && resolveIntegrationAccess(blockType, policy) === 'requestable', + [isIntegrationAccessReady, 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 - } - return isBlockPermitted(blockType) - } - }, [hostContext?.features?.credentialGroups, integrationAvailability, isBlockPermitted]) - - 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 isModelUsable = useMemo( () => createModelAccessGate({ @@ -256,15 +160,14 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isBlockPermitted, isBlockRequestable, isModelUsable, isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, - integrationAvailability, + integrationAvailability: policy.availability, oauthServiceAvailability, - isIntegrationAvailabilityLoading: isEnvAllowlistLoading, + isIntegrationAvailabilityLoading, isIntegrationAvailabilityFetching, isIntegrationAvailabilityReady, integrationAvailabilityError, @@ -280,15 +183,14 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isBlockPermitted, isBlockRequestable, isModelUsable, 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/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index 55def56dbeb..e18a2068ae1 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -43,19 +43,6 @@ export function isBlockTypeAccessControlExempt(blockType: string): boolean { return successor === blockType || successor === UNIVERSAL_ENTRY_POINT } -/** - * Whether a resolved integration allowlist permits `blockType`. The allowlist is - * in the successor-resolved vocabulary `toAccessControlAllowlist` and - * `intersectAccessControlAllowlists` produce; `null` is unrestricted. - */ -export function isBlockTypePermittedByAllowlist( - allowlist: ReadonlySet | null, - blockType: string -): boolean { - if (allowlist === null || isBlockTypeAccessControlExempt(blockType)) return true - return allowlist.has(resolveAccessControlBlockType(blockType.toLowerCase())) -} - /** * Whether `blockType` is a row in the Access Control editor's allowlist * universe — the set the editor materializes an allowlist from and compares 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..205e2ea0400 --- /dev/null +++ b/scripts/check-integration-access.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { findIntegrationAccessViolations } from './check-integration-access' + +const GATE = "import { useIntegrationAccess } from '@/hooks/use-integration-access'" + +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 namespace import of a raw catalog module', () => { + const source = "import * as metadata from '@sim/deployment-config/integration-metadata'" + expect(check({ 'ee/tile.tsx': source })).toHaveLength(1) + }) + + 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 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..61f8e5f22c2 --- /dev/null +++ b/scripts/check-integration-access.ts @@ -0,0 +1,179 @@ +#!/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 imports a raw catalog must also import + * `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' + +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, imported 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 + +/** The names a module imports as values from `from`, by their exported name. */ +function valueImports(source: string, from: string): string[] { + const names: string[] = [] + for (const [, typeOnly, specifiers, module] of source.matchAll(NAMED_IMPORT)) { + if (module !== from || typeOnly) continue + for (const specifier of specifiers.split(',')) { + const [imported] = specifier.trim().split(/\s+as\s+/) + if (imported && !imported.startsWith('type ')) names.push(imported) + } + } + return names +} + +/** The raw catalog exports a module imports as values. */ +function rawCatalogImports(source: string): string[] { + const names = Object.entries(RAW_CATALOGS).flatMap(([module, catalog]) => + valueImports(source, module).filter((name) => catalog.includes(name)) + ) + for (const [, from] of source.matchAll(NAMESPACE_IMPORT)) { + if (RAW_CATALOGS[from]) names.push(...RAW_CATALOGS[from]) + } + 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 gateImports = valueImports(source, GATE_MODULE) + if (gateImports.includes(FAIL_OPEN_NAME) && path !== FAIL_OPEN_READER) { + violations.push({ path, names: [FAIL_OPEN_NAME], stale: false }) + } + const names = rawCatalogImports(source) + if (names.length === 0) continue + const exemption = exemptionFor(path, exempt) + if (exemption) { + usedExemptions.add(exemption) + continue + } + if (!gateImports.includes(GATE_NAME)) 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 reads useIntegrationAccess') + return + } + for (const violation of violations) { + if (violation.stale) { + console.error( + `✗ ${violation.path}: exempt in check-integration-access.ts but imports no raw catalog — remove the entry` + ) + } else if (violation.names.includes(FAIL_OPEN_NAME)) { + console.error( + `✗ apps/sim/${violation.path}: imports ${FAIL_OPEN_NAME}, which fails open while loading — only ${FAIL_OPEN_READER} may` + ) + } else { + console.error( + `✗ apps/sim/${violation.path}: imports ${violation.names.join(', ')} without ${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() From 84a9b8246154be180a86adda5f2a2945c9f82c49 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 17:04:49 -0700 Subject: [PATCH 6/7] fix(integrations): require the access hook to be called and read namespace imports in check:integration-access --- scripts/check-integration-access.test.ts | 36 ++++++++++-- scripts/check-integration-access.ts | 73 ++++++++++++++++-------- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/scripts/check-integration-access.test.ts b/scripts/check-integration-access.test.ts index 205e2ea0400..511a4e61079 100644 --- a/scripts/check-integration-access.test.ts +++ b/scripts/check-integration-access.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { findIntegrationAccessViolations } from './check-integration-access' -const GATE = "import { useIntegrationAccess } from '@/hooks/use-integration-access'" +const GATE = `import { useIntegrationAccess } from '@/hooks/use-integration-access' +const { getAccess } = useIntegrationAccess(workspaceId)` function check(sources: Record, exempt: Record = {}) { return findIntegrationAccessViolations( @@ -28,9 +29,28 @@ describe('findIntegrationAccessViolations', () => { ]) }) - it('flags a namespace import of a raw catalog module', () => { - const source = "import * as metadata from '@sim/deployment-config/integration-metadata'" - expect(check({ 'ee/tile.tsx': source })).toHaveLength(1) + 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('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', () => { @@ -55,6 +75,14 @@ import { type listIntegrations } from '@/blocks/integration-matcher'` ).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([ diff --git a/scripts/check-integration-access.ts b/scripts/check-integration-access.ts index 61f8e5f22c2..15cfd32ea94 100644 --- a/scripts/check-integration-access.ts +++ b/scripts/check-integration-access.ts @@ -10,7 +10,7 @@ * one verdict those surfaces share; this audit makes reaching a raw catalog * without it a build failure. * - * The rule: a scanned module that imports a raw catalog must also import + * 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 @@ -25,6 +25,7 @@ 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 @@ -63,36 +64,54 @@ interface SourceFile { interface IntegrationAccessViolation { path: string - /** The raw catalog exports, or the fail-open policy, imported without leave; empty for a stale exemption. */ + /** 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 +const NAMESPACE_IMPORT = /import\s+\*\s+as\s+([\w$]+)\s+from\s*['"]([^'"]+)['"]/g -/** The names a module imports as values from `from`, by their exported name. */ -function valueImports(source: string, from: string): string[] { - const names: string[] = [] +/** 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 read through a namespace import. */ + local: string +} + +/** The exports of `from` a module reaches as values, through named or namespace imports. */ +function exportReferences(source: string, from: 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 [imported] = specifier.trim().split(/\s+as\s+/) - if (imported && !imported.startsWith('type ')) names.push(imported) + const [exported, local = exported] = specifier.trim().split(/\s+as\s+/) + if (exported && !exported.startsWith('type ')) references.push({ exported, local }) + } + } + for (const [, namespace, module] of source.matchAll(NAMESPACE_IMPORT)) { + if (module !== from) continue + const memberRead = new RegExp(`(? - valueImports(source, module).filter((name) => catalog.includes(name)) - ) - for (const [, from] of source.matchAll(NAMESPACE_IMPORT)) { - if (RAW_CATALOGS[from]) names.push(...RAW_CATALOGS[from]) +function isCalled(source: string, local: string): boolean { + return new RegExp(`(?() + for (const [module, catalog] of Object.entries(RAW_CATALOGS)) { + for (const { exported } of exportReferences(source, module)) { + if (catalog.includes(exported)) names.add(exported) + } } - return names + return [...names] } function exemptionFor(path: string, exempt: Readonly>): string | undefined { @@ -108,18 +127,22 @@ export function findIntegrationAccessViolations( const violations: IntegrationAccessViolation[] = [] const usedExemptions = new Set() for (const { path, source } of files) { - const gateImports = valueImports(source, GATE_MODULE) - if (gateImports.includes(FAIL_OPEN_NAME) && path !== FAIL_OPEN_READER) { + const gateReferences = exportReferences(source, GATE_MODULE) + 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 = rawCatalogImports(source) + const names = rawCatalogReads(source) if (names.length === 0) continue const exemption = exemptionFor(path, exempt) if (exemption) { usedExemptions.add(exemption) continue } - if (!gateImports.includes(GATE_NAME)) violations.push({ path, names, stale: false }) + 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 }) @@ -152,21 +175,21 @@ function collectSources(): SourceFile[] { function main() { const violations = findIntegrationAccessViolations(collectSources(), EXEMPT) if (violations.length === 0) { - console.log('✓ every integration listing reads useIntegrationAccess') + 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 imports no raw catalog — remove the entry` + `✗ ${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}: imports ${FAIL_OPEN_NAME}, which fails open while loading — only ${FAIL_OPEN_READER} may` + `✗ 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}: imports ${violation.names.join(', ')} without ${GATE_NAME}` + `✗ apps/sim/${violation.path}: reads ${violation.names.join(', ')} without calling ${GATE_NAME}` ) } } From bff7a4cdf7318fef65f1e25badb1deb06bcfd5d5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 17:14:48 -0700 Subject: [PATCH 7/7] fix(integrations): treat an untraceable namespace read as reading every export in check:integration-access --- scripts/check-integration-access.test.ts | 20 +++++++++++++ scripts/check-integration-access.ts | 37 +++++++++++++++++------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/scripts/check-integration-access.test.ts b/scripts/check-integration-access.test.ts index 511a4e61079..080bebffdcc 100644 --- a/scripts/check-integration-access.test.ts +++ b/scripts/check-integration-access.test.ts @@ -37,6 +37,26 @@ export const types = metadata.INTEGRATION_METADATA.map((entry) => entry.type)` ]) }) + 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'` diff --git a/scripts/check-integration-access.ts b/scripts/check-integration-access.ts index 15cfd32ea94..20a97d901a4 100644 --- a/scripts/check-integration-access.ts +++ b/scripts/check-integration-access.ts @@ -75,12 +75,24 @@ const NAMESPACE_IMPORT = /import\s+\*\s+as\s+([\w$]+)\s+from\s*['"]([^'"]+)['"]/ /** 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 read through a namespace import. */ - local: 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. */ -function exportReferences(source: string, from: string): ExportReference[] { +/** + * 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 @@ -89,17 +101,22 @@ function exportReferences(source: string, from: string): ExportReference[] { if (exported && !exported.startsWith('type ')) references.push({ exported, local }) } } - for (const [, namespace, module] of source.matchAll(NAMESPACE_IMPORT)) { + for (const [declaration, namespace, module] of source.matchAll(NAMESPACE_IMPORT)) { if (module !== from) continue - const memberRead = new RegExp(`(? ({ exported }))) + } } return references } -function isCalled(source: string, local: string): boolean { +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)) { + for (const { exported } of exportReferences(source, module, catalog)) { if (catalog.includes(exported)) names.add(exported) } } @@ -127,7 +144,7 @@ export function findIntegrationAccessViolations( const violations: IntegrationAccessViolation[] = [] const usedExemptions = new Set() for (const { path, source } of files) { - const gateReferences = exportReferences(source, GATE_MODULE) + 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 })