From 53ad61308ec5b79d0499e44fd83d7931ac375765 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 16 Sep 2026 11:42:55 -0700 Subject: [PATCH 1/2] feat(credential-groups): allowlist integrations by workspace --- .../workspace-access/route.ts | 2 +- .../settings/navigation.test.ts | 2 +- .../o/[organizationId]/settings/navigation.ts | 3 +- .../settings-sidebar.test.tsx | 7 +- .../settings-sidebar/settings-sidebar.tsx | 3 +- apps/sim/components/settings/navigation.ts | 8 +- .../organization-account-providers.test.tsx | 45 +- .../organization-account-providers.tsx | 6 +- ...nization-account-workspace-access.test.tsx | 214 ++++--- .../organization-account-workspace-access.tsx | 185 +++--- .../organization-connected-accounts.tsx | 20 +- ...rganization-workspace-grant-modal.test.tsx | 219 +++++++ .../organization-workspace-grant-modal.tsx | 153 +++++ .../sim/ee/credential-groups/search-params.ts | 7 + .../hooks/queries/organization-accounts.ts | 18 +- .../api/contracts/organization-accounts.ts | 15 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 20 +- .../application/authorization.test.ts | 23 +- .../application/authorization.ts | 27 +- .../application/list-credentials.test.ts | 35 +- .../application/list-credentials.ts | 39 +- .../application/list-mcp-connections.test.ts | 29 +- .../application/list-mcp-connections.ts | 20 +- .../application/organization-access.test.ts | 45 +- .../application/organization-access.ts | 40 +- .../organization-workspace-access.ts | 27 +- .../workspace-access-policy.test.ts | 138 ++++- .../application/workspace-access-policy.ts | 170 ++++-- .../workspace-organization-accounts.ts | 23 +- .../lib/credential-groups/credential-types.ts | 42 ++ .../lib/credential-groups/mcp-connections.ts | 9 + .../sim/lib/credential-groups/trigger.test.ts | 31 +- apps/sim/lib/credential-groups/trigger.ts | 18 +- .../lib/credential-groups/workspace-grants.ts | 42 ++ .../application/personal-connection.test.ts | 5 +- .../application/personal-credentials.test.ts | 8 + .../application/personal-credentials.ts | 9 +- .../resolve-personal-token.test.ts | 47 ++ .../application/resolve-personal-token.ts | 13 +- .../workspace-account-visibility.test.ts | 111 ++++ .../workspace-account-visibility.ts | 81 +++ apps/sim/lib/credentials/managed-mcp.ts | 15 +- apps/sim/lib/credentials/managed-oauth.ts | 7 + apps/sim/lib/credentials/personal-tokens.ts | 3 +- .../mcp/application/managed-auth-provider.ts | 15 +- .../application/managed-connections.test.ts | 7 +- .../mcp/application/managed-connections.ts | 13 +- .../conditions/credential-type.ts | 13 + .../resource-policies/conditions/registry.ts | 2 + .../lib/resource-policies/conditions/types.ts | 2 + apps/sim/lib/resource-policies/registry.ts | 1 + .../organization-section-access.test.ts | 7 +- .../organization-section-access.ts | 4 +- .../workspace-section-access.test.ts | 14 +- .../db/credential-group-resource-policies.ts | 103 ++-- ...credential_group_resource_policies.test.ts | 40 ++ ...check-tool-registry-boundary.baseline.json | 577 +++++++++--------- 57 files changed, 2117 insertions(+), 665 deletions(-) create mode 100644 apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx create mode 100644 apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx create mode 100644 apps/sim/ee/credential-groups/search-params.ts create mode 100644 apps/sim/lib/credential-groups/credential-types.ts create mode 100644 apps/sim/lib/credential-groups/workspace-grants.ts create mode 100644 apps/sim/lib/credentials/application/workspace-account-visibility.test.ts create mode 100644 apps/sim/lib/credentials/application/workspace-account-visibility.ts create mode 100644 apps/sim/lib/resource-policies/conditions/credential-type.ts diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts index 8ae3315a131..b63fce6598f 100644 --- a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts @@ -36,5 +36,5 @@ export const PUT = defineInternalJsonRoute({ errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), useCase: updateOrganizationAccountWorkspaceAccess, - present: ({ revision, workspaceIds }) => ({ revision, workspaceIds }), + present: ({ revision, grants }) => ({ revision, grants }), }) diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts index 94ec37b962a..bf92bfcb03f 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts @@ -33,7 +33,7 @@ describe('organization settings navigation', () => { it('uses Sources for administration when Search is available', () => { expect(organizationSettingsNavigation(true, enterprise, available)).toEqual( - ORGANIZATION_SETTINGS_ITEMS.filter(({ id }) => id !== 'connected-accounts') + ORGANIZATION_SETTINGS_ITEMS ) expect( organizationSettingsNavigation(true, enterprise, available).find( diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts index 0a96e8a0754..f559e4ae62e 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts @@ -71,8 +71,7 @@ export function organizationSettingsNavigation( ) { return ORGANIZATION_SETTINGS_ITEMS.filter( (item) => - (item.id !== 'connected-accounts' || - (availability.connectedAccounts && !availability.search)) && + (item.id !== 'connected-accounts' || availability.connectedAccounts) && ((item.id !== 'search-mcp' && item.id !== 'search-slack' && item.id !== 'integrations') || availability.search) && resolveOrganizationSectionAccess({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx index 7dbb6e5e041..93c385f93c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx @@ -212,7 +212,7 @@ describe('workspace SettingsSidebar organization rollout', () => { expect(workspaceLink('billing')).toHaveTextContent('Subscription') expect(workspaceLink('usage')).toHaveTextContent('Insights') expect(workspaceLink('sso')).toHaveTextContent('Single sign-on') - expect(workspaceLink('connected-accounts')).toHaveTextContent('Connected accounts') + expect(workspaceLink('connected-accounts')).toHaveTextContent('Credential Groups') expect(container.querySelector('a[href^="/o/"]')).toBeNull() expectWorkspaceLinks() } @@ -240,7 +240,10 @@ describe('workspace SettingsSidebar organization rollout', () => { expect(links).toHaveLength(1) expect(links[0]).toHaveAttribute('href', '/o/host-org/settings/members') expect(links[0]).toHaveTextContent('Organization') - for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) { + if (role === 'admin') + expect(workspaceLink('connected-accounts')).toHaveTextContent('Credential Groups') + else expect(workspaceLink('connected-accounts')).toBeNull() + for (const section of ['organization', 'billing', 'usage', 'sso']) { expect(workspaceLink(section)).toBeNull() } expectWorkspaceLinks() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 62e1e063a6f..bbe633e516a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -156,8 +156,7 @@ export function SettingsSidebar({ return Boolean( hostContext.hostOrganizationId && isOrgAdminOrOwner && - hostContext.features?.credentialGroups && - !hostContext.features?.organizationSearch + hostContext.features?.credentialGroups ) } if ( diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index e46615cf09f..abd3d5a4111 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -509,11 +509,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Connected accounts', + label: 'Credential Groups', icon: GridOffset, unified: { id: 'connected-accounts', - description: 'Manage accounts shared with your organization’s workflows.', + description: 'Manage integrations and workspace access for workflows and Chat.', group: 'organization', order: 1, organizationSection: 'connected-accounts', @@ -913,8 +913,8 @@ export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem ( OrganizationAccountPeople: () => null, })) vi.mock('@/ee/credential-groups/components/organization-account-workspace-access', () => ({ - OrganizationAccountWorkspaceAccess: () => null, + OrganizationAccountWorkspaceAccess: () =>
Workspaces
, })) import { OrganizationAccountProviders } from '@/ee/credential-groups/components/organization-account-providers' @@ -167,6 +167,33 @@ describe('organization provider configuration UI', () => { }) } + it('keeps workspace allowlists in the Access tab and preserves the integration controls', async () => { + mocks.accounts.mockReturnValue({ + data: { + canManage: true, + credentialGroup: { ...group, options: [gmail] }, + availableProviders: ['gmail'], + }, + }) + await act(async () => + root.render( + + + + ) + ) + expect(container.textContent).toContain('Update configurations') + expect(container.querySelector('[data-testid="workspace-access"]')).toBeNull() + await clickButton('Access') + expect(container.querySelector('[data-testid="workspace-access"]')).not.toBeNull() + expect(container.textContent).not.toContain('Update configurations') + expect(container.querySelector('[role="combobox"]')).toBeNull() + await clickButton('Integrations') + expect(container.textContent).toContain('Update configurations') + expect(container.querySelector('[data-testid="workspace-access"]')).toBeNull() + expect(mocks.update).not.toHaveBeenCalled() + }) + it('shows only added providers and searches the remaining catalog', async () => { await render([], [gmail]) expect(container.textContent).toContain('Gmail') @@ -175,7 +202,7 @@ describe('organization provider configuration UI', () => { expect(container.textContent).not.toMatch(/Ready|Setup required/) expect(container.textContent).not.toContain('Fireflies') expect(container.querySelector('[role="radio"]')).toBeNull() - await clickButton('Add provider') + await clickButton('Add integration') expect(document.querySelector('[aria-label="Add Gmail"]')).toBeNull() const search = document.querySelector('[aria-label="Search providers"]') await act(async () => { @@ -191,7 +218,7 @@ describe('organization provider configuration UI', () => { it('adds Fireflies directly without an empty configuration modal', async () => { await render([]) - await clickButton('Add provider') + await clickButton('Add integration') await clickButton('Add Fireflies') expect(mocks.add).toHaveBeenCalledWith( { organizationId: 'org-1', connectorId: 'fireflies' }, @@ -208,7 +235,7 @@ describe('organization provider configuration UI', () => { it('adds Gmail directly without opening indexing configuration', async () => { await render([]) - await clickButton('Add provider') + await clickButton('Add integration') await clickButton('Add Gmail') expect(mocks.update).toHaveBeenCalledWith( { @@ -341,7 +368,7 @@ describe('organization provider configuration UI', () => { it('surfaces an add failure in the catalog and does not open configuration', async () => { mocks.add.mockImplementation(() => {}) await render([]) - await clickButton('Add provider') + await clickButton('Add integration') await clickButton('Add Fireflies') mocks.addError = new Error('Could not add Fireflies') await render([]) @@ -373,7 +400,7 @@ describe('organization provider configuration UI', () => { it('returns an unfinished Databricks entry to the catalog until its configuration is saved', async () => { await render([provider]) expect(container.textContent).not.toContain('Databricks') - await clickButton('Add provider') + await clickButton('Add integration') await clickButton('Add Databricks') expect(mocks.add).not.toHaveBeenCalled() expect(mocks.addAsync).not.toHaveBeenCalled() @@ -393,7 +420,7 @@ describe('organization provider configuration UI', () => { it('cancels Databricks setup without adding a provider', async () => { await render([]) - await clickButton('Add provider') + await clickButton('Add integration') await clickButton('Add Databricks') expect(mocks.setup).toHaveBeenCalledWith('org-1', false) expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Add Databricks') @@ -413,7 +440,7 @@ describe('organization provider configuration UI', () => { it('adds Databricks with its complete configuration in one organization-scoped request', async () => { await render([]) - await clickButton('Add provider') + await clickButton('Add integration') await clickButton('Add Databricks') expect(mocks.addAsync).not.toHaveBeenCalled() await fill('Name', ' Analytics ') @@ -437,7 +464,7 @@ describe('organization provider configuration UI', () => { it('keeps Databricks configuration open after validation fails and allows correction', async () => { mocks.addAsync.mockRejectedValueOnce(new Error('Enter a valid Databricks MCP URL')) await render([]) - await clickButton('Add provider') + await clickButton('Add integration') await clickButton('Add Databricks') await fill('MCP URL', 'https://invalid.example.com/mcp') await fill('OAuth Client ID', 'client-1') diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx index 60ecdd1c5dc..d303f264094 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx @@ -159,7 +159,7 @@ export function OrganizationAccountProviders({ return (
{options.length > 0 && ( @@ -177,7 +177,7 @@ export function OrganizationAccountProviders({ setCatalogOpen(true) }} > - Add provider + Add integration
} @@ -223,7 +223,7 @@ export function OrganizationAccountProviders({ ))} {!rows.length && ( - Add a provider to start connecting accounts. + Add an integration to start connecting accounts. )} diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx index c8d8823f6c6..04e7cc23740 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx @@ -5,12 +5,11 @@ import { act, type ComponentProps, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { SettingsAction } from '@/components/settings/settings-header' import type { UpdateOrganizationAccountWorkspaceAccessBody, OrganizationAccountWorkspaceAccess as WorkspaceAccess, } from '@/lib/api/contracts/organization-accounts' -import type { CredentialGroupAddResourceModal } from '@/ee/credential-groups/components/credential-group-add-resource-modal' +import type { OrganizationWorkspaceGrantModal } from '@/ee/credential-groups/components/organization-workspace-grant-modal' const mocks = vi.hoisted(() => ({ useAccess: vi.fn(), @@ -18,7 +17,7 @@ const mocks = vi.hoisted(() => ({ reset: vi.fn(), mutationError: null as Error | null, isPending: false, - modal: null as ComponentProps | null, + grantModal: null as ComponentProps | null, toastError: vi.fn(), toastSuccess: vi.fn(), })) @@ -29,9 +28,10 @@ vi.mock('@sim/emcn', () => ({ {children} ), + ChipTag: ({ children }: { children: ReactNode }) => {children}, toast: { error: mocks.toastError, success: mocks.toastSuccess }, })) -vi.mock('@sim/emcn/icons', () => ({ Workspaces: () => null })) +vi.mock('@sim/emcn/icons', () => ({ Workspaces: () => null, Plus: () => null })) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccountWorkspaceAccess: mocks.useAccess, useUpdateOrganizationAccountWorkspaceAccess: () => ({ @@ -41,29 +41,20 @@ vi.mock('@/hooks/queries/organization-accounts', () => ({ isPending: mocks.isPending, }), })) -vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ - SettingsPanel: ({ - actions = [], - children, - }: { - actions?: SettingsAction[] - children: ReactNode - }) => ( -
- {actions.map((action) => ( - - ))} - {children} -
- ), -})) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-resource-row', () => ({ RESOURCE_LIST_STACK: '', - SettingsResourceRow: ({ title, trailing }: { title: string; trailing: ReactNode }) => ( + SettingsResourceRow: ({ + title, + trailing, + description, + }: { + title: string + trailing: ReactNode + description: ReactNode + }) => (
{title} + {description} {trailing}
), @@ -103,12 +94,12 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/row-actions-menu', () ), })) -vi.mock('@/ee/credential-groups/components/credential-group-add-resource-modal', () => ({ - CredentialGroupAddResourceModal: ( - props: ComponentProps +vi.mock('@/ee/credential-groups/components/organization-workspace-grant-modal', () => ({ + OrganizationWorkspaceGrantModal: ( + props: ComponentProps ) => { - mocks.modal = props - return
Add workspaces modal
+ mocks.grantModal = props + return
Manage workspace access modal
}, })) @@ -121,9 +112,24 @@ const WORKSPACES = [ ] const mountedRoots: Root[] = [] -function setAccess(workspaceIds = ['workspace-1'], revision = 3) { +function gmailGrants(workspaceIds: string[]): WorkspaceAccess['grants'] { + return workspaceIds.map((workspaceId) => ({ + workspaceId, + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + })) +} + +function setAccess(grants = gmailGrants(['workspace-1']), revision = 3) { mocks.useAccess.mockReturnValue({ - data: { workspaceIds, revision, workspaces: WORKSPACES } satisfies WorkspaceAccess, + data: { + grants, + revision, + workspaces: WORKSPACES, + credentialTypes: [ + { id: 'oauth:gmail', label: 'Gmail' }, + { id: 'oauth:google-calendar', label: 'Google Calendar' }, + ], + } satisfies WorkspaceAccess, error: null, }) } @@ -141,13 +147,9 @@ function renderAccess() { if (!match) throw new Error(`Button ${label} not found`) return match } - const add = async (ids: string[]) => { - act(() => button('Add workspaces').click()) - await act(async () => { - if (mocks.modal?.resourceType !== 'workspace') - throw new Error('Workspace picker is unavailable') - mocks.modal.onAdd(ids) - }) + const add = async (grant: WorkspaceAccess['grants'][number]) => { + act(() => button('Add workspace').click()) + await act(async () => mocks.grantModal?.onSave(grant)) } const rows = () => [...container.querySelectorAll('[data-workspace]')].map((row) => @@ -162,12 +164,12 @@ beforeEach(() => { vi.clearAllMocks() mocks.mutationError = null mocks.isPending = false - mocks.modal = null + mocks.grantModal = null setAccess() mocks.mutateAsync.mockImplementation( async (input: UpdateOrganizationAccountWorkspaceAccessBody) => { - setAccess(input.workspaceIds, input.revision + 1) - return { workspaceIds: input.workspaceIds, revision: input.revision + 1 } + setAccess(input.grants, input.revision + 1) + return { grants: input.grants, revision: input.revision + 1 } } ) }) @@ -178,94 +180,126 @@ afterEach(() => { }) }) -it('adds multiple workspaces immediately without Save or Discard actions', async () => { +it('adds a workspace with its chosen integrations and lists it with the existing grants', async () => { + setAccess([{ workspaceId: 'workspace-1', access: { mode: 'all' } }]) const editor = renderAccess() expect(editor.rows()).toEqual(['Finance']) - expect(editor.container.textContent).not.toMatch(/Save|Discard/) - - await editor.add(['workspace-3', 'workspace-2']) + expect(editor.container.textContent).toContain('All integrations') + const grant = { + workspaceId: 'workspace-2', + access: { mode: 'selected', credentialTypes: ['oauth:gmail', 'oauth:google-calendar'] }, + } satisfies WorkspaceAccess['grants'][number] + await editor.add(grant) editor.rerender() + if (mocks.grantModal?.mode !== 'create') throw new Error('Create modal not found') + expect(mocks.grantModal.workspaces).toEqual(WORKSPACES.slice(1)) + expect(mocks.mutateAsync).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + revision: 3, + grants: [{ workspaceId: 'workspace-1', access: { mode: 'all' } }, grant], + }) + expect(editor.rows()).toEqual(['Finance', 'Support']) + expect(editor.container.textContent).toContain('Gmail, Google Calendar') + expect(editor.container.textContent).not.toContain('Manage workspace access modal') +}) - expect(mocks.modal?.resources).toEqual(WORKSPACES.slice(1)) +it('adds an explicit All integrations grant', async () => { + const editor = renderAccess() + await editor.add({ workspaceId: 'workspace-2', access: { mode: 'all' } }) expect(mocks.mutateAsync).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-1', revision: 3, - workspaceIds: ['workspace-1', 'workspace-3', 'workspace-2'], + grants: [ + ...gmailGrants(['workspace-1']), + { workspaceId: 'workspace-2', access: { mode: 'all' } }, + ], }) - expect(editor.rows()).toEqual(['Finance', 'Support', 'Sales']) - expect(editor.container.textContent).not.toContain('Add workspaces modal') - expect(editor.container.textContent).not.toMatch(/Save|Discard/) }) -it('removes workspace access directly from the row action', async () => { - setAccess(['workspace-1', 'workspace-2']) +it('edits one workspace without changing other workspace grants', async () => { + setAccess(gmailGrants(['workspace-1', 'workspace-2'])) const editor = renderAccess() const finance = editor.container.querySelector('[data-workspace="Finance"]') if (!finance) throw new Error('Finance row not found') - await act(async () => editor.button('Remove', finance).click()) - editor.rerender() - + act(() => editor.button('Edit access', finance).click()) + if (mocks.grantModal?.mode !== 'edit') throw new Error('Edit modal not found') + expect(mocks.grantModal.grant).toEqual(gmailGrants(['workspace-1'])[0]) + const changed = { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:google-calendar'] }, + } satisfies WorkspaceAccess['grants'][number] + await act(async () => mocks.grantModal?.onSave(changed)) expect(mocks.mutateAsync).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-1', revision: 3, - workspaceIds: ['workspace-2'], + grants: [changed, ...gmailGrants(['workspace-2'])], }) - expect(editor.rows()).toEqual(['Support']) }) -it('does not change access when the add picker is cancelled', () => { +it('removes workspace access from the editor', async () => { const editor = renderAccess() - act(() => editor.button('Add workspaces').click()) - act(() => mocks.modal?.onClose()) + act(() => editor.button('Edit access').click()) + await act(async () => { + if (mocks.grantModal?.mode !== 'edit') throw new Error('Edit modal not found') + mocks.grantModal.onRemove() + }) + editor.rerender() + expect(mocks.mutateAsync).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + revision: 3, + grants: [], + }) + expect(editor.rows()).toEqual([]) + expect(editor.container.textContent).toContain('No workspaces have access') +}) +it('does not change access when adding a workspace is cancelled', () => { + const editor = renderAccess() + act(() => editor.button('Add workspace').click()) + act(() => mocks.grantModal?.onClose()) expect(mocks.mutateAsync).not.toHaveBeenCalled() expect(editor.rows()).toEqual(['Finance']) - expect(editor.container.textContent).not.toContain('Add workspaces modal') + expect(editor.container.textContent).not.toContain('Manage workspace access modal') }) -it('keeps the picker open and reports failed additions without changing the list', async () => { +it('keeps the editor open and reports failed additions without changing the list', async () => { const conflict = new Error('Workspace access changed while it was edited') mocks.mutateAsync.mockImplementation(async () => { mocks.mutationError = conflict throw conflict }) const editor = renderAccess() - await editor.add(['workspace-2', 'workspace-3']) + await editor.add(gmailGrants(['workspace-2'])[0]) editor.rerender() - expect(mocks.mutateAsync).toHaveBeenCalledOnce() expect(editor.rows()).toEqual(['Finance']) - expect(editor.container.textContent).toContain('Add workspaces modal') - expect(mocks.modal?.error).toBe(conflict.message) + expect(editor.container.textContent).toContain('Manage workspace access modal') + expect(mocks.grantModal?.error).toBe(conflict.message) expect(mocks.toastError).toHaveBeenCalledWith(conflict.message) expect(mocks.toastSuccess).not.toHaveBeenCalled() }) -it('disables access changes while a removal is in flight and preserves the row on failure', async () => { - let fail: ((error: Error) => void) | undefined - mocks.mutateAsync.mockImplementation(() => { - mocks.isPending = true - return new Promise((_, reject) => { - fail = reject - }) - }) - const editor = renderAccess() - act(() => editor.button('Remove').click()) - editor.rerender() - - expect(editor.button('Add workspaces').disabled).toBe(true) - expect(editor.rows()).toEqual(['Finance']) - expect(editor.container.textContent).not.toContain('Remove') +it.each(['create', 'edit'] as const)( + 'keeps the revision captured when the %s editor opened', + async (mode) => { + const editor = renderAccess() + act(() => editor.button(mode === 'create' ? 'Add workspace' : 'Edit access').click()) + setAccess(gmailGrants(['workspace-1']), 4) + editor.rerender() + await act(async () => + mocks.grantModal?.onSave(gmailGrants([mode === 'create' ? 'workspace-2' : 'workspace-1'])[0]) + ) + expect(mocks.mutateAsync).toHaveBeenCalledWith(expect.objectContaining({ revision: 3 })) + } +) - const error = new Error('Could not remove workspace access') - await act(async () => { - if (!fail) throw new Error('Request rejection is unavailable') - mocks.isPending = false - mocks.mutationError = error - fail(error) - }) +it('disables changes while saving and disables adding when all workspaces already have access', () => { + mocks.isPending = true + const editor = renderAccess() + expect(editor.button('Add workspace').disabled).toBe(true) + expect(editor.button('Edit access').disabled).toBe(true) + mocks.isPending = false + setAccess(gmailGrants(WORKSPACES.map((workspace) => workspace.id))) editor.rerender() - expect(editor.rows()).toEqual(['Finance']) - expect(editor.button('Remove').disabled).toBe(false) - expect(editor.container.textContent).toContain(error.message) + expect(editor.button('Add workspace').disabled).toBe(true) }) diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx index ac14e9c7570..bc29477405e 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx @@ -2,27 +2,30 @@ import { useState } from 'react' import { Chip, toast } from '@sim/emcn' -import { Workspaces } from '@sim/emcn/icons' +import { Plus, Workspaces } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import type { OrganizationAccountWorkspaceAccess as WorkspaceAccess } from '@/lib/api/contracts/organization-accounts' import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { CredentialGroupAddResourceModal } from '@/ee/credential-groups/components/credential-group-add-resource-modal' +import { OrganizationWorkspaceGrantModal } from '@/ee/credential-groups/components/organization-workspace-grant-modal' import { useOrganizationAccountWorkspaceAccess, useUpdateOrganizationAccountWorkspaceAccess, } from '@/hooks/queries/organization-accounts' +type Grant = WorkspaceAccess['grants'][number] +type GrantEditor = + | { mode: 'create'; revision: number } + | { mode: 'edit'; grant: Grant; revision: number } + interface OrganizationAccountWorkspaceAccessProps { organizationId: string } @@ -40,7 +43,8 @@ export function OrganizationAccountWorkspaceAccess({ onRetry={() => void access.refetch()} /> ) - if (!access.data) return null + if (!access.data) + return

Loading workspace access…

return ( [workspace.id, workspace])) - if (selected.size !== selectedIds.length) + const [editor, setEditor] = useState(null) + const byId = new Map(access.workspaces.map((workspace) => [workspace.id, workspace])) + const grantsById = new Map(access.grants.map((grant) => [grant.workspaceId, grant])) + const typesById = new Map(access.credentialTypes.map((type) => [type.id, type.label])) + if (grantsById.size !== access.grants.length) throw new Error('Workspace access contains duplicate workspaces') - for (const id of selectedIds) { - if (!workspacesById.has(id)) - throw new Error(`Workspace access references unavailable workspace ${id}`) + for (const grant of access.grants) { + if (!byId.has(grant.workspaceId)) + throw new Error(`Workspace access references unavailable workspace ${grant.workspaceId}`) + if (grant.access.mode === 'selected') { + for (const type of grant.access.credentialTypes) { + if (!typesById.has(type)) throw new Error(`Unknown credential type ${type}`) + } + } } - const allowedWorkspaces = access.workspaces.filter((workspace) => selected.has(workspace.id)) - const availableWorkspaces = access.workspaces.filter((workspace) => !selected.has(workspace.id)) + const allowedWorkspaces = access.workspaces.filter((workspace) => grantsById.has(workspace.id)) + const availableWorkspaces = access.workspaces.filter((workspace) => !grantsById.has(workspace.id)) - const updateAccess = async (workspaceIds: string[]) => { + const save = async (grants: WorkspaceAccess['grants'], revision: number) => { try { - await update.mutateAsync({ - organizationId, - revision: access.revision, - workspaceIds, - }) - setShowAddWorkspace(false) + await update.mutateAsync({ organizationId, revision, grants }) + setEditor(null) toast.success('Workspace access updated') } catch (error) { toast.error(getErrorMessage(error, 'Could not update workspace access')) } } + const saveGrant = (grant: Grant) => { + if (!editor) throw new Error('Workspace access editor is not open') + if (!byId.has(grant.workspaceId)) throw new Error('Selected workspace is unavailable') + if (editor.mode === 'create') { + if (grantsById.has(grant.workspaceId)) throw new Error('Workspace already has access') + if (access.grants.length >= ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + throw new Error( + `Workspace access cannot exceed ${ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT} workspaces` + ) + void save([...access.grants, grant], editor.revision) + } else { + if (grant.workspaceId !== editor.grant.workspaceId) + throw new Error('Cannot change the workspace of an existing grant') + void save( + access.grants.map((existing) => + existing.workspaceId === grant.workspaceId ? grant : existing + ), + editor.revision + ) + } + } return ( - + <> { - update.reset() - setShowAddWorkspace(true) - }} + leftAdornment={} disabled={ update.isPending || - availableWorkspaces.length === 0 || - selectedIds.length >= ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT + !availableWorkspaces.length || + access.grants.length >= ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } + onClick={() => { + update.reset() + setEditor({ mode: 'create', revision: access.revision }) + }} > - Add workspaces + Add workspace } > {update.error && ( -

+

{update.error.message}

)} - {allowedWorkspaces.length === 0 ? ( + {!allowedWorkspaces.length ? ( No workspaces have access ) : (
- {allowedWorkspaces.map((workspace) => ( - } - iconFilled - title={workspace.name} - description='Authorized workflows can use every connected account in this organization' - disabled={update.isPending} - trailing={ - update.isPending ? undefined : ( - - void updateAccess(selectedIds.filter((id) => id !== workspace.id)), - }, - ]} - /> - ) - } - /> - ))} + {allowedWorkspaces.map((workspace) => { + const grant = grantsById.get(workspace.id)! + return ( + } + iconFilled + title={workspace.name} + description={ + grant.access.mode === 'all' + ? 'All integrations' + : grant.access.credentialTypes + .map((type) => typesById.get(type)!) + .sort((a, b) => a.localeCompare(b)) + .join(', ') + } + trailing={ + { + update.reset() + setEditor({ mode: 'edit', grant, revision: access.revision }) + }} + > + Edit access + + } + /> + ) + })}
)}
- {showAddWorkspace && ( - + void save( + access.grants.filter((grant) => grant.workspaceId !== editor.grant.workspaceId), + editor.revision + ), + } as const))} + credentialTypes={access.credentialTypes} disabled={update.isPending} error={update.error?.message} - onAdd={(ids) => { - for (const id of ids) { - if (!workspacesById.has(id)) throw new Error(`Workspace ${id} is unavailable`) - if (selected.has(id)) throw new Error(`Workspace ${id} already has access`) - } - if (selectedIds.length + ids.length > ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) { - throw new Error( - `Workspace access cannot exceed ${ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT} workspaces` - ) - } - void updateAccess([...selectedIds, ...ids]) - }} - onClose={() => setShowAddWorkspace(false)} + onClose={() => setEditor(null)} + onSave={saveGrant} /> )} -
+ ) } diff --git a/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx b/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx index da3eef33092..bb85f8e25b9 100644 --- a/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx +++ b/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx @@ -1,17 +1,17 @@ 'use client' import { Chip, ChipSwitch } from '@sim/emcn' -import { parseAsStringLiteral, useQueryState } from 'nuqs' +import { useQueryStates } from 'nuqs' import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' import { OrganizationAccountProviders } from '@/ee/credential-groups/components/organization-account-providers' import { OrganizationAccountWorkspaceAccess } from '@/ee/credential-groups/components/organization-account-workspace-access' +import { credentialGroupsParsers } from '@/ee/credential-groups/search-params' import { useEnsureOrganizationAccounts, useOrganizationAccounts, } from '@/hooks/queries/organization-accounts' -const TABS = ['providers', 'people', 'workspace-access'] as const interface OrganizationConnectedAccountsProps { organizationId: string } @@ -21,13 +21,13 @@ export function OrganizationConnectedAccounts({ }: OrganizationConnectedAccountsProps) { const accounts = useOrganizationAccounts(organizationId) const ensure = useEnsureOrganizationAccounts() - const [tab, setTab] = useQueryState('tab', parseAsStringLiteral(TABS).withDefault('providers')) + const [{ tab }, setView] = useQueryStates(credentialGroupsParsers) const error = accounts.error ?? ensure.error if (error) return ( { ensure.reset() @@ -36,9 +36,9 @@ export function OrganizationConnectedAccounts({ /> ) if (!accounts.data) - return

Loading connected accounts…

+ return

Loading Credential Groups…

if (!accounts.data.canManage) - return

An organization admin manages connected accounts.

+ return

An organization admin manages Credential Groups.

const group = accounts.data.credentialGroup if (!group) return ( @@ -53,7 +53,7 @@ export function OrganizationConnectedAccounts({ disabled={ensure.isPending} onClick={() => ensure.mutate({ organizationId })} > - Set up connected accounts + Set up Credential Groups @@ -63,11 +63,11 @@ export function OrganizationConnectedAccounts({
void setTab(value)} + onChange={(value) => void setView({ tab: value })} options={[ - { value: 'providers', label: 'Providers' }, + { value: 'providers', label: 'Integrations' }, { value: 'people', label: 'People' }, - { value: 'workspace-access', label: 'Workspace access' }, + { value: 'workspace-access', label: 'Access' }, ]} />
diff --git a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx new file mode 100644 index 00000000000..e2c01a12cb2 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx @@ -0,0 +1,219 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrganizationAccountWorkspaceAccess } from '@/lib/api/contracts/organization-accounts' +import { OrganizationWorkspaceGrantModal } from '@/ee/credential-groups/components/organization-workspace-grant-modal' + +describe('workspace integration grant editor', () => { + let root: Root + let container: HTMLDivElement + const save = vi.fn() + const close = vi.fn() + const remove = vi.fn() + const credentialTypes = [ + { id: 'oauth:gmail', label: 'Gmail' }, + { id: 'oauth:google-calendar', label: 'Google Calendar' }, + ] satisfies OrganizationAccountWorkspaceAccess['credentialTypes'] + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + async function render( + access: OrganizationAccountWorkspaceAccess['grants'][number]['access'] | null = { mode: 'all' }, + disabled = false + ) { + await act(async () => + root.render( + + ) + ) + } + function button(label: string) { + const result = [...document.querySelectorAll('button')].find( + (element) => element.textContent === label + ) + if (!result) throw new Error(`Missing ${label} button`) + return result + } + function integrationOption(label: string) { + const result = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === label + ) + if (!result) throw new Error(`Missing ${label} option`) + return result + } + async function openIntegrations() { + const trigger = document.querySelector('[aria-label="Integrations"]') + expect(trigger).not.toBeNull() + await act(async () => + trigger?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + } + async function closeIntegrations() { + await act(async () => + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + ) + } + async function click(label: string) { + await act(async () => button(label).click()) + } + async function selectWorkspace() { + const trigger = button('Select workspace') + await act(async () => + trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + const option = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === 'Finance' + ) + expect(option).toBeDefined() + await act(async () => option?.click()) + } + + it('creates a workspace grant only after selecting a workspace and integrations', async () => { + await render(null) + expect(button('Add workspace').disabled).toBe(true) + await selectWorkspace() + expect(button('Add workspace').disabled).toBe(true) + await openIntegrations() + expect(document.querySelector('[role="menuitem"]')?.textContent).toBe('All integrations') + await act(async () => integrationOption('Gmail').click()) + await closeIntegrations() + expect(button('Add workspace').disabled).toBe(false) + await click('Add workspace') + expect(save).toHaveBeenCalledExactlyOnceWith({ + workspaceId: 'finance', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }) + }) + + it('allows explicitly granting all current and future integrations', async () => { + await render(null) + await selectWorkspace() + await openIntegrations() + await act(async () => integrationOption('All integrations').click()) + await closeIntegrations() + expect(document.body.textContent).toContain('Includes integrations added in the future.') + await click('Add workspace') + expect(save).toHaveBeenCalledExactlyOnceWith({ + workspaceId: 'finance', + access: { mode: 'all' }, + }) + }) + + it('narrows broad access when a specific integration is selected', async () => { + await render() + await openIntegrations() + expect(document.querySelector('[role="menuitem"]')?.textContent).toBe('All integrations') + await act(async () => integrationOption('Gmail').click()) + await closeIntegrations() + await click('Save access') + expect(save).toHaveBeenCalledExactlyOnceWith({ + workspaceId: 'finance', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }) + }) + + it('does not implicitly grant future integrations when every individual integration is selected', async () => { + await render({ mode: 'selected', credentialTypes: ['oauth:gmail'] }) + await openIntegrations() + await act(async () => integrationOption('Google Calendar').click()) + await closeIntegrations() + await click('Save access') + expect(save).toHaveBeenCalledExactlyOnceWith({ + workspaceId: 'finance', + access: { mode: 'selected', credentialTypes: ['oauth:gmail', 'oauth:google-calendar'] }, + }) + }) + + it('replaces individual selections with an explicit all-integration grant', async () => { + await render({ mode: 'selected', credentialTypes: ['oauth:gmail'] }) + await openIntegrations() + await act(async () => integrationOption('All integrations').click()) + await closeIntegrations() + await click('Save access') + expect(save).toHaveBeenCalledExactlyOnceWith({ + workspaceId: 'finance', + access: { mode: 'all' }, + }) + }) + + it.each(['all', 'selected'] as const)( + 'does not treat clearing the last %s selection as unrestricted access', + async (mode) => { + await render(mode === 'all' ? { mode } : { mode, credentialTypes: ['oauth:gmail'] }) + await openIntegrations() + await act(async () => + integrationOption(mode === 'all' ? 'All integrations' : 'Gmail').click() + ) + await closeIntegrations() + expect(button('Save access').disabled).toBe(true) + expect(save).not.toHaveBeenCalled() + } + ) + + it('preserves saved selections while searching and cancels without saving', async () => { + await render({ mode: 'selected', credentialTypes: ['oauth:gmail'] }) + await openIntegrations() + const search = document.querySelector( + 'input[placeholder="Search integrations"]' + ) + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + search, + 'calendar' + ) + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect( + [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent) + ).toEqual(['Google Calendar']) + await act(async () => integrationOption('Google Calendar').click()) + await closeIntegrations() + await click('Save access') + expect(save).toHaveBeenLastCalledWith({ + workspaceId: 'finance', + access: { mode: 'selected', credentialTypes: ['oauth:gmail', 'oauth:google-calendar'] }, + }) + save.mockClear() + await click('Cancel') + expect(close).toHaveBeenCalledOnce() + expect(save).not.toHaveBeenCalled() + }) + + it('removes access explicitly and disables mutations while a request is pending', async () => { + await render() + await click('Remove access') + expect(remove).toHaveBeenCalledOnce() + expect(save).not.toHaveBeenCalled() + await render({ mode: 'all' }, true) + expect(button('Save access').disabled).toBe(true) + expect(button('Remove access').disabled).toBe(true) + expect(button('Cancel').disabled).toBe(true) + expect(document.querySelector('[aria-label="Integrations"]')?.disabled).toBe( + true + ) + }) +}) diff --git a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx new file mode 100644 index 00000000000..84118767f1d --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useState } from 'react' +import { + ChipDropdown, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + ChipSelect, +} from '@sim/emcn' +import type { OrganizationAccountWorkspaceAccess } from '@/lib/api/contracts/organization-accounts' +import { isOrganizationCredentialType } from '@/lib/credential-groups/credential-types' + +type Grant = OrganizationAccountWorkspaceAccess['grants'][number] +const ALL_INTEGRATIONS = 'all' + +type OrganizationWorkspaceGrantModalProps = { + credentialTypes: OrganizationAccountWorkspaceAccess['credentialTypes'] + disabled: boolean + error?: string + onSave: (grant: Grant) => void + onClose: () => void +} & ( + | { mode: 'create'; workspaces: OrganizationAccountWorkspaceAccess['workspaces'] } + | { mode: 'edit'; grant: Grant; workspaceName: string; onRemove: () => void } +) + +export function OrganizationWorkspaceGrantModal(props: OrganizationWorkspaceGrantModalProps) { + const { credentialTypes, disabled, error, onSave, onClose } = props + const [workspaceId, setWorkspaceId] = useState( + props.mode === 'edit' ? props.grant.workspaceId : '' + ) + const [access, setAccess] = useState( + props.mode === 'edit' ? props.grant.access : { mode: 'selected', credentialTypes: [] } + ) + const title = props.mode === 'create' ? 'Add workspace' : `Edit ${props.workspaceName} access` + const save = () => { + if (!workspaceId) throw new Error('Select a workspace before granting access') + if ( + props.mode === 'create' && + !props.workspaces.some((workspace) => workspace.id === workspaceId) + ) + throw new Error('Selected workspace is unavailable') + if (access.mode === 'selected' && !access.credentialTypes.length) + throw new Error('Select at least one integration') + onSave({ workspaceId, access }) + } + + return ( + !open && !disabled && onClose()} + > + + {title} + + + {props.mode === 'create' && ( + + {(aria) => ( + ({ + value: workspace.id, + label: workspace.name, + }))} + value={workspaceId} + onChange={setWorkspaceId} + placeholder='Select workspace' + aria-label='Workspace' + searchable + searchPlaceholder='Search workspaces' + disabled={disabled} + fullWidth + dropdownWidth='trigger' + align='start' + {...aria} + /> + )} + + )} + + {(aria) => ( + ({ value: type.id, label: type.label })), + ]} + value={access.mode === 'all' ? [ALL_INTEGRATIONS] : access.credentialTypes} + onChange={(values) => { + if (access.mode !== 'all' && values.includes(ALL_INTEGRATIONS)) { + setAccess({ mode: 'all' }) + return + } + const selected = values.filter((value) => value !== ALL_INTEGRATIONS) + if (!selected.every(isOrganizationCredentialType)) + throw new Error('Unknown credential type') + setAccess({ mode: 'selected', credentialTypes: selected }) + }} + allLabel='Select integrations' + aria-label='Integrations' + showAllOption={false} + searchable + searchPlaceholder='Search integrations' + disabled={disabled} + fullWidth + matchTriggerWidth + align='start' + {...aria} + /> + )} + + {error} + + + + ) +} diff --git a/apps/sim/ee/credential-groups/search-params.ts b/apps/sim/ee/credential-groups/search-params.ts new file mode 100644 index 00000000000..c720460dca3 --- /dev/null +++ b/apps/sim/ee/credential-groups/search-params.ts @@ -0,0 +1,7 @@ +import { parseAsStringLiteral } from 'nuqs/server' + +export const credentialGroupsParsers = { + tab: parseAsStringLiteral(['providers', 'people', 'workspace-access'] as const).withDefault( + 'providers' + ), +} diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 733c3f03927..60bc27d401b 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -38,7 +38,9 @@ import { updateOrganizationAccountsContract, updateOrganizationAccountWorkspaceAccessContract, } from '@/lib/api/contracts/organization-accounts' +import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' import { slackSearchKeys } from '@/hooks/queries/slack-search' +import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' @@ -145,6 +147,8 @@ export function useConfigureOrganizationMcp() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), ]), }) } @@ -171,6 +175,8 @@ export function useUpdateOrganizationAccounts() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), queryClient.invalidateQueries({ queryKey: slackSearchKeys.organizationManifests(organizationId), }), @@ -205,9 +211,13 @@ export function useWorkspaceOrganizationAccounts(workspaceId?: string, enabled = }, }) } -export function useOrganizationAccountWorkspaceAccess(organizationId: string) { +export function useOrganizationAccountWorkspaceAccess( + organizationId: string, + options?: { enabled?: boolean } +) { return useQuery({ queryKey: organizationAccountsKeys.access(organizationId), + enabled: Boolean(organizationId) && (options?.enabled ?? true), staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, queryFn: ({ signal }) => requestJson(getOrganizationAccountWorkspaceAccessContract, { @@ -233,6 +243,8 @@ export function useUpdateOrganizationAccountWorkspaceAccess() { queryKey: organizationAccountsKeys.access(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), ]), }) } @@ -345,6 +357,8 @@ export function useAddOrganizationAccountMcpProvider() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), ]), }) } @@ -367,6 +381,8 @@ export function useRemoveOrganizationAccountMcpProvider() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), ]), }) } diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index 7b08459e082..b38c3aaad1e 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -16,11 +16,16 @@ import { } from '@/lib/api/contracts/credential-groups' import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types' import { ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT, ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT, ORGANIZATION_VIEWER_ACCOUNT_LIMIT, } from '@/lib/credential-groups/limits' +import { + organizationAccountWorkspaceGrantsSchema, + organizationCredentialTypeSchema, +} from '@/lib/credential-groups/workspace-grants' const organizationAccountsParamsSchema = z.object({ id: organizationIdSchema }) const organizationCredentialGroupSchema = credentialGroupSchema.extend({ @@ -131,10 +136,7 @@ export type UpdateOrganizationAccountsBody = z.input new Set(ids).size === ids.length, 'Workspace IDs must be unique'), + grants: organizationAccountWorkspaceGrantsSchema, }) export const getOrganizationAccountWorkspaceAccessContract = defineRouteContract({ method: 'GET', @@ -143,6 +145,11 @@ export const getOrganizationAccountWorkspaceAccessContract = defineRouteContract response: { mode: 'json', schema: organizationAccountWorkspaceAccessSchema.extend({ + credentialTypes: z + .array( + z.object({ id: organizationCredentialTypeSchema, label: z.string().min(1).max(256) }) + ) + .max(ORGANIZATION_CREDENTIAL_TYPES.length), workspaces: z .array(z.object({ id: workspaceIdSchema, name: z.string().max(256) })) .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT), diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 7f5a55a89ed..03339aac020 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -18,6 +18,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { createCopilotChatPrincipal } from '@/lib/copilot/auth/application-delegation' import { buildWorkspaceContextMd, buildWorkspaceMd, @@ -116,10 +117,11 @@ import { } from '@/lib/core/config/env-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { CredentialGroupRecord } from '@/lib/credential-groups/types' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, - getEnrolledManagedOAuthCredentials, } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' @@ -3158,7 +3160,21 @@ export class WorkspaceVFS { getAccessibleOAuthCredentials(workspaceId, userId, { isWorkspaceAdmin }).then( async (accessible) => [ ...accessible, - ...(await getEnrolledManagedOAuthCredentials(workspaceId, userId)), + ...( + await listPersonalCredentials.execute({ + principal: createCopilotChatPrincipal( + { workspaceId, userId }, + CREDENTIAL_DELEGATION_AUDIENCE + ), + input: { workspaceId }, + }) + ).credentials + .filter((entry) => entry.type === 'managed_oauth') + .map((entry) => ({ + ...entry, + type: 'managed_oauth' as const, + role: 'member' as const, + })), ] ), listApiKeys(workspaceId), diff --git a/apps/sim/lib/credential-groups/application/authorization.test.ts b/apps/sim/lib/credential-groups/application/authorization.test.ts index 25b2d806c86..8638bed8885 100644 --- a/apps/sim/lib/credential-groups/application/authorization.test.ts +++ b/apps/sim/lib/credential-groups/application/authorization.test.ts @@ -48,6 +48,7 @@ const context = { organizationId: 'org-1', allowPersonalApiKeys: true, credentialId: 'credential-1', + credentialType: 'oauth:gmail' as const, credentialGroupId: 'group-1', credentialGroupEnrollmentId: 'enrollment-1', } @@ -69,7 +70,10 @@ function storedPolicy(workspaceIds: string[] = ['workspace-1']) { id: 'policy-1', organizationId: 'org-1', revision: 1, - document: buildOrganizationAccountAccessPolicy('group-1', workspaceIds), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + workspaceIds.map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), } } @@ -250,6 +254,23 @@ describe('requireCredentialGroupCredentialAccess', () => { ).rejects.toThrow('Reconnect this account') }) + it.each([executorPrincipal, copilotPrincipal])( + 'rechecks the canonical integration even when the workspace still has other grants', + async (makePrincipal) => { + await expect(requireAccess(makePrincipal())).resolves.toBeUndefined() + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:google-calendar'] }, + }, + ]), + }) + await expect(requireAccess(makePrincipal())).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.requirePolicy).toHaveBeenCalledTimes(2) + } + ) + it('rechecks the org feature flag before credential use', async () => { mocks.isAvailable.mockResolvedValue(false) await expect(requireAccess(executorPrincipal())).rejects.toMatchObject({ code: 'not_found' }) diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts index 13a51696fd6..2d6b92d3379 100644 --- a/apps/sim/lib/credential-groups/application/authorization.ts +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -15,6 +15,10 @@ import { credentialGroupWorkflowAccessPolicyCodec, evaluateCredentialGroupActorCredentialAccess, } from '@/lib/credential-groups/application/workflow-access-policy' +import { + isOrganizationCredentialType, + type OrganizationCredentialType, +} from '@/lib/credential-groups/credential-types' import type { CredentialGroupCredentialListContext, ManagedCredentialGroupBinding, @@ -165,10 +169,19 @@ export async function requireCredentialGroupCredentialAccess( principal: Principal, context: CredentialGroupAuthorizationContext & { credentialId: string + credentialType: OrganizationCredentialType credentialGroupEnrollmentId: string }, resourcePolicy: ResourcePolicyBindingFor<'credential_group'> ): Promise { + if (principal.kind === 'delegated' && principal.serviceId === 'copilot') { + const subject = resolvePrincipalSubject(principal) + if (subject?.kind !== 'sim_user' || !subject.userId) { + throw new OrchestrationError('forbidden', 'Credential Group actor access required') + } + } else { + requireCredentialGroupWorkflowActor(principal) + } /** * A managed OAuth credential is usable only while its credential, enrollment, * option, and group are all live, whoever is using it: an admin disabling the @@ -179,21 +192,23 @@ export async function requireCredentialGroupCredentialAccess( if (binding && !isManagedCredentialGroupBindingLive(binding)) { throw new OrchestrationError('forbidden', 'Credential Group credential access denied') } + if (context.organizationId) { + if (!isOrganizationCredentialType(context.credentialType)) + throw new Error('Organization credential access requires a canonical credential type') + await requireOrganizationAccountsWorkspaceAccess( + { ...context, organizationId: context.organizationId }, + context.credentialType + ) + } if (principal.kind === 'delegated' && principal.serviceId === 'copilot') { return requireCredentialGroupActorCredentialAccess(principal, context, binding, resourcePolicy) } - requireCredentialGroupWorkflowActor(principal) - requireCurrentWorkflow(principal) if (!context.organizationId) { throw new OrchestrationError( 'forbidden', 'Reconnect this account in organization settings and replace the legacy Connected Accounts block' ) } - await requireOrganizationAccountsWorkspaceAccess({ - ...context, - organizationId: context.organizationId, - }) } export const credentialGroupDelegationPolicy = { diff --git a/apps/sim/lib/credential-groups/application/list-credentials.test.ts b/apps/sim/lib/credential-groups/application/list-credentials.test.ts index b940d915701..f0c73b24891 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.test.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.test.ts @@ -117,7 +117,10 @@ describe('listCredentialGroupCredentials', () => { beforeEach(() => { vi.clearAllMocks() mocks.requirePolicy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), }) mocks.loadGroup.mockResolvedValue(groupContext) mocks.loadWorkspace.mockResolvedValue(workspaceContext) @@ -263,6 +266,36 @@ describe('listCredentialGroupCredentials', () => { }) }) + it('filters restricted integrations before pagination and rejects explicitly requesting them', async () => { + mocks.loadGroup.mockResolvedValue({ + ...groupContext, + options: [ + ...groupContext.options, + { ...groupContext.options[0], id: 'calendar-option', provider: 'google-calendar' }, + ], + }) + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }, + ]), + }) + await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ credentialGroupOptionIds: ['option-1'], limit: 50 }) + ) + mocks.listCredentials.mockClear() + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['google-calendar'] }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + it('filters by canonical providers active in the group', async () => { await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), diff --git a/apps/sim/lib/credential-groups/application/list-credentials.ts b/apps/sim/lib/credential-groups/application/list-credentials.ts index a81af811f5c..203b43c63af 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.ts @@ -10,6 +10,7 @@ import { requireOrganizationAccountsWorkspaceAccess, resolveOrganizationAccountsWorkspaceContext, } from '@/lib/credential-groups/application/organization-workspace-access' +import { organizationAccountPolicyAllowsWorkspace } from '@/lib/credential-groups/application/workspace-access-policy' import { CredentialGroupCredentialCursorNotFoundError, type CredentialGroupCredentialReference, @@ -43,7 +44,7 @@ export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ authorizationOptions: { delegation: credentialGroupDelegationPolicy }, async authorizeResource({ principal, context }) { requireCredentialGroupWorkflowActor(principal) - await requireOrganizationAccountsWorkspaceAccess(context) + context.workspaceAccessPolicy = await requireOrganizationAccountsWorkspaceAccess(context) }, execute: async ({ input, context }): Promise => { if ( @@ -69,14 +70,17 @@ export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ if (credentialProviderIds.some((providerId) => !providerId.trim())) { throw new OrchestrationError('validation', 'Credential provider IDs must not be empty') } - const activeOptions = context.options.filter((option) => option.status === 'active') - const activeProviderIds = new Set( - activeOptions.map((option) => { - if (!isCredentialGroupProvider(option.provider)) { - throw new Error(`Credential Group provider is not registered: ${option.provider}`) - } - return getCredentialGroupProviderId(option.provider) + const policy = context.workspaceAccessPolicy + if (!policy) throw new Error('Credential listing requires workspace policy authorization') + const activeOptions = context.options + .filter((option) => option.status === 'active') + .map((option) => { + if (!isCredentialGroupProvider(option.provider)) + throw new Error(`Unsupported credential provider: ${option.provider}`) + return { ...option, provider: option.provider } }) + const activeProviderIds = new Set( + activeOptions.map((option) => getCredentialGroupProviderId(option.provider)) ) const invalidProviderIds = credentialProviderIds.filter( (providerId) => !activeProviderIds.has(providerId) @@ -88,12 +92,29 @@ export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ ) } + const allowedOptions = activeOptions.filter((option) => + organizationAccountPolicyAllowsWorkspace( + policy, + context.workspaceId, + `oauth:${option.provider}` + ) + ) + const allowedProviders = new Set( + allowedOptions.map((option) => getCredentialGroupProviderId(option.provider)) + ) + if (credentialProviderIds.some((providerId) => !allowedProviders.has(providerId))) { + throw new OrchestrationError( + 'forbidden', + 'This workspace is not allowed to use the requested credential provider' + ) + } + let page try { page = await listCredentialGroupCredentialReferences({ organizationId: context.organizationId, credentialGroupId: context.credentialGroupId, - credentialGroupOptionIds: activeOptions.map((option) => option.id), + credentialGroupOptionIds: allowedOptions.map((option) => option.id), limit: input.limit, cursor: input.cursor, email, diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts index 25487113610..f16108c8d3b 100644 --- a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts @@ -97,7 +97,10 @@ describe('listCredentialGroupMcpConnections', () => { beforeEach(() => { vi.clearAllMocks() mocks.requirePolicy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), }) mocks.loadGroup.mockResolvedValue(groupContext) mocks.loadWorkspace.mockResolvedValue(workspaceContext) @@ -142,6 +145,29 @@ describe('listCredentialGroupMcpConnections', () => { expect(mocks.listMcpConnections).not.toHaveBeenCalled() }) + it('limits discovery to allowed MCP types and rejects an explicit restricted connector', async () => { + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['mcp:fireflies'] }, + }, + ]), + }) + await listCredentialGroupMcpConnections.execute({ principal: executorPrincipal(), input }) + expect(mocks.listMcpConnections).toHaveBeenCalledWith( + expect.objectContaining({ allowedConnectorIds: ['fireflies'] }) + ) + mocks.listMcpConnections.mockClear() + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { ...input, connectorId: 'granola' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + it('lists bounded MCP connection references after authorization and entitlement checks', async () => { const result = await listCredentialGroupMcpConnections.execute({ principal: executorPrincipal(), @@ -160,6 +186,7 @@ describe('listCredentialGroupMcpConnections', () => { email: 'person@example.com', mcpServerId: 'mcp-server-1', connectorId: undefined, + allowedConnectorIds: ['fireflies', 'granola', 'databricks'], }) expect(result).toEqual({ mcpConnections: [ diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts index 576b86d27dd..d0baadc58a2 100644 --- a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts @@ -10,7 +10,11 @@ import { requireOrganizationAccountsWorkspaceAccess, resolveOrganizationAccountsWorkspaceContext, } from '@/lib/credential-groups/application/organization-workspace-access' -import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { organizationAccountPolicyAllowsWorkspace } from '@/lib/credential-groups/application/workspace-access-policy' +import { + getManagedMcpConnector, + MANAGED_MCP_CONNECTOR_IDS, +} from '@/lib/credential-groups/managed-mcp-connectors' import { CredentialGroupMcpConnectionCursorNotFoundError, type CredentialGroupMcpConnectionReference, @@ -41,7 +45,7 @@ export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCas authorizationOptions: { delegation: credentialGroupDelegationPolicy }, async authorizeResource({ principal, context }) { requireCredentialGroupWorkflowActor(principal) - await requireOrganizationAccountsWorkspaceAccess(context) + context.workspaceAccessPolicy = await requireOrganizationAccountsWorkspaceAccess(context) }, execute: async ({ input, context }): Promise => { if ( @@ -68,6 +72,17 @@ export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCas throw new OrchestrationError('validation', 'MCP server ID must not be empty') } + const policy = context.workspaceAccessPolicy + if (!policy) throw new Error('MCP listing requires workspace policy authorization') + const allowedConnectorIds = MANAGED_MCP_CONNECTOR_IDS.filter((id) => + organizationAccountPolicyAllowsWorkspace(policy, context.workspaceId, `mcp:${id}`) + ) + if (input.connectorId && !allowedConnectorIds.some((id) => id === input.connectorId)) { + throw new OrchestrationError( + 'forbidden', + 'This workspace is not allowed to use the requested MCP provider' + ) + } let page try { page = await listCredentialGroupMcpConnectionReferences({ @@ -78,6 +93,7 @@ export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCas email, mcpServerId, connectorId: input.connectorId, + allowedConnectorIds, }) } catch (error) { if (error instanceof CredentialGroupMcpConnectionCursorNotFoundError) { diff --git a/apps/sim/lib/credential-groups/application/organization-access.test.ts b/apps/sim/lib/credential-groups/application/organization-access.test.ts index 32c4897be29..020657e082c 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.test.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts @@ -55,6 +55,7 @@ import { startOrganizationAccountConnection, } from '@/lib/credential-groups/application/organization-accounts' import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types' import { ResourcePolicyRevisionConflictError } from '@/lib/resource-policies/repository' const principal: SessionPrincipal = { @@ -62,7 +63,16 @@ const principal: SessionPrincipal = { userId: 'admin-user', sessionId: 'session-1', } -const input = { organizationId: 'org-1', revision: 3, workspaceIds: ['workspace-1'] } +const input = { + organizationId: 'org-1', + revision: 3, + grants: [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected' as const, credentialTypes: ['oauth:gmail' as const] }, + }, + ], +} describe('organization workspace sharing administration', () => { beforeEach(() => { @@ -166,7 +176,15 @@ describe('organization workspace sharing administration', () => { queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }]) await expect( updateOrganizationAccountWorkspaceAccess.execute({ principal, input }) - ).resolves.toMatchObject({ revision: 4, workspaceIds: ['workspace-1'] }) + ).resolves.toMatchObject({ + revision: 4, + grants: [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected' as const, credentialTypes: ['oauth:gmail' as const] }, + }, + ], + }) expect(eq).toHaveBeenCalledWith(schemaMock.member.userId, 'admin-user') expect(eq).toHaveBeenCalledWith(schemaMock.member.organizationId, 'org-1') expect(eq).toHaveBeenCalledWith(schemaMock.workspace.organizationId, 'org-1') @@ -175,6 +193,7 @@ describe('organization workspace sharing administration', () => { organizationId: 'org-1', actorUserId: 'admin-user', expectedRevision: 3, + document: buildOrganizationAccountAccessPolicy('group-1', input.grants), }) ) }) @@ -193,21 +212,37 @@ describe('organization workspace sharing administration', () => { await expect( updateOrganizationAccountWorkspaceAccess.execute({ principal, - input: { ...input, workspaceIds: [] }, + input: { ...input, grants: [] }, }) - ).resolves.toMatchObject({ workspaceIds: [] }) + ).resolves.toMatchObject({ grants: [] }) expect(mocks.write).toHaveBeenCalledWith( expect.objectContaining({ document: buildOrganizationAccountAccessPolicy('group-1', []) }) ) }) + it('rejects selected grants exceeding the persisted policy size bound before writing', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + const grants = Array.from({ length: 1000 }, (_, index) => ({ + workspaceId: `workspace-${index}`, + access: { mode: 'selected' as const, credentialTypes: [...ORGANIZATION_CREDENTIAL_TYPES] }, + })) + queueTableRows( + schemaMock.workspace, + grants.map((grant) => ({ id: grant.workspaceId })) + ) + await expect( + updateOrganizationAccountWorkspaceAccess.execute({ principal, input: { ...input, grants } }) + ).rejects.toMatchObject({ code: 'validation', message: expect.stringContaining('too large') }) + expect(mocks.write).not.toHaveBeenCalled() + }) + it('rejects a stale revision rather than overwriting another admin', async () => { queueTableRows(schemaMock.member, [{ role: 'admin' }]) mocks.write.mockRejectedValue(new ResourcePolicyRevisionConflictError()) await expect( updateOrganizationAccountWorkspaceAccess.execute({ principal, - input: { ...input, workspaceIds: [] }, + input: { ...input, grants: [] }, }) ).rejects.toMatchObject({ code: 'conflict' }) }) diff --git a/apps/sim/lib/credential-groups/application/organization-access.ts b/apps/sim/lib/credential-groups/application/organization-access.ts index 7300e2bbfb8..1c73f3a6cd6 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.ts @@ -1,4 +1,5 @@ import { db } from '@sim/db' +import { ORGANIZATION_ACCOUNT_POLICY_DOCUMENT_MAX_BYTES } from '@sim/db/credential-group-resource-policies' import { workspace } from '@sim/db/schema' import { and, asc, eq, inArray, isNull } from 'drizzle-orm' import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' @@ -7,12 +8,16 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineOrganizationAccountsUseCase } from '@/lib/credential-groups/application/organization-accounts' import { buildOrganizationAccountAccessPolicy, - listOrganizationAccountWorkspaceIds, + listOrganizationAccountWorkspaceGrants, organizationAccountAccessPolicyCodec, - organizationAccountWorkspaceIdsSchema, } from '@/lib/credential-groups/application/workspace-access-policy' +import { getOrganizationCredentialTypeCatalog } from '@/lib/credential-groups/credential-types' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { + type OrganizationAccountWorkspaceGrant, + organizationAccountWorkspaceGrantsSchema, +} from '@/lib/credential-groups/workspace-grants' import { ResourcePolicyRevisionConflictError, requireResourcePolicy, @@ -71,8 +76,9 @@ export const getOrganizationAccountWorkspaceAccess = defineOrganizationAccountsU ) return { revision: policy.revision, - workspaceIds: listOrganizationAccountWorkspaceIds(policy.document), + grants: listOrganizationAccountWorkspaceGrants(policy.document), workspaces, + credentialTypes: getOrganizationCredentialTypeCatalog(), } }, }) @@ -83,14 +89,14 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun input, context, }: { - input: { organizationId: string; revision: number; workspaceIds: string[] } + input: { organizationId: string; revision: number; grants: OrganizationAccountWorkspaceGrant[] } context: OrganizationMembershipContext }) { - const parsed = organizationAccountWorkspaceIdsSchema.safeParse(input.workspaceIds) + const parsed = organizationAccountWorkspaceGrantsSchema.safeParse(input.grants) if (!parsed.success) throw new OrchestrationError( 'validation', - 'Workspace IDs must be unique, valid identifiers within the supported limit' + 'Workspace grants must contain unique workspace IDs and valid integration selections' ) const group = await requireGroup(context.organizationId) if (parsed.data.length) { @@ -100,7 +106,10 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun .where( and( eq(workspace.organizationId, context.organizationId), - inArray(workspace.id, parsed.data), + inArray( + workspace.id, + parsed.data.map((grant) => grant.workspaceId) + ), isNull(workspace.archivedAt) ) ) @@ -110,6 +119,17 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun 'Every allowed workspace must be active and belong to this organization' ) } + const document = buildOrganizationAccountAccessPolicy(group.credentialGroupId, parsed.data) + /** Indented JSON conservatively includes the whitespace PostgreSQL adds to jsonb text. */ + if ( + Buffer.byteLength(JSON.stringify(document, null, 1), 'utf8') > + ORGANIZATION_ACCOUNT_POLICY_DOCUMENT_MAX_BYTES + ) { + throw new OrchestrationError( + 'validation', + 'Workspace access policy is too large. Reduce the number of selected integrations or workspaces.' + ) + } try { const policy = await writeResourcePolicy({ organizationId: context.organizationId, @@ -117,14 +137,14 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun resourceId: group.credentialGroupId, codec: organizationAccountAccessPolicyCodec, expectedRevision: input.revision, - document: buildOrganizationAccountAccessPolicy(group.credentialGroupId, parsed.data), + document, actorUserId: context.userId, }) return { credentialGroupId: group.credentialGroupId, name: group.name, revision: policy.revision, - workspaceIds: listOrganizationAccountWorkspaceIds(policy.document), + grants: listOrganizationAccountWorkspaceGrants(policy.document), } } catch (error) { if (error instanceof ResourcePolicyRevisionConflictError) @@ -138,6 +158,6 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun projectAudit: (result) => ({ resourceId: result.credentialGroupId, resourceName: result.name, - description: `Allowed ${result.workspaceIds.length} workspaces to use organization connected accounts`, + description: `Allowed ${result.grants.length} workspaces to use organization connected accounts`, }), }) diff --git a/apps/sim/lib/credential-groups/application/organization-workspace-access.ts b/apps/sim/lib/credential-groups/application/organization-workspace-access.ts index 76ed97340cf..b6f09a114ac 100644 --- a/apps/sim/lib/credential-groups/application/organization-workspace-access.ts +++ b/apps/sim/lib/credential-groups/application/organization-workspace-access.ts @@ -1,16 +1,19 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization' import { resolveCredentialGroupWorkspaceContext } from '@/lib/credential-groups/application/context' +import type { OrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { organizationAccountAccessPolicyCodec, organizationAccountPolicyAllowsWorkspace, } from '@/lib/credential-groups/application/workspace-access-policy' +import type { OrganizationCredentialType } from '@/lib/credential-groups/credential-types' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { requireResourcePolicy } from '@/lib/resource-policies/repository' export interface OrganizationAccountsWorkspaceContext extends CredentialGroupApplicationContext { organizationId: string + workspaceAccessPolicy?: OrganizationAccountAccessPolicy } /** Resolves the singleton using the executing workspace's current organization. */ @@ -31,12 +34,15 @@ export async function resolveOrganizationAccountsWorkspaceContext( } /** Uses live policy and ownership; cached selections and deployment snapshots never grant access. */ -export async function requireOrganizationAccountsWorkspaceAccess(context: { - workspaceId: string - workspaceOrganizationId: string | null - organizationId: string - credentialGroupId: string -}): Promise { +export async function requireOrganizationAccountsWorkspaceAccess( + context: { + workspaceId: string + workspaceOrganizationId: string | null + organizationId: string + credentialGroupId: string + }, + credentialType?: OrganizationCredentialType +): Promise { if (context.organizationId !== context.workspaceOrganizationId) { throw new OrchestrationError('forbidden', 'Connected accounts belong to another organization') } @@ -54,10 +60,15 @@ export async function requireOrganizationAccountsWorkspaceAccess(context: { resourceId: context.credentialGroupId, codec: organizationAccountAccessPolicyCodec, }) - if (!organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId)) { + if ( + !organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId, credentialType) + ) { throw new OrchestrationError( 'forbidden', - 'An organization admin must allow this workspace to use connected accounts' + credentialType + ? `This workspace is not allowed to use ${credentialType} credentials` + : 'An organization admin must allow this workspace to use Credential Groups' ) } + return policy.document } diff --git a/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts b/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts index c29a825eab1..5e37acc8c6d 100644 --- a/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts +++ b/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { buildOrganizationAccountAccessPolicy, + listOrganizationAccountWorkspaceGrants, listOrganizationAccountWorkspaceIds, organizationAccountAccessPolicyCodec, organizationAccountPolicyAllowsWorkspace, } from '@/lib/credential-groups/application/workspace-access-policy' +import { organizationAccountWorkspaceGrantsSchema } from '@/lib/credential-groups/workspace-grants' describe('organization account workspace policy', () => { it('denies every workspace by default', () => { @@ -14,7 +16,13 @@ describe('organization account workspace policy', () => { }) it('grants only selected workspaces without a workflow or deployment condition', () => { - const policy = buildOrganizationAccountAccessPolicy('group-1', ['workspace-2', 'workspace-1']) + const policy = buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-2', 'workspace-1'].map((workspaceId) => ({ + workspaceId, + access: { mode: 'all' as const }, + })) + ) expect(listOrganizationAccountWorkspaceIds(policy)).toEqual(['workspace-1', 'workspace-2']) expect(organizationAccountPolicyAllowsWorkspace(policy, 'workspace-1')).toBe(true) expect(organizationAccountPolicyAllowsWorkspace(policy, 'workspace-3')).toBe(false) @@ -35,7 +43,10 @@ describe('organization account workspace policy', () => { { type: 'workflow', workflowId: 'workflow-1' }, { type: 'knowledge_connector', connectorId: 'connector-1' }, ]) { - const policy = buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']) + const policy = buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ) expect(() => organizationAccountAccessPolicyCodec.parse( { ...policy, statements: [{ ...policy.statements[0], principals: [principal] }] }, @@ -47,8 +58,127 @@ describe('organization account workspace policy', () => { it('rejects duplicate selections and malformed IDs', () => { expect(() => - buildOrganizationAccountAccessPolicy('group-1', ['workspace-1', 'workspace-1']) + buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1', 'workspace-1'].map((workspaceId) => ({ + workspaceId, + access: { mode: 'all' as const }, + })) + ) + ).toThrow() + expect(() => + buildOrganizationAccountAccessPolicy( + 'group-1', + [' workspace-1 '].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ) ).toThrow() - expect(() => buildOrganizationAccountAccessPolicy('group-1', [' workspace-1 '])).toThrow() + }) +}) + +describe('integration-specific organization grants', () => { + const grants = [ + { + workspaceId: 'mail-workspace', + access: { + mode: 'selected' as const, + credentialTypes: ['oauth:gmail' as const, 'mcp:fireflies' as const], + }, + }, + { + workspaceId: 'calendar-workspace', + access: { + mode: 'selected' as const, + credentialTypes: ['oauth:google-calendar' as const, 'personal_token:gitlab' as const], + }, + }, + { workspaceId: 'all-workspace', access: { mode: 'all' as const } }, + ] + const policy = buildOrganizationAccountAccessPolicy('group-1', grants) + + it('evaluates type and workspace together across OAuth, MCP, and personal tokens', () => { + expect(organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'oauth:gmail')).toBe( + true + ) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'oauth:google-calendar') + ).toBe(false) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'mcp:fireflies') + ).toBe(true) + expect(organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'mcp:granola')).toBe( + false + ) + expect( + organizationAccountPolicyAllowsWorkspace( + policy, + 'calendar-workspace', + 'personal_token:gitlab' + ) + ).toBe(true) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'personal_token:gitlab') + ).toBe(false) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'unknown-workspace', 'oauth:gmail') + ).toBe(false) + }) + + it('keeps all-integration grants unconditional and round-trips selected grants', () => { + expect(organizationAccountPolicyAllowsWorkspace(policy, 'all-workspace', 'oauth:zoom')).toBe( + true + ) + expect( + policy.statements.find((statement) => statement.sid === 'WorkspaceCredentialAccess') + ).not.toHaveProperty('condition') + const restored = listOrganizationAccountWorkspaceGrants(policy) + for (const grant of grants) { + const match = restored.find((value) => value.workspaceId === grant.workspaceId) + expect(match?.access.mode).toBe(grant.access.mode) + if (grant.access.mode === 'selected' && match?.access.mode === 'selected') { + expect(new Set(match.access.credentialTypes)).toEqual(new Set(grant.access.credentialTypes)) + } + } + expect(organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace')).toBe(true) + }) + + it('fails closed for unknown types, duplicate types, and empty selections', () => { + for (const types of [[], ['oauth:unknown'], ['oauth:gmail', 'oauth:gmail']]) { + expect( + organizationAccountWorkspaceGrantsSchema.safeParse([ + { workspaceId: 'mail-workspace', access: { mode: 'selected', credentialTypes: types } }, + ]).success + ).toBe(false) + expect(() => + organizationAccountAccessPolicyCodec.parse( + { + ...policy, + statements: [ + { + ...policy.statements.find((statement) => statement.condition), + condition: { StringEquals: { 'credential_group:CredentialType': types } }, + }, + ], + }, + { type: 'credential_group', id: 'group-1' } + ) + ).toThrow() + } + }) + + it('rejects ambiguous overlapping or duplicate statements', () => { + const selected = policy.statements.find((statement) => statement.condition)! + const unrestricted = policy.statements.find((statement) => !statement.condition)! + for (const statements of [ + [selected, selected], + [selected, { ...unrestricted, principals: selected.principals }], + [{ ...selected, sid: 'WorkspaceCredentialAccess:oauth:unknown' }], + ]) { + expect(() => + organizationAccountAccessPolicyCodec.parse( + { ...policy, statements }, + { type: 'credential_group', id: 'group-1' } + ) + ).toThrow() + } }) }) diff --git a/apps/sim/lib/credential-groups/application/workspace-access-policy.ts b/apps/sim/lib/credential-groups/application/workspace-access-policy.ts index dd9af53ba90..bc037f9277c 100644 --- a/apps/sim/lib/credential-groups/application/workspace-access-policy.ts +++ b/apps/sim/lib/credential-groups/application/workspace-access-policy.ts @@ -1,34 +1,59 @@ import { z } from 'zod' +import { + ORGANIZATION_CREDENTIAL_TYPES, + type OrganizationCredentialType, +} from '@/lib/credential-groups/credential-types' import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { + type OrganizationAccountWorkspaceGrant, + organizationAccountWorkspaceGrantsSchema, + organizationCredentialTypeSchema, +} from '@/lib/credential-groups/workspace-grants' +import { CREDENTIAL_TYPE_CONDITION_KEY } from '@/lib/resource-policies/conditions/credential-type' import { evaluateResourcePolicy } from '@/lib/resource-policies/evaluator' import { workspaceResourcePolicyPrincipalSchema } from '@/lib/resource-policies/principals/workspace' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' import type { ResourcePolicyCodec } from '@/lib/resource-policies/types' -export const organizationAccountWorkspaceIdsSchema = z - .array(workspaceResourcePolicyPrincipalSchema.shape.workspaceId) - .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) - .refine((ids) => new Set(ids).size === ids.length, 'Workspace IDs must be unique') - const workspaceAccessStatementSchema = z .object({ - sid: z.literal('WorkspaceCredentialAccess'), + sid: z.string().min(1).max(256), effect: z.literal('allow'), actions: z.tuple([z.literal(CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION)]), principals: z .array(workspaceResourcePolicyPrincipalSchema) .min(1) .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT), + condition: z + .object({ + StringEquals: z + .object({ [CREDENTIAL_TYPE_CONDITION_KEY]: organizationCredentialTypeSchema }) + .strict(), + }) + .strict() + .optional(), }) .strict() - .refine( - ({ principals }) => - principals.every( + .superRefine((statement, context) => { + const type = statement.condition?.StringEquals[CREDENTIAL_TYPE_CONDITION_KEY] + const expectedSid = type ? `WorkspaceCredentialAccess:${type}` : 'WorkspaceCredentialAccess' + if (statement.sid !== expectedSid) + context.addIssue({ + code: 'custom', + message: 'Workspace statement ID must match its credential type', + }) + if ( + !statement.principals.every( (principal, index) => - index === 0 || principals[index - 1].workspaceId < principal.workspaceId - ), - 'Workspace principals must be sorted and unique' - ) + index === 0 || statement.principals[index - 1].workspaceId < principal.workspaceId + ) + ) { + context.addIssue({ + code: 'custom', + message: 'Workspace principals must be sorted and unique', + }) + } + }) export const organizationAccountAccessPolicySchema = z .object({ @@ -36,9 +61,34 @@ export const organizationAccountAccessPolicySchema = z resource: z .object({ type: z.literal('credential_group'), id: z.string().min(1).max(128) }) .strict(), - statements: z.array(workspaceAccessStatementSchema).max(1), + statements: z + .array(workspaceAccessStatementSchema) + .max(ORGANIZATION_CREDENTIAL_TYPES.length + 1), }) .strict() + .superRefine(({ statements }, context) => { + const statementIds = new Set(statements.map((statement) => statement.sid)) + if (statementIds.size !== statements.length) + context.addIssue({ code: 'custom', message: 'Workspace statements must be unique' }) + const unrestricted = new Set( + statements + .filter((statement) => !statement.condition) + .flatMap((statement) => statement.principals.map((principal) => principal.workspaceId)) + ) + const workspaceIds = new Set() + for (const statement of statements) { + for (const principal of statement.principals) { + workspaceIds.add(principal.workspaceId) + if (statement.condition && unrestricted.has(principal.workspaceId)) + context.addIssue({ + code: 'custom', + message: 'A workspace cannot have both all and selected credential access', + }) + } + } + if (workspaceIds.size > ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + context.addIssue({ code: 'custom', message: 'Too many workspace grants' }) + }) export type OrganizationAccountAccessPolicy = z.output @@ -49,51 +99,93 @@ export const organizationAccountAccessPolicyCodec: ResourcePolicyCodec< resourceType: 'credential_group', parse(value, expected) { const document = organizationAccountAccessPolicySchema.parse(value) - if (document.resource.type !== expected.type || document.resource.id !== expected.id) { - throw new Error('Connected accounts policy does not match its canonical group') - } + if (document.resource.type !== expected.type || document.resource.id !== expected.id) + throw new Error('Credential Groups policy does not match its canonical group') return document }, } export function buildOrganizationAccountAccessPolicy( credentialGroupId: string, - workspaceIds: string[] + grants: OrganizationAccountWorkspaceGrant[] ): OrganizationAccountAccessPolicy { - const ids = organizationAccountWorkspaceIdsSchema.parse(workspaceIds).sort() + const parsed = organizationAccountWorkspaceGrantsSchema.parse(grants) + const byType = new Map() + for (const { workspaceId, access } of parsed) { + const types = access.mode === 'all' ? ['all' as const] : access.credentialTypes + for (const type of types) { + const workspaces = byType.get(type) ?? [] + workspaces.push(workspaceId) + byType.set(type, workspaces) + } + } return organizationAccountAccessPolicySchema.parse({ version: 2, resource: { type: 'credential_group', id: credentialGroupId }, - statements: ids.length - ? [ - { - sid: 'WorkspaceCredentialAccess', - effect: 'allow', - actions: [CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION], - principals: ids.map((workspaceId) => ({ type: 'workspace', workspaceId })), - }, - ] - : [], + statements: [...byType] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([type, workspaceIds]) => ({ + sid: type === 'all' ? 'WorkspaceCredentialAccess' : `WorkspaceCredentialAccess:${type}`, + effect: 'allow', + actions: [CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION], + principals: workspaceIds.sort().map((workspaceId) => ({ type: 'workspace', workspaceId })), + ...(type === 'all' + ? {} + : { condition: { StringEquals: { [CREDENTIAL_TYPE_CONDITION_KEY]: type } } }), + })), }) } +export function listOrganizationAccountWorkspaceGrants( + document: OrganizationAccountAccessPolicy +): OrganizationAccountWorkspaceGrant[] { + const grants = new Map() + for (const statement of document.statements) { + const type = statement.condition?.StringEquals[CREDENTIAL_TYPE_CONDITION_KEY] + for (const { workspaceId } of statement.principals) { + if (!type) { + grants.set(workspaceId, { workspaceId, access: { mode: 'all' } }) + } else { + const existing = grants.get(workspaceId) + if (existing?.access.mode === 'all') throw new Error('Overlapping workspace grants') + if (existing) existing.access.credentialTypes.push(type) + else + grants.set(workspaceId, { + workspaceId, + access: { mode: 'selected', credentialTypes: [type] }, + }) + } + } + } + return [...grants.values()].sort((left, right) => + left.workspaceId.localeCompare(right.workspaceId) + ) +} + export function listOrganizationAccountWorkspaceIds( document: OrganizationAccountAccessPolicy ): string[] { - return document.statements.flatMap((statement) => - statement.principals.map((principal) => principal.workspaceId) - ) + return [ + ...new Set( + document.statements.flatMap((statement) => + statement.principals.map((principal) => principal.workspaceId) + ) + ), + ].sort() } +/** Tests the resource policy with the canonical integration, or any registered integration for a catalog entry point. */ export function organizationAccountPolicyAllowsWorkspace( document: OrganizationAccountAccessPolicy, - workspaceId: string + workspaceId: string, + credentialType?: OrganizationCredentialType ): boolean { - return ( - evaluateResourcePolicy({ - document, - action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, - facts: { currentWorkspaceId: workspaceId }, - }).decision === 'allow' + return (credentialType ? [credentialType] : ORGANIZATION_CREDENTIAL_TYPES).some( + (type) => + evaluateResourcePolicy({ + document, + action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, + facts: { currentWorkspaceId: workspaceId, credentialType: type }, + }).decision === 'allow' ) } diff --git a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts index c33cf518f70..ec88a78c09e 100644 --- a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts @@ -77,7 +77,18 @@ export const getWorkspaceOrganizationAccounts = defineAuthorizedWorkspaceUseCase result.allowed = organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId) if (!result.allowed) return result result.providers = group.options - .filter((option) => option.status === 'active') + .filter((option) => { + if (!isCredentialGroupProvider(option.provider)) + throw new Error(`Unsupported organization provider: ${option.provider}`) + return ( + option.status === 'active' && + organizationAccountPolicyAllowsWorkspace( + policy.document, + context.workspaceId, + `oauth:${option.provider}` + ) + ) + }) .map((option) => { if (!isCredentialGroupProvider(option.provider)) throw new Error(`Unsupported organization provider: ${option.provider}`) @@ -95,11 +106,17 @@ export const getWorkspaceOrganizationAccounts = defineAuthorizedWorkspaceUseCase isNull(mcpServers.deletedAt) ) ) - result.mcpProviders = servers.map((server) => { + result.mcpProviders = servers.flatMap((server) => { if (!server.connectorId) throw new Error('Organization MCP provider is missing its connector ID') const connector = getManagedMcpConnector(server.connectorId) - return { id: connector.id, label: connector.name } + return organizationAccountPolicyAllowsWorkspace( + policy.document, + context.workspaceId, + `mcp:${connector.id}` + ) + ? [{ id: connector.id, label: connector.name }] + : [] }) return result }, diff --git a/apps/sim/lib/credential-groups/credential-types.ts b/apps/sim/lib/credential-groups/credential-types.ts new file mode 100644 index 00000000000..2cb90078aed --- /dev/null +++ b/apps/sim/lib/credential-groups/credential-types.ts @@ -0,0 +1,42 @@ +import { + MANAGED_MCP_CONNECTOR_IDS, + MANAGED_MCP_CONNECTORS, +} from '@/lib/credential-groups/managed-mcp-connectors' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + getCredentialGroupProviderFromProviderId, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' + +export type OrganizationCredentialType = + | `oauth:${(typeof CREDENTIAL_GROUP_PROVIDER_IDS)[number]}` + | `mcp:${(typeof MANAGED_MCP_CONNECTOR_IDS)[number]}` + | 'personal_token:gitlab' + +export const ORGANIZATION_CREDENTIAL_TYPES: readonly OrganizationCredentialType[] = [ + ...CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => `oauth:${provider}` as const), + ...MANAGED_MCP_CONNECTOR_IDS.map((provider) => `mcp:${provider}` as const), + 'personal_token:gitlab', +] + +export function isOrganizationCredentialType(value: string): value is OrganizationCredentialType { + return ORGANIZATION_CREDENTIAL_TYPES.some((type) => type === value) +} + +export function organizationOAuthCredentialType(providerId: string): OrganizationCredentialType { + return `oauth:${getCredentialGroupProviderFromProviderId(providerId)}` +} + +export function getOrganizationCredentialTypeCatalog() { + return [ + ...CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => ({ + id: `oauth:${provider}` as const, + label: getCredentialGroupProviderService(provider).name, + })), + ...MANAGED_MCP_CONNECTOR_IDS.map((provider) => ({ + id: `mcp:${provider}` as const, + label: MANAGED_MCP_CONNECTORS[provider].name, + })), + { id: 'personal_token:gitlab' as const, label: 'GitLab' }, + ].sort((left, right) => left.label.localeCompare(right.label)) +} diff --git a/apps/sim/lib/credential-groups/mcp-connections.ts b/apps/sim/lib/credential-groups/mcp-connections.ts index 12a891a9427..7e65efa40bc 100644 --- a/apps/sim/lib/credential-groups/mcp-connections.ts +++ b/apps/sim/lib/credential-groups/mcp-connections.ts @@ -32,6 +32,7 @@ interface ListCredentialGroupMcpConnectionReferencesInput { email?: string mcpServerId?: string connectorId?: string + allowedConnectorIds?: readonly string[] } function decodeToolNames(value: unknown): string[] { @@ -52,15 +53,23 @@ export async function listCredentialGroupMcpConnectionReferences({ email, mcpServerId, connectorId, + allowedConnectorIds, }: ListCredentialGroupMcpConnectionReferencesInput): Promise<{ mcpConnections: CredentialGroupMcpConnectionReference[] nextCursor: string | null }> { + if (allowedConnectorIds?.length === 0) { + if (cursor) throw new CredentialGroupMcpConnectionCursorNotFoundError() + return { mcpConnections: [], nextCursor: null } + } const ownerScope = resourceScopeFromOwner({ workspaceId, organizationId }) const scope = () => and( resourceScopeCondition(credential, ownerScope), eq(credential.type, 'managed_mcp'), + allowedConnectorIds + ? inArray(mcpServers.managedConnectorId, [...allowedConnectorIds]) + : undefined, eq(credential.managedOauthStatus, 'active'), eq(credential.mcpOauthConfigVersion, mcpServers.oauthConfigVersion), eq(credentialGroup.id, credentialGroupId), diff --git a/apps/sim/lib/credential-groups/trigger.test.ts b/apps/sim/lib/credential-groups/trigger.test.ts index 1f8d1ac446d..62c6cf2a663 100644 --- a/apps/sim/lib/credential-groups/trigger.test.ts +++ b/apps/sim/lib/credential-groups/trigger.test.ts @@ -73,7 +73,13 @@ describe('Credential Group trigger delivery', () => { beforeEach(() => { vi.clearAllMocks() mocks.requirePolicy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1', 'workspace-2']), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1', 'workspace-2'].map((workspaceId) => ({ + workspaceId, + access: { mode: 'all' as const }, + })) + ), }) mocks.resolveWorkspace.mockImplementation(async (workspaceId: string) => ({ workspaceId, @@ -108,6 +114,29 @@ describe('Credential Group trigger delivery', () => { ) }) + it('discovers subscribers only for the event integration and rechecks that type before delivery', async () => { + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }, + { + workspaceId: 'workspace-2', + access: { mode: 'selected', credentialTypes: ['oauth:google-calendar'] }, + }, + ]), + }) + mocks.fetchSubscriptions.mockResolvedValue([subscription({ workflowId: 'allowed' })]) + await fireCredentialGroupTrigger(EVENT) + expect(mocks.fetchSubscriptions).toHaveBeenCalledWith('org-1', ['workspace-1']) + expect(mocks.requireAccess).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1' }), + 'oauth:gmail' + ) + expect(mocks.processEvent).toHaveBeenCalledOnce() + }) + it('does not scan subscriptions when no workspace has access', async () => { mocks.requirePolicy.mockResolvedValue({ document: buildOrganizationAccountAccessPolicy('group-1', []), diff --git a/apps/sim/lib/credential-groups/trigger.ts b/apps/sim/lib/credential-groups/trigger.ts index c9fa7e964a6..19d5f896312 100644 --- a/apps/sim/lib/credential-groups/trigger.ts +++ b/apps/sim/lib/credential-groups/trigger.ts @@ -8,8 +8,14 @@ import { import { listOrganizationAccountWorkspaceIds, organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, } from '@/lib/credential-groups/application/workspace-access-policy' +import { + type OrganizationCredentialType, + organizationOAuthCredentialType, +} from '@/lib/credential-groups/credential-types' import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID, @@ -121,7 +127,15 @@ export async function fireCredentialGroupTrigger( resourceId: event.credentialGroupId, codec: organizationAccountAccessPolicyCodec, }) - const allowedWorkspaceIds = listOrganizationAccountWorkspaceIds(policy.document) + const credentialType: OrganizationCredentialType | undefined = + event.event === 'form_submitted' + ? undefined + : event.credential.mcpServerId + ? `mcp:${getManagedMcpConnector(event.credential.provider).id}` + : organizationOAuthCredentialType(event.credential.providerId) + const allowedWorkspaceIds = listOrganizationAccountWorkspaceIds(policy.document).filter((id) => + organizationAccountPolicyAllowsWorkspace(policy.document, id, credentialType) + ) if (allowedWorkspaceIds.length === 0) return const subscriptions = await fetchCredentialGroupTriggerSubscriptions( event.organizationId, @@ -141,7 +155,7 @@ export async function fireCredentialGroupTrigger( const context = await resolveOrganizationAccountsWorkspaceContext(workflow.workspaceId) if (context.credentialGroupId !== event.credentialGroupId || context.status !== 'active') continue - await requireOrganizationAccountsWorkspaceAccess(context) + await requireOrganizationAccountsWorkspaceAccess(context, credentialType) } catch (error) { /** Revocations and workspace moves remove subscribers between discovery and delivery. */ if ( diff --git a/apps/sim/lib/credential-groups/workspace-grants.ts b/apps/sim/lib/credential-groups/workspace-grants.ts new file mode 100644 index 00000000000..b982dd53a3e --- /dev/null +++ b/apps/sim/lib/credential-groups/workspace-grants.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types' +import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { workspaceResourcePolicyPrincipalSchema } from '@/lib/resource-policies/principals/workspace' + +export const organizationCredentialTypeSchema = z.enum(ORGANIZATION_CREDENTIAL_TYPES, { + error: 'Unknown credential type', +}) + +export const organizationAccountWorkspaceGrantSchema = z + .object({ + workspaceId: workspaceResourcePolicyPrincipalSchema.shape.workspaceId, + access: z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('all') }).strict(), + z + .object({ + mode: z.literal('selected'), + credentialTypes: z + .array(organizationCredentialTypeSchema) + .min(1, 'Select at least one credential type') + .max(ORGANIZATION_CREDENTIAL_TYPES.length) + .refine( + (types) => new Set(types).size === types.length, + 'Credential types must be unique' + ), + }) + .strict(), + ]), + }) + .strict() + +export const organizationAccountWorkspaceGrantsSchema = z + .array(organizationAccountWorkspaceGrantSchema) + .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + .refine( + (grants) => new Set(grants.map((grant) => grant.workspaceId)).size === grants.length, + 'Workspace grants must be unique' + ) + +export type OrganizationAccountWorkspaceGrant = z.output< + typeof organizationAccountWorkspaceGrantSchema +> diff --git a/apps/sim/lib/credentials/application/personal-connection.test.ts b/apps/sim/lib/credentials/application/personal-connection.test.ts index c1b3190ffb6..c2544fc67c5 100644 --- a/apps/sim/lib/credentials/application/personal-connection.test.ts +++ b/apps/sim/lib/credentials/application/personal-connection.test.ts @@ -82,7 +82,10 @@ describe('personal connection launch', () => { mocks.organizationMembership.mockResolvedValue({ userId: 'viewer', role: 'member' }) mocks.available.mockResolvedValue(true) mocks.policy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('canonical-group', ['workspace']), + document: buildOrganizationAccountAccessPolicy( + 'canonical-group', + ['workspace'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), }) mocks.catalog.mockResolvedValue([ { diff --git a/apps/sim/lib/credentials/application/personal-credentials.test.ts b/apps/sim/lib/credentials/application/personal-credentials.test.ts index 295f4e04fc6..732f692b03c 100644 --- a/apps/sim/lib/credentials/application/personal-credentials.test.ts +++ b/apps/sim/lib/credentials/application/personal-credentials.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -70,6 +71,7 @@ function delegatedPrincipal(overrides: Partial = {}): Delega describe('personal credential application access', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.listPersonal.mockResolvedValue([personalCredential]) @@ -114,6 +116,9 @@ describe('personal credential application access', () => { instanceUrl: 'https://gitlab.example.com', } mocks.listTokens.mockResolvedValue([token]) + queueTableRows(schemaMock.credential, [ + { ...token, organizationId: null, groupId: 'legacy-group' }, + ]) const result = await listPersonalCredentials.execute({ principal, input: { workspaceId: 'workspace-1' }, @@ -136,6 +141,9 @@ describe('personal credential application access', () => { it('authorizes a managed account returned by the same personal policy', async () => { const managed = { ...personalCredential, providerId: 'slack', type: 'managed_oauth' as const } mocks.listPersonal.mockResolvedValue([managed]) + queueTableRows(schemaMock.credential, [ + { ...managed, organizationId: null, groupId: 'legacy-group' }, + ]) const result = await authorizePersonalCredential.execute({ principal, diff --git a/apps/sim/lib/credentials/application/personal-credentials.ts b/apps/sim/lib/credentials/application/personal-credentials.ts index 725ee40fc98..9271ff4c449 100644 --- a/apps/sim/lib/credentials/application/personal-credentials.ts +++ b/apps/sim/lib/credentials/application/personal-credentials.ts @@ -3,6 +3,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' import { credentialOperations } from '@/lib/credentials/application/operations' +import { filterWorkspaceAccountCredentials } from '@/lib/credentials/application/workspace-account-visibility' import { getPersonalOAuthCredentials, type PersonalOAuthCredential, @@ -33,7 +34,10 @@ export const listPersonalCredentials = defineAuthorizedWorkspaceUseCase({ getPersonalTokenCredentials(context.workspaceId, userId), ]) return { - credentials: [...oauthCredentials, ...tokenCredentials], + credentials: await filterWorkspaceAccountCredentials(context, [ + ...oauthCredentials, + ...tokenCredentials, + ]), } }, }) @@ -57,7 +61,8 @@ export const authorizePersonalCredential = defineAuthorizedWorkspaceUseCase({ input.credentialId ) const providerIds = providerIdsForService(input.expectedProviderId) - const credential = credentials.find( + const visible = await filterWorkspaceAccountCredentials(context, credentials) + const credential = visible.find( (entry) => entry.id === input.credentialId && providerIds.includes(entry.providerId) ) if (!credential) { diff --git a/apps/sim/lib/credentials/application/resolve-personal-token.test.ts b/apps/sim/lib/credentials/application/resolve-personal-token.test.ts index 92b3969f17f..5bd8e7eb18f 100644 --- a/apps/sim/lib/credentials/application/resolve-personal-token.test.ts +++ b/apps/sim/lib/credentials/application/resolve-personal-token.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ decrypt: vi.fn(), audit: vi.fn(), enrollment: vi.fn(), + policy: vi.fn(), + available: vi.fn(), })) vi.mock('@/lib/credentials/application/credential-context', () => ({ resolveCredentialApplicationContext: mocks.context, @@ -27,6 +29,12 @@ vi.mock('@sim/audit', () => ({ recordAudit: mocks.audit, })) +vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.policy })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) + +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { resolvePersonalToken } from '@/lib/credentials/application/resolve-personal-token' const principal = { kind: 'session', userId: 'owner', sessionId: 'session' } as const @@ -116,6 +124,45 @@ describe('authorized personal token resolution', () => { expect(mocks.decrypt).not.toHaveBeenCalled() expect(mocks.audit).not.toHaveBeenCalled() }) + it('authorizes organization token type before decrypting and rechecks revocation', async () => { + const organizationToken = { ...current, workspaceId: null, organizationId: 'org' } + mocks.context.mockResolvedValue({ + ...context, + workspaceOrganizationId: 'org', + credential: organizationToken, + }) + mocks.access.mockResolvedValue({ + credential: organizationToken, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: false, + isAdmin: true, + }) + mocks.enrollment.mockResolvedValue({ credentialGroupId: 'group' }) + mocks.available.mockResolvedValue(true) + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group', [ + { + workspaceId: 'ws', + access: { mode: 'selected', credentialTypes: ['personal_token:gitlab'] }, + }, + ]), + }) + await expect(resolvePersonalToken.execute({ principal, input })).resolves.toMatchObject({ + accessToken: 'secret', + }) + mocks.decrypt.mockClear() + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group', [ + { workspaceId: 'ws', access: { mode: 'selected', credentialTypes: ['oauth:gmail'] } }, + ]), + }) + await expect(resolvePersonalToken.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.decrypt).not.toHaveBeenCalled() + }) + it('refuses revoked workspace access before secret resolution', async () => { mocks.permission.mockResolvedValue(null) await expect(resolvePersonalToken.execute({ principal, input })).rejects.toThrow( diff --git a/apps/sim/lib/credentials/application/resolve-personal-token.ts b/apps/sim/lib/credentials/application/resolve-personal-token.ts index 1901ee3aa19..3b0613df538 100644 --- a/apps/sim/lib/credentials/application/resolve-personal-token.ts +++ b/apps/sim/lib/credentials/application/resolve-personal-token.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' @@ -41,11 +42,21 @@ export const resolvePersonalToken = defineAuthorizedCredentialUseCase({ 'Connect your own active personal token for this integration' ) } - await requirePersonalTokenEnrollment({ + const enrollment = await requirePersonalTokenEnrollment({ ...resourceScopeFields(resourceScopeFromOwner(current)), userId, enrollmentId: current.credentialGroupEnrollmentId, }) + if (current.organizationId) { + await requireOrganizationAccountsWorkspaceAccess( + { + ...context, + organizationId: current.organizationId, + credentialGroupId: enrollment.credentialGroupId, + }, + 'personal_token:gitlab' + ) + } const accessToken = await decryptPersonalToken(current.encryptedPersonalToken, { providerId: 'gitlab', ownerUserId: userId, diff --git a/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts b/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts new file mode 100644 index 00000000000..e3e281032ba --- /dev/null +++ b/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts @@ -0,0 +1,111 @@ +/** @vitest-environment node */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ policy: vi.fn(), available: vi.fn() })) +vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.policy })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) + +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { filterWorkspaceAccountCredentials } from '@/lib/credentials/application/workspace-account-visibility' + +const context = { workspaceId: 'ws', workspaceOrganizationId: 'org', allowPersonalApiKeys: true } +const entries = [ + { id: 'ordinary', type: 'oauth', providerId: 'google-email' }, + { id: 'mail', type: 'managed_oauth', providerId: 'google-email' }, + { id: 'calendar', type: 'managed_oauth', providerId: 'google-calendar' }, + { id: 'token', type: 'personal_token', providerId: 'gitlab' }, +] +const bindings = entries + .slice(1) + .map((entry) => ({ ...entry, organizationId: 'org', groupId: 'group' })) + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.available.mockResolvedValue(true) + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group', [ + { + workspaceId: 'ws', + access: { mode: 'selected', credentialTypes: ['oauth:gmail', 'personal_token:gitlab'] }, + }, + ]), + }) +}) + +describe('workspace organization credential visibility', () => { + it('filters canonical OAuth and token types with a single policy read while preserving ordinary accounts', async () => { + queueTableRows(schemaMock.credential, bindings) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([ + entries[0], + entries[1], + entries[3], + ]) + expect(mocks.policy).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ organizationId: 'org', resourceId: 'group' }) + ) + expect(dbChainMockFns.select).toHaveBeenCalledExactlyOnceWith({ + id: schemaMock.credential.id, + organizationId: schemaMock.credential.organizationId, + groupId: schemaMock.credentialGroupEnrollment.credentialGroupId, + providerId: schemaMock.credential.providerId, + type: schemaMock.credential.type, + }) + }) + + it('rechecks revocation and does not reuse a previously allowed selection', async () => { + queueTableRows(schemaMock.credential, bindings) + await filterWorkspaceAccountCredentials(context, entries) + mocks.policy.mockResolvedValue({ document: buildOrganizationAccountAccessPolicy('group', []) }) + queueTableRows(schemaMock.credential, bindings) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + }) + + it.each([null, 'other-org'])( + 'hides organization accounts when the workspace belongs to %s', + async (workspaceOrganizationId) => { + queueTableRows(schemaMock.credential, bindings) + expect( + await filterWorkspaceAccountCredentials({ ...context, workspaceOrganizationId }, entries) + ).toEqual([entries[0]]) + expect(mocks.policy).not.toHaveBeenCalled() + } + ) + + it('fails closed for removed bindings and a disabled feature', async () => { + queueTableRows(schemaMock.credential, []) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + mocks.available.mockResolvedValue(false) + queueTableRows(schemaMock.credential, bindings) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + expect(mocks.policy).not.toHaveBeenCalled() + }) + + it('preserves independently managed workspace accounts', async () => { + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ ...binding, organizationId: null })) + ) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual(entries) + expect(mocks.available).not.toHaveBeenCalled() + expect(mocks.policy).not.toHaveBeenCalled() + }) + + it('throws for malformed policy or a changed canonical provider instead of granting access', async () => { + queueTableRows(schemaMock.credential, bindings) + mocks.policy.mockRejectedValueOnce(new Error('Malformed policy')) + await expect(filterWorkspaceAccountCredentials(context, entries)).rejects.toThrow( + 'Malformed policy' + ) + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ ...binding, providerId: 'slack' })) + ) + await expect(filterWorkspaceAccountCredentials(context, entries)).rejects.toThrow( + 'binding changed' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/workspace-account-visibility.ts b/apps/sim/lib/credentials/application/workspace-account-visibility.ts new file mode 100644 index 00000000000..f2ebe1e1f91 --- /dev/null +++ b/apps/sim/lib/credentials/application/workspace-account-visibility.ts @@ -0,0 +1,81 @@ +import { db } from '@sim/db' +import { credential, credentialGroupEnrollment } from '@sim/db/schema' +import { eq, inArray } from 'drizzle-orm' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { + type OrganizationAccountAccessPolicy, + organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, +} from '@/lib/credential-groups/application/workspace-access-policy' +import { organizationOAuthCredentialType } from '@/lib/credential-groups/credential-types' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { requireResourcePolicy } from '@/lib/resource-policies/repository' + +/** Applies organization grants after the calling application operation authorizes workspace access. */ +export async function filterWorkspaceAccountCredentials< + T extends { id: string; type: string; providerId: string }, +>(context: WorkspaceAuthorizationContext, credentials: T[]): Promise { + const managedIds = credentials + .filter((entry) => entry.type === 'managed_oauth' || entry.type === 'personal_token') + .map((entry) => entry.id) + if (!managedIds.length) return credentials + const bindings = await db + .select({ + id: credential.id, + organizationId: credential.organizationId, + groupId: credentialGroupEnrollment.credentialGroupId, + providerId: credential.providerId, + type: credential.type, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where(inArray(credential.id, managedIds)) + const byId = new Map(bindings.map((binding) => [binding.id, binding])) + const policies = new Map() + const organizationId = context.workspaceOrganizationId + const organizationAvailable = + organizationId && bindings.some((binding) => binding.organizationId === organizationId) + ? await isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }) + : false + for (const binding of bindings) { + if ( + !binding.organizationId || + binding.organizationId !== organizationId || + !organizationAvailable || + policies.has(binding.groupId) + ) + continue + const policy = await requireResourcePolicy({ + organizationId: binding.organizationId, + resourceType: 'credential_group', + resourceId: binding.groupId, + codec: organizationAccountAccessPolicyCodec, + }) + policies.set(binding.groupId, policy.document) + } + return credentials.filter((entry) => { + if (entry.type !== 'managed_oauth' && entry.type !== 'personal_token') return true + const binding = byId.get(entry.id) + if (!binding) return false + if (!binding.organizationId) return true + if (binding.organizationId !== organizationId || !organizationAvailable) return false + const policy = policies.get(binding.groupId) + if (!policy) throw new Error('Organization credential policy was not loaded') + if ( + !binding.providerId || + binding.providerId !== entry.providerId || + binding.type !== entry.type + ) + throw new Error('Credential binding changed while listing accounts') + if (binding.type === 'personal_token' && binding.providerId !== 'gitlab') + throw new Error('Unsupported personal-token provider') + const type = + binding.type === 'personal_token' + ? 'personal_token:gitlab' + : organizationOAuthCredentialType(binding.providerId) + return organizationAccountPolicyAllowsWorkspace(policy, context.workspaceId, type) + }) +} diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index df621ae31c7..597a93e94db 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -20,6 +20,7 @@ import { sameResourceScopeCondition, } from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { OrganizationCredentialType } from '@/lib/credential-groups/credential-types' import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' @@ -36,6 +37,7 @@ interface ManagedMcpTokenEnvelope { } export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialType: OrganizationCredentialType organizationId?: string credentialId: string credentialGroupId: string @@ -45,6 +47,7 @@ export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthori } export interface ManagedMcpRuntimeCredential { + credentialType: OrganizationCredentialType grantedAt: Date oauthConfigVersion: number scope: ResourceScope @@ -150,7 +153,7 @@ export async function loadManagedMcpCredentialApplicationContext( if (!row.managedConnectorId) { throw new Error(`Managed MCP server ${row.mcpServerId} has no connector ID`) } - getManagedMcpConnector(row.managedConnectorId) + const connector = getManagedMcpConnector(row.managedConnectorId) const workspaceContext = await loadActiveWorkspaceApplicationContext(workspaceId) if ( !workspaceContext || @@ -159,7 +162,12 @@ export async function loadManagedMcpCredentialApplicationContext( : row.workspaceId !== workspaceId) ) return null - return { ...row, ...workspaceContext, organizationId: row.organizationId ?? undefined } + return { + ...row, + ...workspaceContext, + credentialType: `mcp:${connector.id}` as const, + organizationId: row.organizationId ?? undefined, + } } export async function loadManagedMcpRuntimeCredential( @@ -221,7 +229,7 @@ export async function loadManagedMcpRuntimeCredential( if (!row.managedConnectorId) { throw new ManagedMcpCredentialError('Managed MCP connector metadata is missing', 500) } - getManagedMcpConnector(row.managedConnectorId) + const connector = getManagedMcpConnector(row.managedConnectorId) if ( row.status !== 'active' || row.groupStatus !== 'active' || @@ -239,6 +247,7 @@ export async function loadManagedMcpRuntimeCredential( if (!row.grantedAt) throw new ManagedMcpCredentialError('Managed MCP grant version is missing', 500) return { + credentialType: `mcp:${connector.id}`, credentialId: row.credentialId, oauthConfigVersion: row.serverOauthConfigVersion, credentialGroupId: row.credentialGroupId, diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts index 57a4cc1998b..51429749b89 100644 --- a/apps/sim/lib/credentials/managed-oauth.ts +++ b/apps/sim/lib/credentials/managed-oauth.ts @@ -11,6 +11,10 @@ import { } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + type OrganizationCredentialType, + organizationOAuthCredentialType, +} from '@/lib/credential-groups/credential-types' import { type CredentialGroupProviderAdapter, CredentialGroupProviderConfigurationError, @@ -73,6 +77,7 @@ interface ResolveManagedOAuthTokenParams { } export interface ManagedOAuthCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialType: OrganizationCredentialType organizationId?: string credentialId: string credentialGroupId: string @@ -196,9 +201,11 @@ export async function loadManagedOAuthCredentialApplicationContext( : row.workspaceId !== workspaceId ) return null + if (!row.providerId) throw new Error('Managed OAuth credential is missing its provider') return { ...workspaceContext, ...(row.organizationId ? { organizationId: row.organizationId } : {}), + credentialType: organizationOAuthCredentialType(row.providerId), credentialId: row.id, credentialGroupId: row.credentialGroupId, credentialGroupEnrollmentId: row.credentialGroupEnrollmentId, diff --git a/apps/sim/lib/credentials/personal-tokens.ts b/apps/sim/lib/credentials/personal-tokens.ts index 41cbd2b2ba3..00f300f9751 100644 --- a/apps/sim/lib/credentials/personal-tokens.ts +++ b/apps/sim/lib/credentials/personal-tokens.ts @@ -120,7 +120,7 @@ export async function requirePersonalTokenEnrollment( input: ResourceOwner & { userId: string; enrollmentId: string | null }, executor: DbOrTx = db, lock = false -): Promise { +): Promise<{ credentialGroupId: string }> { const scope = resourceScopeFromOwner(input) if (!input.enrollmentId) throw new OrchestrationError( @@ -173,6 +173,7 @@ export async function requirePersonalTokenEnrollment( executor ) } + return { credentialGroupId: binding.credentialGroupId } } export interface CreatePersonalTokenParams { diff --git a/apps/sim/lib/mcp/application/managed-auth-provider.ts b/apps/sim/lib/mcp/application/managed-auth-provider.ts index cf631231024..93b7dc1e062 100644 --- a/apps/sim/lib/mcp/application/managed-auth-provider.ts +++ b/apps/sim/lib/mcp/application/managed-auth-provider.ts @@ -15,12 +15,15 @@ export async function loadManagedMcpAuthProvider( ): Promise { const current = await loadManagedMcpRuntimeCredential(credentialId, workspaceId) if (current.scope.kind === 'organization') { - await requireOrganizationAccountsWorkspaceAccess({ - workspaceId, - workspaceOrganizationId: current.scope.organizationId, - organizationId: current.scope.organizationId, - credentialGroupId: current.credentialGroupId, - }) + await requireOrganizationAccountsWorkspaceAccess( + { + workspaceId, + workspaceOrganizationId: current.scope.organizationId, + organizationId: current.scope.organizationId, + credentialGroupId: current.credentialGroupId, + }, + current.credentialType + ) } const clientRow = await getOrCreateOauthRow({ mcpServerId: current.mcpServerId, diff --git a/apps/sim/lib/mcp/application/managed-connections.test.ts b/apps/sim/lib/mcp/application/managed-connections.test.ts index b3b22e831ea..9a85b92e116 100644 --- a/apps/sim/lib/mcp/application/managed-connections.test.ts +++ b/apps/sim/lib/mcp/application/managed-connections.test.ts @@ -30,6 +30,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.permission, })) +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { listManagedMcpConnectionsUseCase } from '@/lib/mcp/application/managed-connections' const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } @@ -60,7 +61,11 @@ describe('managed MCP connection catalog', () => { billedAccountUserId: 'owner-1', }) mocks.permission.mockResolvedValue('read') - mocks.requireAccess.mockResolvedValue(undefined) + mocks.requireAccess.mockResolvedValue( + buildOrganizationAccountAccessPolicy('group-1', [ + { workspaceId: 'workspace-1', access: { mode: 'all' } }, + ]) + ) }) it('uses organization ownership and workspace access before exposing credential operations', async () => { diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts index 19f7815f2a4..c3e26b82102 100644 --- a/apps/sim/lib/mcp/application/managed-connections.ts +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -4,9 +4,13 @@ import { and, asc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' +import { organizationAccountPolicyAllowsWorkspace } from '@/lib/credential-groups/application/workspace-access-policy' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' -import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { + getManagedMcpConnector, + MANAGED_MCP_CONNECTOR_IDS, +} from '@/lib/credential-groups/managed-mcp-connectors' import { resolveMcpWorkspaceContext } from '@/lib/mcp/application/context' import { mcpServerOperations } from '@/lib/mcp/application/operations' import type { McpToolSchema } from '@/lib/mcp/types' @@ -48,14 +52,19 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase organizationId, }) if (!group) return { servers: [], tools: [] } - await requireOrganizationAccountsWorkspaceAccess({ + const policy = await requireOrganizationAccountsWorkspaceAccess({ ...context, organizationId, credentialGroupId: group.credentialGroupId, }) + const allowedConnectorIds = MANAGED_MCP_CONNECTOR_IDS.filter((id) => + organizationAccountPolicyAllowsWorkspace(policy, context.workspaceId, `mcp:${id}`) + ) + if (!allowedConnectorIds.length) return { servers: [], tools: [] } const managedCatalogScope = () => and( eq(credential.organizationId, organizationId), + inArray(mcpServers.managedConnectorId, allowedConnectorIds), eq(credentialGroup.id, group.credentialGroupId), eq(credential.mcpOauthConfigVersion, mcpServers.oauthConfigVersion), eq(credential.type, 'managed_mcp'), diff --git a/apps/sim/lib/resource-policies/conditions/credential-type.ts b/apps/sim/lib/resource-policies/conditions/credential-type.ts new file mode 100644 index 00000000000..6ec768392a7 --- /dev/null +++ b/apps/sim/lib/resource-policies/conditions/credential-type.ts @@ -0,0 +1,13 @@ +import { defineResourcePolicyCondition } from '@/lib/resource-policies/conditions/types' + +export const CREDENTIAL_TYPE_CONDITION_KEY = 'credential_group:CredentialType' as const + +/** Resolves the integration from the canonical credential, independently of caller input. */ +export const credentialTypeConditionDefinition = defineResourcePolicyCondition({ + key: CREDENTIAL_TYPE_CONDITION_KEY, + label: 'Credential type', + valueType: 'string', + operators: ['StringEquals'], + selector: { type: 'internal' }, + resolve: (facts) => facts.credentialType, +}) diff --git a/apps/sim/lib/resource-policies/conditions/registry.ts b/apps/sim/lib/resource-policies/conditions/registry.ts index 780dfded096..640a02936db 100644 --- a/apps/sim/lib/resource-policies/conditions/registry.ts +++ b/apps/sim/lib/resource-policies/conditions/registry.ts @@ -1,5 +1,6 @@ import { credentialGroupActorOwnsCredentialConditionDefinition } from '@/lib/resource-policies/conditions/credential-group-actor-owns-credential' import { credentialGroupOptionIdConditionDefinition } from '@/lib/resource-policies/conditions/credential-group-option' +import { credentialTypeConditionDefinition } from '@/lib/resource-policies/conditions/credential-type' import type { ResourcePolicyConditionDefinition, ResourcePolicyConditionKey, @@ -9,6 +10,7 @@ import { workflowModeResourcePolicyConditionDefinition } from '@/lib/resource-po export const RESOURCE_POLICY_CONDITION_DEFINITIONS = Object.freeze({ 'credential_group:ActorOwnsCredential': credentialGroupActorOwnsCredentialConditionDefinition, 'credential_group:OptionId': credentialGroupOptionIdConditionDefinition, + 'credential_group:CredentialType': credentialTypeConditionDefinition, 'execution:WorkflowMode': workflowModeResourcePolicyConditionDefinition, } as const satisfies Record) diff --git a/apps/sim/lib/resource-policies/conditions/types.ts b/apps/sim/lib/resource-policies/conditions/types.ts index d22c9397071..c2c9eb79974 100644 --- a/apps/sim/lib/resource-policies/conditions/types.ts +++ b/apps/sim/lib/resource-policies/conditions/types.ts @@ -3,6 +3,7 @@ export const RESOURCE_POLICY_CONDITION_OPERATORS = ['Bool', 'StringEquals'] as c export type ResourcePolicyConditionOperator = (typeof RESOURCE_POLICY_CONDITION_OPERATORS)[number] export interface ResourcePolicyConditionEvaluationFacts { + credentialType?: string credentialGroupActorEnrollmentId?: string credentialGroupCredentialEnrollmentId?: string /** The option the credential being accessed was collected under. */ @@ -36,6 +37,7 @@ export interface ResourcePolicyConditionDefinition { export type ResourcePolicyConditionKey = | 'credential_group:ActorOwnsCredential' | 'credential_group:OptionId' + | 'credential_group:CredentialType' | 'execution:WorkflowMode' export function defineResourcePolicyCondition( diff --git a/apps/sim/lib/resource-policies/registry.ts b/apps/sim/lib/resource-policies/registry.ts index 25070bcd4ab..32c42363bdc 100644 --- a/apps/sim/lib/resource-policies/registry.ts +++ b/apps/sim/lib/resource-policies/registry.ts @@ -21,6 +21,7 @@ export const RESOURCE_POLICY_DEFINITIONS = Object.freeze({ conditionKeys: [ 'credential_group:ActorOwnsCredential', 'credential_group:OptionId', + 'credential_group:CredentialType', 'execution:WorkflowMode', ], }, diff --git a/apps/sim/lib/settings/application/organization-section-access.test.ts b/apps/sim/lib/settings/application/organization-section-access.test.ts index f425f2c3604..93602fa53a8 100644 --- a/apps/sim/lib/settings/application/organization-section-access.test.ts +++ b/apps/sim/lib/settings/application/organization-section-access.test.ts @@ -60,7 +60,7 @@ describe('organization settings authorization', () => { it.each([ { groups: false, search: false, connectedAccounts: false, integrations: false }, { groups: true, search: false, connectedAccounts: true, integrations: false }, - { groups: true, search: true, connectedAccounts: false, integrations: true }, + { groups: true, search: true, connectedAccounts: true, integrations: true }, ])( 'selects the setup page with groups=$groups and search=$search', async ({ groups, search, connectedAccounts, integrations }) => { @@ -92,7 +92,7 @@ describe('organization settings authorization', () => { } ) - it('propagates Search availability failures instead of selecting the old UI', async () => { + it('keeps Credential Groups independent of Search availability', async () => { mocks.search.mockRejectedValue(new Error('Feature configuration unavailable')) await expect( authorizeOrganizationSettingsSection({ @@ -100,7 +100,8 @@ describe('organization settings authorization', () => { userId: 'admin', section: 'connected-accounts', }) - ).rejects.toThrow('Feature configuration unavailable') + ).resolves.toBe(true) + expect(mocks.search).not.toHaveBeenCalled() }) it('checks current target organization membership before billing reads', async () => { diff --git a/apps/sim/lib/settings/application/organization-section-access.ts b/apps/sim/lib/settings/application/organization-section-access.ts index bf762ea9e8a..7427638a686 100644 --- a/apps/sim/lib/settings/application/organization-section-access.ts +++ b/apps/sim/lib/settings/application/organization-section-access.ts @@ -24,9 +24,7 @@ export async function authorizeOrganizationSettingsSection({ if (!(await canOpenOrganizationSettingsSection(organizationId, userId, section))) return false if (section === 'connected-accounts') { - if (!(await isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }))) - return false - return !(await isKnowledgeMemberAccessAvailable({ organizationId })) + return isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }) } if (section === 'search-mcp' || section === 'search-slack' || section === 'integrations') return isKnowledgeMemberAccessAvailable({ organizationId }) diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index cb9724ac59e..248d85372c1 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -336,10 +336,10 @@ describe('authorizeWorkspaceSettingsSection', () => { it.each([ { groups: true, search: false, allowed: true }, { groups: false, search: false, allowed: false }, - { groups: true, search: true, allowed: false }, + { groups: true, search: true, allowed: true }, { groups: false, search: true, allowed: false }, ])( - 'gates Connected accounts with organization groups=$groups and search=$search', + 'gates Credential Groups with organization groups=$groups and search=$search', async ({ groups, search, allowed }) => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) mocks.isScopedCredentialGroupsAvailable.mockResolvedValue(groups) @@ -357,11 +357,7 @@ describe('authorizeWorkspaceSettingsSection', () => { kind: 'organization', organizationId: 'organization-1', }) - if (groups) { - expect(mocks.isKnowledgeMemberAccessAvailable).toHaveBeenCalledWith({ - organizationId: 'organization-1', - }) - } + expect(mocks.isKnowledgeMemberAccessAvailable).not.toHaveBeenCalled() expect(mocks.isOrganizationOnEnterprisePlan).not.toHaveBeenCalled() } ) @@ -385,9 +381,9 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() }) - it('propagates feature lookup failures instead of opening Connected accounts', async () => { + it('propagates feature lookup failures instead of opening Credential Groups', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) - mocks.isKnowledgeMemberAccessAvailable.mockRejectedValue(new Error('Feature lookup failed')) + mocks.isScopedCredentialGroupsAvailable.mockRejectedValue(new Error('Feature lookup failed')) await expect(authorize('connected-accounts')).rejects.toThrow('Feature lookup failed') }) diff --git a/packages/db/credential-group-resource-policies.ts b/packages/db/credential-group-resource-policies.ts index 28d70c986fd..c21292ed4fb 100644 --- a/packages/db/credential-group-resource-policies.ts +++ b/packages/db/credential-group-resource-policies.ts @@ -370,41 +370,76 @@ export function validateOrganizationAccountPolicyDocument( requireCanonicalId(resource.id, 'Organization account resource ID') !== expectedResourceId ) throw new Error('Organization account policy resource does not match its canonical resource') - if (!Array.isArray(document.statements) || document.statements.length > 1) - throw new Error('Organization account policy supports only workspace access') - if (document.statements.length === 0) return - const statement = requireRecord( - document.statements[0], - 'Organization account workspace statement' - ) - requireExactKeys( - statement, - ['sid', 'effect', 'actions', 'principals'], - 'Organization account workspace statement' - ) - if ( - statement.sid !== 'WorkspaceCredentialAccess' || - statement.effect !== 'allow' || - !Array.isArray(statement.actions) || - statement.actions.length !== 1 || - statement.actions[0] !== CREDENTIAL_USE_ACTION - ) - throw new Error('Organization account workspace statement is invalid') - if ( - !Array.isArray(statement.principals) || - statement.principals.length < 1 || - statement.principals.length > 1000 - ) - throw new Error('Organization account policy supports 1-1000 workspaces') - let previous = '' - for (const value of statement.principals) { - const principal = requireRecord(value, 'Organization account workspace principal') - requireExactKeys(principal, ['type', 'workspaceId'], 'Organization account workspace principal') - const id = requireCanonicalId(principal.workspaceId, 'Organization account workspace ID') - if (principal.type !== 'workspace' || id <= previous) - throw new Error('Organization account workspace principals must be unique and sorted') - previous = id + if (!Array.isArray(document.statements) || document.statements.length > 128) + throw new Error('Organization account policy has too many statements') + const seenStatements = new Set() + const allWorkspaces = new Set() + const selectedWorkspaces = new Set() + for (const value of document.statements) { + const statement = requireRecord(value, 'Organization account workspace statement') + requireExactKeys( + statement, + [ + 'sid', + 'effect', + 'actions', + 'principals', + ...(statement.condition === undefined ? [] : ['condition']), + ], + 'Organization account workspace statement' + ) + let credentialType: string | undefined + if (statement.condition !== undefined) { + const condition = requireRecord(statement.condition, 'Credential type condition') + requireExactKeys(condition, ['StringEquals'], 'Credential type condition') + const equals = requireRecord(condition.StringEquals, 'Credential type StringEquals') + requireExactKeys(equals, ['credential_group:CredentialType'], 'Credential type StringEquals') + credentialType = requireCanonicalId( + equals['credential_group:CredentialType'], + 'Credential type' + ) + if (!/^(oauth|mcp|personal_token):[a-z][a-z0-9-]*$/.test(credentialType)) + throw new Error('Invalid credential type') + } + const sid = credentialType + ? `WorkspaceCredentialAccess:${credentialType}` + : 'WorkspaceCredentialAccess' + if ( + statement.sid !== sid || + seenStatements.has(sid) || + statement.effect !== 'allow' || + !Array.isArray(statement.actions) || + statement.actions.length !== 1 || + statement.actions[0] !== CREDENTIAL_USE_ACTION + ) + throw new Error('Organization account workspace statement is invalid') + seenStatements.add(sid) + if ( + !Array.isArray(statement.principals) || + statement.principals.length < 1 || + statement.principals.length > 1000 + ) + throw new Error('Organization account policy supports 1-1000 workspaces') + let previous = '' + for (const value of statement.principals) { + const principal = requireRecord(value, 'Organization account workspace principal') + requireExactKeys( + principal, + ['type', 'workspaceId'], + 'Organization account workspace principal' + ) + const id = requireCanonicalId(principal.workspaceId, 'Organization account workspace ID') + if (principal.type !== 'workspace' || id <= previous) + throw new Error('Organization account workspace principals must be unique and sorted') + previous = id + const workspaces = credentialType ? selectedWorkspaces : allWorkspaces + workspaces.add(id) + } } + if ([...allWorkspaces].some((id) => selectedWorkspaces.has(id))) + throw new Error('Workspace has overlapping all and selected grants') + if (new Set([...allWorkspaces, ...selectedWorkspaces]).size > 1000) + throw new Error('Organization account policy supports at most 1000 workspaces') } function assertPage( diff --git a/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts b/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts index b412333b8d3..94d45698e92 100644 --- a/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts +++ b/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts @@ -381,6 +381,46 @@ describe('organization account policy validation', () => { ] : [], }) + it('accepts integration conditions without rewriting organization policy grants', () => { + const statement = policy(['a']).statements[0] + const typed = { + ...policy([]), + statements: [ + { + ...statement, + sid: 'WorkspaceCredentialAccess:oauth:gmail', + condition: { StringEquals: { 'credential_group:CredentialType': 'oauth:gmail' } }, + }, + ], + } + expect(() => validateOrganizationAccountPolicyDocument(typed, 'group-org')).not.toThrow() + expect(() => + validateOrganizationAccountPolicyDocument( + { ...typed, statements: [...typed.statements, statement] }, + 'group-org' + ) + ).toThrow('overlapping') + expect(() => + validateOrganizationAccountPolicyDocument( + { ...typed, statements: [...typed.statements, ...typed.statements] }, + 'group-org' + ) + ).toThrow('invalid') + expect(() => + validateOrganizationAccountPolicyDocument( + { + ...typed, + statements: [ + { + ...typed.statements[0], + condition: { StringNotEquals: { 'credential_group:CredentialType': 'oauth:gmail' } }, + }, + ], + }, + 'group-org' + ) + ).toThrow() + }) it('accepts deny-by-default and the maximum workspace allowlist', () => { expect(() => validateOrganizationAccountPolicyDocument(policy([]), 'group-org')).not.toThrow() expect(() => diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 7f6ee38c1af..e2ca0a52ad2 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -6,68 +6,68 @@ }, "entries": { "app/api/v2/blocks/[blockId]/route.ts": { - "modules": 1600, + "modules": 1633, "gateways": { "apps/sim/triggers/index.ts": 487, + "apps/sim/lib/api/server/routes/index.ts": 486, "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/api/server/routes/index.ts": 461, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 409, - "apps/sim/lib/auth/index.ts": 396, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/lib/webhooks/providers/index.ts": 118, - "apps/sim/lib/webhooks/providers/registry.ts": 116 + "apps/sim/lib/api/server/routes/internal-json-route.ts": 431, + "apps/sim/lib/auth/index.ts": 418, + "apps/sim/blocks/registry.ts": 359, + "apps/sim/lib/webhooks/providers/index.ts": 117, + "apps/sim/lib/webhooks/providers/registry.ts": 115 } }, "app/api/v2/blocks/route.ts": { - "modules": 1599, + "modules": 1632, "gateways": { "apps/sim/triggers/index.ts": 487, "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/api/server/routes/index.ts": 455, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 412, - "apps/sim/lib/auth/index.ts": 399, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/lib/webhooks/providers/index.ts": 119, - "apps/sim/lib/webhooks/providers/registry.ts": 116 + "apps/sim/lib/api/server/routes/index.ts": 480, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 434, + "apps/sim/lib/auth/index.ts": 421, + "apps/sim/blocks/registry.ts": 359, + "apps/sim/lib/webhooks/providers/index.ts": 118, + "apps/sim/lib/webhooks/providers/registry.ts": 115 } }, "app/api/v2/connector-types/route.ts": { - "modules": 1664, + "modules": 1697, "gateways": { + "apps/sim/lib/api/server/routes/index.ts": 488, "apps/sim/triggers/index.ts": 487, "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/api/server/routes/index.ts": 463, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 411, - "apps/sim/lib/auth/index.ts": 398, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 433, + "apps/sim/lib/auth/index.ts": 420, "apps/sim/blocks/registry.ts": 360, - "apps/sim/lib/webhooks/providers/index.ts": 119, - "apps/sim/lib/webhooks/providers/registry.ts": 116 + "apps/sim/lib/webhooks/providers/index.ts": 118, + "apps/sim/lib/webhooks/providers/registry.ts": 115 } }, "app/api/v2/tools/[toolId]/route.ts": { - "modules": 1597, + "modules": 1630, "gateways": { + "apps/sim/lib/api/server/routes/index.ts": 487, "apps/sim/triggers/index.ts": 487, "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/api/server/routes/index.ts": 462, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 410, - "apps/sim/lib/auth/index.ts": 397, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 432, + "apps/sim/lib/auth/index.ts": 419, "apps/sim/blocks/registry.ts": 360, - "apps/sim/lib/webhooks/providers/index.ts": 119, - "apps/sim/lib/webhooks/providers/registry.ts": 116 + "apps/sim/lib/webhooks/providers/index.ts": 118, + "apps/sim/lib/webhooks/providers/registry.ts": 115 } }, "app/api/v2/tools/route.ts": { - "modules": 1598, + "modules": 1631, "gateways": { "apps/sim/triggers/index.ts": 487, "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/api/server/routes/index.ts": 453, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 410, - "apps/sim/lib/auth/index.ts": 397, + "apps/sim/lib/api/server/routes/index.ts": 478, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 432, + "apps/sim/lib/auth/index.ts": 419, "apps/sim/blocks/registry.ts": 360, - "apps/sim/lib/webhooks/providers/index.ts": 119, - "apps/sim/lib/webhooks/providers/registry.ts": 116 + "apps/sim/lib/webhooks/providers/index.ts": 118, + "apps/sim/lib/webhooks/providers/registry.ts": 115 } }, "app/workspace/[workspaceId]/access-requests/loading.tsx": { @@ -75,17 +75,17 @@ "gateways": {} }, "app/workspace/[workspaceId]/access-requests/page.tsx": { - "modules": 44, + "modules": 45, "gateways": { - "apps/sim/components/access-requests/my-access-requests.tsx": 42 + "apps/sim/components/access-requests/my-access-requests.tsx": 43 } }, "app/workspace/[workspaceId]/chat/[chatId]/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/chat/[chatId]/layout.tsx": { @@ -93,89 +93,89 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3068, + "modules": 3125, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1498, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 910, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 743, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 740, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1521, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 961, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 795, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 790, "apps/sim/triggers/registry.ts": 485, "apps/sim/blocks/registry.ts": 330, - "apps/sim/lib/auth/index.ts": 267, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 201 + "apps/sim/lib/auth/index.ts": 271, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 200 } }, "app/workspace/[workspaceId]/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/files/[fileId]/loading.tsx": { - "modules": 159, + "modules": 154, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 2176, + "modules": 2186, "gateways": { "apps/sim/triggers/registry.ts": 485, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 411, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 417, "apps/sim/blocks/registry.ts": 355, - "apps/sim/lib/auth/index.ts": 274, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 214, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 183, - "apps/sim/lib/webhooks/providers/index.ts": 117, - "apps/sim/lib/webhooks/providers/registry.ts": 115 + "apps/sim/lib/auth/index.ts": 276, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 217, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 186, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 117, + "apps/sim/lib/webhooks/providers/index.ts": 117 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 65, + "modules": 68, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 64, - "apps/sim/hooks/queries/workspace-files.ts": 60 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 67, + "apps/sim/hooks/queries/workspace-files.ts": 63 } }, "app/workspace/[workspaceId]/files/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/files/loading.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 2175, + "modules": 2185, "gateways": { "apps/sim/triggers/registry.ts": 485, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 412, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 418, "apps/sim/blocks/registry.ts": 355, - "apps/sim/lib/auth/index.ts": 274, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 214, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 183, - "apps/sim/lib/webhooks/providers/index.ts": 117, - "apps/sim/lib/webhooks/providers/registry.ts": 115 + "apps/sim/lib/auth/index.ts": 276, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 217, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 186, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 117, + "apps/sim/lib/webhooks/providers/index.ts": 117 } }, "app/workspace/[workspaceId]/home/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -183,224 +183,203 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3068, + "modules": 3125, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1498, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 910, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 743, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 740, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1521, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 961, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 795, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 790, "apps/sim/triggers/registry.ts": 485, "apps/sim/blocks/registry.ts": 330, - "apps/sim/lib/auth/index.ts": 267, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 201 + "apps/sim/lib/auth/index.ts": 271, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 200 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1166, + "modules": 1189, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1133, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1115, "apps/sim/triggers/index.ts": 524, "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 367, - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx": 63, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 59, - "apps/sim/lib/api/contracts/index.ts": 34, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx": 62, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 58, + "apps/sim/lib/api/contracts/index.ts": 33, "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1213, + "modules": 1214, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1103, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1173, "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 373, "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 102, "apps/sim/components/permissions/index.ts": 89, "apps/sim/components/permissions/add-people-modal.tsx": 80, "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 78, - "apps/sim/lib/api/contracts/index.ts": 40 + "apps/sim/lib/api/contracts/index.ts": 39 } }, "app/workspace/[workspaceId]/integrations/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1140, + "modules": 1164, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 983, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1012, "apps/sim/blocks/registry.ts": 895, "apps/sim/triggers/index.ts": 524, "apps/sim/triggers/registry.ts": 522, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, - "apps/sim/lib/api/contracts/index.ts": 34, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 59, + "apps/sim/lib/api/contracts/index.ts": 33, "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx": { - "modules": 159, + "modules": 154, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1368, + "modules": 1379, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1208, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1198, "apps/sim/triggers/registry.ts": 522, - "apps/sim/blocks/registry.ts": 362, - "apps/sim/blocks/registry-maps.ts": 359, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 86, + "apps/sim/blocks/registry.ts": 366, + "apps/sim/blocks/registry-maps.ts": 363, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 85, "apps/sim/connectors/registry.ts": 67, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 66, - "apps/sim/lib/api/contracts/index.ts": 33 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 61, + "apps/sim/lib/api/contracts/index.ts": 32 } }, "app/workspace/[workspaceId]/knowledge/[id]/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/knowledge/[id]/loading.tsx": { - "modules": 160, + "modules": 155, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1478, + "modules": 1503, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1317, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1321, "apps/sim/triggers/registry.ts": 522, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 93, + "apps/sim/blocks/registry.ts": 361, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 114, "apps/sim/connectors/registry.ts": 67, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 57, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 64, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 35, "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/knowledge/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/knowledge/loading.tsx": { - "modules": 159, + "modules": 154, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2376, + "modules": 2389, "gateways": { "apps/sim/triggers/registry.ts": 485, "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 408, "apps/sim/blocks/registry.ts": 355, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 347, - "apps/sim/lib/auth/index.ts": 226, - "apps/sim/lib/knowledge/orchestration/index.ts": 211, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 207, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 203 + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 346, + "apps/sim/lib/auth/index.ts": 229, + "apps/sim/lib/knowledge/orchestration/index.ts": 210, + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 209, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 206 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2302, + "modules": 2309, "gateways": { "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/auth/index.ts": 402, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 387, + "apps/sim/lib/auth/index.ts": 404, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 390, "apps/sim/blocks/registry.ts": 352, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 256, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 259, "apps/sim/lib/webhooks/providers/index.ts": 117, "apps/sim/lib/webhooks/providers/registry.ts": 115, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/index.ts": 91 } }, "app/workspace/[workspaceId]/logs/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/logs/loading.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1729, + "modules": 1744, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1571, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1591, "apps/sim/triggers/registry.ts": 522, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 448, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 400, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 394, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 358, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 465, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 417, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 411, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 350, "apps/sim/blocks/registry.ts": 350, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 347 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 343 } }, "app/workspace/[workspaceId]/not-found.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/page.tsx": { "modules": 5, "gateways": {} }, - "app/workspace/[workspaceId]/search/error.tsx": { - "modules": 157, - "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 - } - }, - "app/workspace/[workspaceId]/search/page.tsx": { - "modules": 1938, - "gateways": { - "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/auth/index.ts": 443, - "apps/sim/blocks/registry.ts": 356, - "apps/sim/app/workspace/[workspaceId]/search/search.tsx": 288, - "apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx": 163, - "apps/sim/lib/webhooks/providers/index.ts": 118, - "apps/sim/lib/webhooks/providers/registry.ts": 116, - "apps/sim/connectors/registry.ts": 67 - } - }, "app/workspace/[workspaceId]/settings/[section]/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/settings/[section]/layout.tsx": { @@ -412,16 +391,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2348, + "modules": 2384, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 739, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 736, "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/auth/index.ts": 378, - "apps/sim/blocks/registry.ts": 354, + "apps/sim/lib/auth/index.ts": 382, + "apps/sim/blocks/registry.ts": 355, "apps/sim/lib/webhooks/providers/index.ts": 117, "apps/sim/lib/webhooks/providers/registry.ts": 115, - "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 80, - "apps/sim/ee/access-control/components/access-control.tsx": 75 + "apps/sim/ee/access-control/components/access-control.tsx": 80, + "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 79 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -433,24 +412,24 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1556, + "modules": 1590, "gateways": { - "apps/sim/lib/auth/index.ts": 1413, + "apps/sim/lib/auth/index.ts": 1443, "apps/sim/triggers/index.ts": 487, "apps/sim/triggers/registry.ts": 485, "apps/sim/blocks/registry.ts": 363, "apps/sim/blocks/registry-maps.ts": 360, - "apps/sim/lib/webhooks/providers/index.ts": 121, - "apps/sim/lib/webhooks/providers/registry.ts": 118, - "apps/sim/lib/workflows/lifecycle.ts": 50 + "apps/sim/lib/webhooks/providers/index.ts": 120, + "apps/sim/lib/webhooks/providers/registry.ts": 117, + "apps/sim/lib/workflows/lifecycle.ts": 52 } }, "app/workspace/[workspaceId]/settings/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/settings/layout.tsx": { @@ -462,12 +441,12 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/loading.tsx": { - "modules": 1141, + "modules": 1146, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1138, - "apps/sim/components/permissions/index.ts": 1005, - "apps/sim/components/permissions/add-people-modal.tsx": 996, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 994, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1143, + "apps/sim/components/permissions/index.ts": 1008, + "apps/sim/components/permissions/add-people-modal.tsx": 999, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 997, "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 375, "apps/sim/blocks/registry-maps.ts": 372, @@ -475,16 +454,16 @@ } }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1226, + "modules": 1225, "gateways": { "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 375, "apps/sim/blocks/registry-maps.ts": 372, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 95, - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 84, - "apps/sim/components/permissions/index.ts": 84, - "apps/sim/components/permissions/add-people-modal.tsx": 75, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 73 + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 99, + "apps/sim/components/permissions/index.ts": 88, + "apps/sim/components/permissions/add-people-modal.tsx": 79, + "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 78, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 77 } }, "app/workspace/[workspaceId]/settings/usage/events/layout.tsx": { @@ -496,9 +475,9 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/usage/events/page.tsx": { - "modules": 1595, + "modules": 1597, "gateways": { - "apps/sim/lib/auth/index.ts": 1441, + "apps/sim/lib/auth/index.ts": 1443, "apps/sim/triggers/index.ts": 487, "apps/sim/triggers/registry.ts": 485, "apps/sim/blocks/registry.ts": 363, @@ -509,104 +488,104 @@ } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1373, + "modules": 1383, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1372, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1382, "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 367, "apps/sim/blocks/registry-maps.ts": 365, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 161, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 158, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 80, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 78 + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 171, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 168, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 91, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 89 } }, "app/workspace/[workspaceId]/skills/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1371, + "modules": 1381, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1370, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1380, "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 367, "apps/sim/blocks/registry-maps.ts": 365, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 161, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 158, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 80, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 78 + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 171, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 168, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 91, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 89 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1106, + "modules": 1102, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 949, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 937, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 950, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 938, "apps/sim/blocks/registry.ts": 928, "apps/sim/blocks/registry-maps.ts": 926, "apps/sim/triggers/index.ts": 524, "apps/sim/triggers/registry.ts": 522, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 67, - "apps/sim/lib/api/contracts/index.ts": 43 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, + "apps/sim/lib/api/contracts/index.ts": 44 } }, "app/workspace/[workspaceId]/tables/[tableId]/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/tables/[tableId]/loading.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 1874, + "modules": 1871, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1696, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1692, "apps/sim/triggers/registry.ts": 522, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 366, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 365, "apps/sim/blocks/registry.ts": 331, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 320, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 316, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 265, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 259 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 319, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 315, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 264, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 258 } }, "app/workspace/[workspaceId]/tables/error.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/tables/loading.tsx": { - "modules": 157, + "modules": 152, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1971, + "modules": 1978, "gateways": { "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/auth/index.ts": 416, + "apps/sim/lib/auth/index.ts": 420, "apps/sim/blocks/registry.ts": 354, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 192, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 195, "apps/sim/lib/webhooks/providers/index.ts": 117, "apps/sim/lib/webhooks/providers/registry.ts": 115, "apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts": 98, @@ -614,9 +593,9 @@ } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 141, + "modules": 143, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 134, + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 136, "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 84, "apps/sim/lib/billing/client/upgrade.ts": 76, "apps/sim/hooks/queries/organization.ts": 71, @@ -625,44 +604,44 @@ } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 159, + "modules": 154, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 158, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 98, - "apps/sim/hooks/queries/copilot-feedback.ts": 60 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 153, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 116, + "apps/sim/hooks/queries/copilot-feedback.ts": 65 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2155, + "modules": 2168, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2154, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2167, "apps/sim/triggers/registry.ts": 522, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 371, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 379, "apps/sim/blocks/registry.ts": 350, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 334, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 268, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 342, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 275, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 168, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 149 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2136, + "modules": 2149, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 950, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 624, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 963, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 633, "apps/sim/triggers/registry.ts": 522, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 355, "apps/sim/blocks/registry.ts": 350, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 347, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 175, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 155, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 150 } }, "app/workspace/layout.tsx": { - "modules": 1107, + "modules": 1112, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1097, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1102, "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 375, "apps/sim/blocks/registry-maps.ts": 372, @@ -673,16 +652,16 @@ } }, "app/workspace/page.tsx": { - "modules": 1109, + "modules": 1112, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 1014, + "apps/sim/lib/auth/stale-session-recovery.ts": 1016, "apps/sim/triggers/index.ts": 524, "apps/sim/triggers/registry.ts": 522, "apps/sim/blocks/registry.ts": 375, "apps/sim/blocks/registry-maps.ts": 372, - "apps/sim/stores/workflows/registry/store.ts": 41, - "apps/sim/lib/api/contracts/index.ts": 38, - "apps/sim/hooks/queries/deployments.ts": 36 + "apps/sim/stores/workflows/registry/store.ts": 43, + "apps/sim/hooks/queries/deployments.ts": 38, + "apps/sim/lib/api/contracts/index.ts": 38 } }, "lib/catalog/projection/block-detail.ts": { @@ -690,8 +669,8 @@ "gateways": { "apps/sim/triggers/index.ts": 524, "apps/sim/triggers/registry.ts": 522, - "apps/sim/lib/catalog/projection/block-summary.ts": 406, - "apps/sim/blocks/registry-maps.ts": 402, + "apps/sim/lib/catalog/projection/block-summary.ts": 405, + "apps/sim/blocks/registry-maps.ts": 401, "apps/sim/triggers/clickup/index.ts": 32 } }, @@ -718,16 +697,16 @@ "gateways": {} }, "lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts": { - "modules": 1076, + "modules": 1077, "gateways": { "apps/sim/triggers/index.ts": 524, "apps/sim/triggers/registry.ts": 522, - "apps/sim/blocks/registry.ts": 399, - "apps/sim/blocks/registry-maps.ts": 397, - "apps/sim/lib/permission-groups/config-scope.server.ts": 94, - "apps/sim/lib/permission-groups/resolve.server.ts": 92, - "apps/sim/lib/billing/core/subscription.ts": 86, - "apps/sim/components/emails/index.ts": 54 + "apps/sim/blocks/registry.ts": 398, + "apps/sim/blocks/registry-maps.ts": 396, + "apps/sim/lib/permission-groups/config-scope.server.ts": 95, + "apps/sim/lib/permission-groups/resolve.server.ts": 93, + "apps/sim/lib/billing/core/subscription.ts": 87, + "apps/sim/components/emails/index.ts": 55 } } } From 18d6048e76de95c328823aa6a6c1af6b79b6c39b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 16 Sep 2026 12:00:23 -0700 Subject: [PATCH 2/2] fix(credential-groups): refresh selectors and validate credential ownership --- .../[workspaceId]/settings/navigation.test.ts | 2 +- .../organization-workspace-grant-modal.tsx | 26 ++++-- .../queries/organization-accounts.test.tsx | 17 ++++ .../hooks/queries/organization-accounts.ts | 12 +++ .../workspace-organization-accounts.test.ts | 87 +++++++++++++++++++ .../workspace-organization-accounts.ts | 12 ++- .../application/personal-credentials.test.ts | 18 +++- .../workspace-account-visibility.test.ts | 53 +++++++++-- .../workspace-account-visibility.ts | 23 ++++- 9 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/credential-groups/application/workspace-organization-accounts.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index e1b26dc32a7..246a11b9cc9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -33,7 +33,7 @@ describe('unified settings navigation', () => { { id: 'organization', label: 'Members', section: 'organization' }, { id: 'usage', label: 'Insights', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, - { id: 'connected-accounts', label: 'Connected accounts', section: 'organization' }, + { id: 'connected-accounts', label: 'Credential Groups', section: 'organization' }, { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, { id: 'mcp', label: 'MCP tools', section: 'workspace' }, { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, diff --git a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx index 84118767f1d..fdc2eb01f69 100644 --- a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx +++ b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.tsx @@ -17,17 +17,33 @@ import { isOrganizationCredentialType } from '@/lib/credential-groups/credential type Grant = OrganizationAccountWorkspaceAccess['grants'][number] const ALL_INTEGRATIONS = 'all' -type OrganizationWorkspaceGrantModalProps = { +interface OrganizationWorkspaceGrantModalBaseProps { credentialTypes: OrganizationAccountWorkspaceAccess['credentialTypes'] disabled: boolean error?: string onSave: (grant: Grant) => void onClose: () => void -} & ( - | { mode: 'create'; workspaces: OrganizationAccountWorkspaceAccess['workspaces'] } - | { mode: 'edit'; grant: Grant; workspaceName: string; onRemove: () => void } -) +} + +interface CreateOrganizationWorkspaceGrantModalProps + extends OrganizationWorkspaceGrantModalBaseProps { + mode: 'create' + workspaces: OrganizationAccountWorkspaceAccess['workspaces'] +} + +interface EditOrganizationWorkspaceGrantModalProps + extends OrganizationWorkspaceGrantModalBaseProps { + mode: 'edit' + grant: Grant + workspaceName: string + onRemove: () => void +} + +type OrganizationWorkspaceGrantModalProps = + | CreateOrganizationWorkspaceGrantModalProps + | EditOrganizationWorkspaceGrantModalProps +/** All integrations is an explicit grant; an empty picker selection never grants access. */ export function OrganizationWorkspaceGrantModal(props: OrganizationWorkspaceGrantModalProps) { const { credentialTypes, disabled, error, onSave, onClose } = props const [workspaceId, setWorkspaceId] = useState( diff --git a/apps/sim/hooks/queries/organization-accounts.test.tsx b/apps/sim/hooks/queries/organization-accounts.test.tsx index 69f33d1f871..9752436158c 100644 --- a/apps/sim/hooks/queries/organization-accounts.test.tsx +++ b/apps/sim/hooks/queries/organization-accounts.test.tsx @@ -27,6 +27,7 @@ import { import { slackSearchKeys } from '@/hooks/queries/slack-search' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +import { selectorKeys, selectorQueryRoots } from '@/hooks/queries/utils/selector-keys' describe('personal account disconnect', () => { it.each([true, false])( @@ -163,6 +164,20 @@ describe('organization account setup updates', () => { const other = slackSearchKeys.manifest('org-2', 'Sim Search') const overview = searchSourceKeys.organizationOverview('org-1') const otherOverview = searchSourceKeys.organizationOverview('org-2') + const providerSelectors = [ + selectorKeys.scoped( + 'workspace.credentialGroupProviders', + { kind: 'workspace', workspaceId: 'workspace-1' }, + 'block-1' + ), + selectorKeys.scoped( + 'workspace.organizationMcpProviders', + { kind: 'workspace', workspaceId: 'workspace-1' }, + 'block-2' + ), + [...selectorQueryRoots.workflowSearchReplace, 'workflow-1'], + ] + for (const key of providerSelectors) client.setQueryData(key, { options: ['cached'] }) for (const key of [current, renamed, other]) client.setQueryData(key, { existingApp: 'A1' }) for (const key of [overview, otherOverview]) client.setQueryData(key, { providers: [] }) try { @@ -200,6 +215,8 @@ describe('organization account setup updates', () => { expect(client.getQueryState(other)?.isInvalidated).toBe(false) expect(client.getQueryState(overview)?.isInvalidated).toBe(success) expect(client.getQueryState(otherOverview)?.isInvalidated).toBe(false) + for (const key of providerSelectors) + expect(client.getQueryState(key)?.isInvalidated).toBe(success) } finally { await act(async () => root.unmount()) client.clear() diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 60bc27d401b..4efb5095aea 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -43,6 +43,7 @@ import { slackSearchKeys } from '@/hooks/queries/slack-search' import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 @@ -65,6 +66,9 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string) onSuccess: async () => { await Promise.all([ resetOrganizationSearchAccess(queryClient, organizationId), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId), }), @@ -149,6 +153,7 @@ export function useConfigureOrganizationMcp() { queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -177,6 +182,7 @@ export function useUpdateOrganizationAccounts() { queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), queryClient.invalidateQueries({ queryKey: slackSearchKeys.organizationManifests(organizationId), }), @@ -245,6 +251,7 @@ export function useUpdateOrganizationAccountWorkspaceAccess() { queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -334,6 +341,9 @@ export function useRevokeOrganizationAccountEnrollment() { onSuccess: (_, { organizationId }) => Promise.all([ resetOrganizationSearchAccess(queryClient, organizationId), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId), }), @@ -359,6 +369,7 @@ export function useAddOrganizationAccountMcpProvider() { queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -383,6 +394,7 @@ export function useRemoveOrganizationAccountMcpProvider() { queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } diff --git a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.test.ts b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.test.ts new file mode 100644 index 00000000000..dbb592c9a22 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.test.ts @@ -0,0 +1,87 @@ +/** @vitest-environment node */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ group: vi.fn(), policy: vi.fn() })) +vi.mock('@/lib/credential-groups/application/context', () => ({ + resolveCredentialGroupWorkspaceContext: async () => ({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + }), +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('read'), + permissionSatisfies: (permission: string, required: string) => permission === required, +})) +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadScopedAccountsCredentialListContext: mocks.group, +})) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: vi.fn().mockResolvedValue(true), +})) +vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.policy })) + +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts' + +function read() { + return getWorkspaceOrganizationAccounts.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1' }, + }) +} + +describe('workspace organization provider projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(schemaMock.organization, [{ name: 'Organization' }]) + queueTableRows(schemaMock.member, [{ role: 'member' }]) + queueTableRows(schemaMock.mcpServers, [{ connectorId: 'fireflies' }]) + mocks.group.mockResolvedValue({ + credentialGroupId: 'group-1', + status: 'active', + options: [ + { provider: 'gmail', status: 'active' }, + { provider: 'google-calendar', status: 'active' }, + { provider: 'retired-provider', status: 'disabled' }, + ], + }) + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { workspaceId: 'workspace-1', access: { mode: 'all' } }, + ]), + }) + }) + + it('ignores disabled legacy options before validating active providers', async () => { + const result = await read() + expect(result.providers.map(({ id }) => id)).toEqual(['google-email', 'google-calendar']) + expect(result.mcpProviders.map(({ id }) => id)).toEqual(['fireflies']) + }) + + it('projects only credential types allowed for the current workspace', async () => { + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }, + ]), + }) + const result = await read() + expect(result.allowed).toBe(true) + expect(result.providers.map(({ id }) => id)).toEqual(['google-email']) + expect(result.mcpProviders).toEqual([]) + }) + + it('fails fast for an unregistered active provider', async () => { + mocks.group.mockResolvedValue({ + credentialGroupId: 'group-1', + status: 'active', + options: [{ provider: 'unknown', status: 'active' }], + }) + await expect(read()).rejects.toThrow('Unsupported organization provider: unknown') + }) +}) diff --git a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts index ec88a78c09e..e09baa58387 100644 --- a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts @@ -78,15 +78,13 @@ export const getWorkspaceOrganizationAccounts = defineAuthorizedWorkspaceUseCase if (!result.allowed) return result result.providers = group.options .filter((option) => { + if (option.status !== 'active') return false if (!isCredentialGroupProvider(option.provider)) throw new Error(`Unsupported organization provider: ${option.provider}`) - return ( - option.status === 'active' && - organizationAccountPolicyAllowsWorkspace( - policy.document, - context.workspaceId, - `oauth:${option.provider}` - ) + return organizationAccountPolicyAllowsWorkspace( + policy.document, + context.workspaceId, + `oauth:${option.provider}` ) }) .map((option) => { diff --git a/apps/sim/lib/credentials/application/personal-credentials.test.ts b/apps/sim/lib/credentials/application/personal-credentials.test.ts index 732f692b03c..ccad2c1d180 100644 --- a/apps/sim/lib/credentials/application/personal-credentials.test.ts +++ b/apps/sim/lib/credentials/application/personal-credentials.test.ts @@ -117,7 +117,14 @@ describe('personal credential application access', () => { } mocks.listTokens.mockResolvedValue([token]) queueTableRows(schemaMock.credential, [ - { ...token, organizationId: null, groupId: 'legacy-group' }, + { + ...token, + organizationId: null, + workspaceId: 'workspace-1', + groupId: 'legacy-group', + groupOrganizationId: null, + groupWorkspaceId: 'workspace-1', + }, ]) const result = await listPersonalCredentials.execute({ principal, @@ -142,7 +149,14 @@ describe('personal credential application access', () => { const managed = { ...personalCredential, providerId: 'slack', type: 'managed_oauth' as const } mocks.listPersonal.mockResolvedValue([managed]) queueTableRows(schemaMock.credential, [ - { ...managed, organizationId: null, groupId: 'legacy-group' }, + { + ...managed, + organizationId: null, + workspaceId: 'workspace-1', + groupId: 'legacy-group', + groupOrganizationId: null, + groupWorkspaceId: 'workspace-1', + }, ]) const result = await authorizePersonalCredential.execute({ diff --git a/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts b/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts index e3e281032ba..69d94bd8b47 100644 --- a/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts +++ b/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts @@ -18,9 +18,14 @@ const entries = [ { id: 'calendar', type: 'managed_oauth', providerId: 'google-calendar' }, { id: 'token', type: 'personal_token', providerId: 'gitlab' }, ] -const bindings = entries - .slice(1) - .map((entry) => ({ ...entry, organizationId: 'org', groupId: 'group' })) +const bindings = entries.slice(1).map((entry) => ({ + ...entry, + organizationId: 'org', + workspaceId: null, + groupId: 'group', + groupOrganizationId: 'org', + groupWorkspaceId: null, +})) beforeEach(() => { vi.clearAllMocks() @@ -50,7 +55,10 @@ describe('workspace organization credential visibility', () => { expect(dbChainMockFns.select).toHaveBeenCalledExactlyOnceWith({ id: schemaMock.credential.id, organizationId: schemaMock.credential.organizationId, - groupId: schemaMock.credentialGroupEnrollment.credentialGroupId, + workspaceId: schemaMock.credential.workspaceId, + groupId: schemaMock.credentialGroup.id, + groupOrganizationId: schemaMock.credentialGroup.organizationId, + groupWorkspaceId: schemaMock.credentialGroup.workspaceId, providerId: schemaMock.credential.providerId, type: schemaMock.credential.type, }) @@ -87,13 +95,48 @@ describe('workspace organization credential visibility', () => { it('preserves independently managed workspace accounts', async () => { queueTableRows( schemaMock.credential, - bindings.map((binding) => ({ ...binding, organizationId: null })) + bindings.map((binding) => ({ + ...binding, + organizationId: null, + workspaceId: 'ws', + groupOrganizationId: null, + groupWorkspaceId: 'ws', + })) ) expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual(entries) expect(mocks.available).not.toHaveBeenCalled() expect(mocks.policy).not.toHaveBeenCalled() }) + it.each([ + { groupOrganizationId: 'other-org', groupWorkspaceId: null }, + { groupOrganizationId: null, groupWorkspaceId: 'ws' }, + ])('rejects mismatched group ownership before loading policy: %j', async (owner) => { + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ ...binding, ...owner })) + ) + await expect(filterWorkspaceAccountCredentials(context, entries)).rejects.toThrow( + 'Credential and enrollment group owners do not match' + ) + expect(mocks.policy).not.toHaveBeenCalled() + }) + + it('hides independently managed credentials that moved to another workspace', async () => { + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ + ...binding, + organizationId: null, + workspaceId: 'other-ws', + groupOrganizationId: null, + groupWorkspaceId: 'other-ws', + })) + ) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + expect(mocks.policy).not.toHaveBeenCalled() + }) + it('throws for malformed policy or a changed canonical provider instead of granting access', async () => { queueTableRows(schemaMock.credential, bindings) mocks.policy.mockRejectedValueOnce(new Error('Malformed policy')) diff --git a/apps/sim/lib/credentials/application/workspace-account-visibility.ts b/apps/sim/lib/credentials/application/workspace-account-visibility.ts index f2ebe1e1f91..a472cd895f0 100644 --- a/apps/sim/lib/credentials/application/workspace-account-visibility.ts +++ b/apps/sim/lib/credentials/application/workspace-account-visibility.ts @@ -1,7 +1,8 @@ import { db } from '@sim/db' -import { credential, credentialGroupEnrollment } from '@sim/db/schema' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' import { eq, inArray } from 'drizzle-orm' import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { resourceScopeFromOwner, sameResourceScope } from '@/lib/core/resource-scope' import { type OrganizationAccountAccessPolicy, organizationAccountAccessPolicyCodec, @@ -23,7 +24,10 @@ export async function filterWorkspaceAccountCredentials< .select({ id: credential.id, organizationId: credential.organizationId, - groupId: credentialGroupEnrollment.credentialGroupId, + workspaceId: credential.workspaceId, + groupId: credentialGroup.id, + groupOrganizationId: credentialGroup.organizationId, + groupWorkspaceId: credentialGroup.workspaceId, providerId: credential.providerId, type: credential.type, }) @@ -32,7 +36,20 @@ export async function filterWorkspaceAccountCredentials< credentialGroupEnrollment, eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) .where(inArray(credential.id, managedIds)) + for (const binding of bindings) { + if ( + !sameResourceScope( + resourceScopeFromOwner(binding), + resourceScopeFromOwner({ + organizationId: binding.groupOrganizationId, + workspaceId: binding.groupWorkspaceId, + }) + ) + ) + throw new Error('Credential and enrollment group owners do not match') + } const byId = new Map(bindings.map((binding) => [binding.id, binding])) const policies = new Map() const organizationId = context.workspaceOrganizationId @@ -60,7 +77,7 @@ export async function filterWorkspaceAccountCredentials< if (entry.type !== 'managed_oauth' && entry.type !== 'personal_token') return true const binding = byId.get(entry.id) if (!binding) return false - if (!binding.organizationId) return true + if (!binding.organizationId) return binding.workspaceId === context.workspaceId if (binding.organizationId !== organizationId || !organizationAvailable) return false const policy = policies.get(binding.groupId) if (!policy) throw new Error('Organization credential policy was not loaded')