Skip to content

Commit 324299e

Browse files
fix(access-control): exempt legacy blocks (#5063)
1 parent 67e02fa commit 324299e

6 files changed

Lines changed: 75 additions & 9 deletions

File tree

apps/sim/ee/access-control/components/access-control.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { ArrowLeft } from '@/components/emcn/icons'
2828
import { getEnv, isTruthy } from '@/lib/core/config/env'
2929
import { cn } from '@/lib/core/utils/cn'
30+
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
3031
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
3132
import { getUserColor } from '@/lib/workspaces/colors'
3233
import { getAllBlocks } from '@/blocks'
@@ -632,7 +633,7 @@ export function AccessControl() {
632633
}, [viewingGroup, editingConfig])
633634

634635
const allBlocks = useMemo(() => {
635-
const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
636+
const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type))
636637
return blocks.sort((a, b) => {
637638
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
638639
const catA = categoryOrder[a.category] ?? 3

apps/sim/ee/access-control/utils/permission-check.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const {
99
mockIsOrganizationOnEnterprisePlan,
1010
mockGetWorkspaceWithOwner,
1111
mockGetProviderFromModel,
12+
mockGetBlock,
1213
mockExplicitGroup,
1314
mockDefaultGroup,
1415
} = vi.hoisted(() => ({
@@ -40,6 +41,7 @@ const {
4041
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
4142
mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(),
4243
mockGetProviderFromModel: vi.fn<(model: string) => string>(),
44+
mockGetBlock: vi.fn<(type: string) => { hideFromToolbar?: boolean } | undefined>(),
4345
// The explicit-group query joins permission_group_member -> permission_group;
4446
// the org-default query selects permission_group directly. The db mock returns
4547
// the explicit rows when `innerJoin` was called and the default rows otherwise.
@@ -108,6 +110,11 @@ vi.mock('@/providers/utils', () => ({
108110
getProviderFromModel: mockGetProviderFromModel,
109111
}))
110112

113+
vi.mock('@/blocks/registry', () => ({
114+
getBlock: mockGetBlock,
115+
getAllBlocks: vi.fn(() => []),
116+
}))
117+
111118
import {
112119
assertPermissionsAllowed,
113120
CustomToolsNotAllowedError,
@@ -128,6 +135,15 @@ function setEnterpriseOrgWorkspace() {
128135
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
129136
}
130137

138+
/**
139+
* Default every block to non-legacy. `vi.clearAllMocks()` (used by the
140+
* describe-level hooks) keeps implementations, so reset here to stop a legacy
141+
* `getBlock` implementation set in one test from leaking into later ones.
142+
*/
143+
beforeEach(() => {
144+
mockGetBlock.mockImplementation(() => undefined)
145+
})
146+
131147
describe('IntegrationNotAllowedError', () => {
132148
it.concurrent('creates error with correct name and message', () => {
133149
const error = new IntegrationNotAllowedError('discord')
@@ -280,6 +296,14 @@ describe('validateBlockType', () => {
280296
await validateBlockType(undefined, undefined, 'start_trigger')
281297
})
282298

299+
it('always allows legacy blocks hidden from the toolbar', async () => {
300+
mockGetBlock.mockImplementation((type) =>
301+
type === 'notion' ? { hideFromToolbar: true } : undefined
302+
)
303+
304+
await validateBlockType(undefined, undefined, 'notion')
305+
})
306+
283307
it('matches case-insensitively', async () => {
284308
await validateBlockType(undefined, undefined, 'Slack')
285309
await validateBlockType(undefined, undefined, 'GOOGLE_DRIVE')
@@ -445,6 +469,19 @@ describe('assertPermissionsAllowed', () => {
445469
).rejects.toBeInstanceOf(IntegrationNotAllowedError)
446470
})
447471

472+
it('exempts legacy blocks from the integration allowlist', async () => {
473+
mockExplicitGroup.value = [{ config: { allowedIntegrations: ['slack'] } }]
474+
mockGetBlock.mockImplementation((type) =>
475+
type === 'notion' ? { hideFromToolbar: true } : undefined
476+
)
477+
478+
await assertPermissionsAllowed({
479+
userId: 'user-123',
480+
workspaceId: 'workspace-1',
481+
blockType: 'notion',
482+
})
483+
})
484+
448485
it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => {
449486
mockExplicitGroup.value = [{ config: { disableCustomTools: true } }]
450487

apps/sim/ee/access-control/utils/permission-check.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isInvitationsDisabled,
1111
isPublicApiDisabled,
1212
} from '@/lib/core/config/feature-flags'
13+
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
1314
import {
1415
DEFAULT_PERMISSION_GROUP_CONFIG,
1516
type PermissionGroupConfig,
@@ -287,7 +288,7 @@ export async function validateBlockType(
287288
blockType: string,
288289
ctx?: ExecutionContext
289290
): Promise<void> {
290-
if (blockType === 'start_trigger') {
291+
if (isBlockTypeAccessControlExempt(blockType)) {
291292
return
292293
}
293294

@@ -478,10 +479,10 @@ interface PermissionAssertion {
478479
export async function assertPermissionsAllowed(req: PermissionAssertion): Promise<void> {
479480
const { userId, workspaceId, model, blockType, toolKind, ctx } = req
480481

481-
if (blockType === 'start_trigger') {
482-
if (!model && !toolKind) {
483-
return
484-
}
482+
const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false
483+
484+
if (blockTypeExempt && !model && !toolKind) {
485+
return
485486
}
486487

487488
const config =
@@ -509,7 +510,7 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis
509510
}
510511
}
511512

512-
if (blockType && blockType !== 'start_trigger') {
513+
if (blockType && !blockTypeExempt) {
513514
if (config && config.allowedIntegrations !== null) {
514515
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
515516
const envAllowlist = getAllowedIntegrationsFromEnv()

apps/sim/hooks/use-permission-config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ApiClientError } from '@/lib/api/client/errors'
77
import { requestJson } from '@/lib/api/client/request'
88
import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common'
99
import { getEnv, isTruthy } from '@/lib/core/config/env'
10+
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
1011
import {
1112
DEFAULT_PERMISSION_GROUP_CONFIG,
1213
type PermissionGroupConfig,
@@ -91,7 +92,7 @@ export function usePermissionConfig(): PermissionConfigResult {
9192

9293
const isBlockAllowed = useMemo(() => {
9394
return (blockType: string) => {
94-
if (blockType === 'start_trigger') return true
95+
if (isBlockTypeAccessControlExempt(blockType)) return true
9596
if (mergedAllowedIntegrations === null) return true
9697
return mergedAllowedIntegrations.includes(blockType.toLowerCase())
9798
}
@@ -117,7 +118,7 @@ export function usePermissionConfig(): PermissionConfigResult {
117118
if (mergedAllowedIntegrations === null) return blocks
118119
return blocks.filter(
119120
(block) =>
120-
block.type === 'start_trigger' ||
121+
isBlockTypeAccessControlExempt(block.type) ||
121122
mergedAllowedIntegrations.includes(block.type.toLowerCase())
122123
)
123124
}

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator'
4+
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
45
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
56
import {
67
buildCanonicalIndex,
@@ -714,6 +715,9 @@ export function isBlockTypeAllowed(
714715
blockType: string,
715716
permissionConfig: PermissionGroupConfig | null
716717
): boolean {
718+
if (isBlockTypeAccessControlExempt(blockType)) {
719+
return true
720+
}
717721
if (!permissionConfig || permissionConfig.allowedIntegrations === null) {
718722
return true
719723
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getBlock } from '@/blocks/registry'
2+
3+
/**
4+
* Block types that bypass permission-group access control entirely.
5+
*
6+
* Two kinds of blocks are exempt:
7+
* - `start_trigger`: the universal workflow entry point. A workflow must always
8+
* be startable regardless of the configured integration allowlist.
9+
* - Legacy blocks (`hideFromToolbar: true`): superseded integration versions and
10+
* deprecated blocks. They never appear in the toolbar or the Access Control
11+
* admin list, so admins cannot allowlist them — yet they may still live inside
12+
* older workflows. Exempting them keeps those workflows runnable instead of
13+
* silently blocking blocks the admin had no way to permit.
14+
*
15+
* This is the single source of truth shared by both the runtime enforcement
16+
* paths and the Access Control admin UI so the "hidden from the list" set and
17+
* the "skipped by enforcement" set never drift apart.
18+
*/
19+
export function isBlockTypeAccessControlExempt(blockType: string): boolean {
20+
if (blockType === 'start_trigger') return true
21+
return getBlock(blockType)?.hideFromToolbar === true
22+
}

0 commit comments

Comments
 (0)