Skip to content

Commit 66056ad

Browse files
committed
fix(access-control): keep superseded blocks out of the editor's allowlist universe
1 parent e84519e commit 66056ad

5 files changed

Lines changed: 230 additions & 30 deletions

File tree

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

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { formatDate } from '@sim/utils/formatting'
3030
import { useQueryState } from 'nuqs'
3131
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
3232
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
33-
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
33+
import { isAccessControlAllowlistRow } from '@/lib/permission-groups/block-access'
3434
import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features'
3535
import type { PermissionGroupConfig } from '@/lib/permission-groups/fields'
3636
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
@@ -65,6 +65,11 @@ import {
6565
useRemovePermissionGroupMember,
6666
useUpdatePermissionGroup,
6767
} from '@/ee/access-control/hooks/permission-groups'
68+
import {
69+
allowlistRowsFromStored,
70+
toggleAllowlistRow,
71+
withAllowlistRows,
72+
} from '@/ee/access-control/utils/integration-allowlist-rows'
6873
import { SettingRow } from '@/ee/components/setting-row'
6974
import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
7075
import { useOrganizationRoster } from '@/hooks/queries/organization'
@@ -789,9 +794,15 @@ export function GroupDetail({
789794
* otherwise a null→partial transition by a non-revealed admin would silently
790795
* drop a preview block from the stored allowlist and deny it to revealed
791796
* users already running it.
797+
*
798+
* EXCLUDES superseded blocks. They are hidden and so are never rendered, but
799+
* they used to be materialized into the allowlist all the same — so an admin
800+
* narrowing a previously-unrestricted allowlist by unchecking `slack_v2` wrote
801+
* `slack` into it, which the runtime resolves back to `slack_v2` and allows.
802+
* A decision about a retired version is made on its successor's row.
792803
*/
793804
const allBlocks = useMemo(() => {
794-
const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type))
805+
const blocks = getAllBlocks().filter((b) => isAccessControlAllowlistRow(b.type))
795806
return blocks.sort((a, b) => {
796807
const catA = BLOCK_CATEGORY_ORDER[a.category] ?? 3
797808
const catB = BLOCK_CATEGORY_ORDER[b.category] ?? 3
@@ -881,17 +892,16 @@ export function GroupDetail({
881892

882893
const guard = useSettingsUnsavedGuard({ isDirty: hasChanges })
883894

895+
const allBlockTypes = useMemo(() => allBlocks.map((b) => b.type), [allBlocks])
896+
884897
/**
885898
* `null` means "everything allowed". Indexing the allow-lists once keeps the
886899
* per-row membership checks O(1) — they run for every one of the ~200 block
887900
* rows on each render, and again in the section-wide `every(...)` scans.
888901
*/
889902
const allowedIntegrationSet = useMemo(
890-
() =>
891-
editingConfig.allowedIntegrations === null
892-
? null
893-
: new Set(editingConfig.allowedIntegrations),
894-
[editingConfig.allowedIntegrations]
903+
() => allowlistRowsFromStored(allBlockTypes, editingConfig.allowedIntegrations),
904+
[allBlockTypes, editingConfig.allowedIntegrations]
895905
)
896906

897907
const allowedProviderSet = useMemo(
@@ -994,48 +1004,35 @@ export function GroupDetail({
9941004
const toggleIntegration = useCallback(
9951005
(blockType: string) => {
9961006
setEditingConfig((prev) => {
997-
const current = prev.allowedIntegrations
998-
let nextAllowed: string[] | null
999-
if (current === null) {
1000-
nextAllowed = allBlocks.map((b) => b.type).filter((t) => t !== blockType)
1001-
} else if (current.includes(blockType)) {
1002-
const updated = current.filter((t) => t !== blockType)
1003-
nextAllowed = updated.length === allBlocks.length ? null : updated
1004-
} else {
1005-
const updated = [...current, blockType]
1006-
nextAllowed = updated.length === allBlocks.length ? null : updated
1007-
}
1007+
const nextAllowed = toggleAllowlistRow(allBlockTypes, prev.allowedIntegrations, blockType)
10081008
return {
10091009
...prev,
10101010
allowedIntegrations: nextAllowed,
10111011
deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
10121012
}
10131013
})
10141014
},
1015-
[allBlocks, pruneDeniedTools]
1015+
[allBlockTypes, pruneDeniedTools]
10161016
)
10171017

10181018
/** Allow or deny a whole section's blocks at once, respecting the active filter. */
10191019
const setBlocksAllowed = useCallback(
10201020
(blocks: BlockConfig[], allowed: boolean) => {
10211021
setEditingConfig((prev) => {
1022-
const allTypes = allBlocks.map((b) => b.type)
1023-
const current =
1024-
prev.allowedIntegrations === null ? new Set(allTypes) : new Set(prev.allowedIntegrations)
1025-
for (const block of blocks) {
1026-
if (allowed) current.add(block.type)
1027-
else current.delete(block.type)
1028-
}
1029-
const nextArr = allTypes.filter((t) => current.has(t))
1030-
const nextAllowed = nextArr.length === allTypes.length ? null : nextArr
1022+
const nextAllowed = withAllowlistRows(
1023+
allBlockTypes,
1024+
prev.allowedIntegrations,
1025+
blocks.map((block) => block.type),
1026+
allowed
1027+
)
10311028
return {
10321029
...prev,
10331030
allowedIntegrations: nextAllowed,
10341031
deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
10351032
}
10361033
})
10371034
},
1038-
[allBlocks, pruneDeniedTools]
1035+
[allBlockTypes, pruneDeniedTools]
10391036
)
10401037

10411038
const isToolAllowed = useCallback(
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* The universe below is the editor's row set, which excludes superseded blocks
5+
* (`isAccessControlAllowlistRow`). Every id is a real one, so the assertions
6+
* rest on the repository's own lifecycle facts: `slack` was replaced by
7+
* `slack_v2`.
8+
*/
9+
import { describe, expect, it } from 'vitest'
10+
import {
11+
allowlistRowsFromStored,
12+
toggleAllowlistRow,
13+
withAllowlistRows,
14+
} from '@/ee/access-control/utils/integration-allowlist-rows'
15+
16+
const UNIVERSE = ['agent', 'notion_v2', 'slack_v2'] as const
17+
18+
describe('allowlistRowsFromStored', () => {
19+
it('keeps an unrestricted allowlist unrestricted', () => {
20+
expect(allowlistRowsFromStored(UNIVERSE, null)).toBeNull()
21+
})
22+
23+
/**
24+
* The runtime resolves a stored `slack` to `slack_v2` and allows it, so the
25+
* row has to render checked or the editor is lying about what is permitted.
26+
*/
27+
it('reads a stored retired id as the row it actually governs', () => {
28+
const rows = allowlistRowsFromStored(UNIVERSE, ['slack'])
29+
30+
expect(rows?.has('slack_v2')).toBe(true)
31+
expect(rows?.has('slack')).toBe(false)
32+
})
33+
34+
it('drops an id no row corresponds to', () => {
35+
expect([...(allowlistRowsFromStored(UNIVERSE, ['agent', 'retired_thing']) ?? [])]).toEqual([
36+
'agent',
37+
])
38+
})
39+
})
40+
41+
describe('toggleAllowlistRow', () => {
42+
/**
43+
* The bug this closes. The editor renders only current blocks, so narrowing a
44+
* previously-unrestricted allowlist used to materialize the hidden `slack`
45+
* alongside the rows — and the runtime resolves `slack` back to `slack_v2`,
46+
* re-allowing the integration the admin had just denied.
47+
*/
48+
it('does not leave a superseded id behind when a row is denied', () => {
49+
const next = toggleAllowlistRow(UNIVERSE, null, 'slack_v2')
50+
51+
expect(next).toEqual(['agent', 'notion_v2'])
52+
expect(allowlistRowsFromStored(UNIVERSE, next)?.has('slack_v2')).toBe(false)
53+
})
54+
55+
/** A stored retired id must follow its successor's fate, not outlive it. */
56+
it('denies a row a stored retired id was granting', () => {
57+
const next = toggleAllowlistRow(UNIVERSE, ['agent', 'slack'], 'slack_v2')
58+
59+
expect(next).toEqual(['agent'])
60+
})
61+
62+
it('grants a row that was not allowed', () => {
63+
expect(toggleAllowlistRow(UNIVERSE, ['agent'], 'notion_v2')).toEqual(['agent', 'notion_v2'])
64+
})
65+
66+
/** Permitting everything is stored as "no restriction", not as a frozen list. */
67+
it('collapses back to unrestricted when the last row is granted', () => {
68+
expect(toggleAllowlistRow(UNIVERSE, ['agent', 'notion_v2'], 'slack_v2')).toBeNull()
69+
})
70+
})
71+
72+
describe('withAllowlistRows', () => {
73+
it('denies a whole section at once', () => {
74+
expect(withAllowlistRows(UNIVERSE, null, ['notion_v2', 'slack_v2'], false)).toEqual(['agent'])
75+
})
76+
77+
it('emits rows in universe order however the section is ordered', () => {
78+
expect(withAllowlistRows(UNIVERSE, [], ['slack_v2', 'agent'], true)).toEqual([
79+
'agent',
80+
'slack_v2',
81+
])
82+
})
83+
84+
it('collapses to unrestricted when a section grant covers every row', () => {
85+
expect(withAllowlistRows(UNIVERSE, ['agent'], [...UNIVERSE], true)).toBeNull()
86+
})
87+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { toAccessControlAllowlist } from '@/lib/permission-groups/block-access'
2+
3+
/**
4+
* The stored `allowedIntegrations` re-expressed as editor rows: successor-
5+
* resolved the way the runtime resolves it, then projected onto the universe of
6+
* rows the editor actually offers. `null` stays `null` — everything allowed.
7+
*
8+
* A stored list can name a retired id (written before the universe excluded
9+
* superseded blocks, or through the API directly). The runtime resolves it to
10+
* its successor, so a stored `slack` allows `slack_v2`; reading the raw strings
11+
* would render that row unchecked, and toggling it would "enable" an
12+
* integration that was already permitted. Resolving first makes the checkbox
13+
* tell the truth, and the projection drops the stale id on the next write.
14+
*/
15+
export function allowlistRowsFromStored(
16+
universe: readonly string[],
17+
stored: readonly string[] | null
18+
): Set<string> | null {
19+
const resolved = toAccessControlAllowlist(stored)
20+
return resolved === null ? null : new Set(universe.filter((type) => resolved.has(type)))
21+
}
22+
23+
/**
24+
* The stored allowlist after a set of rows is allowed or denied.
25+
*
26+
* Always emitted in universe order and collapsed back to `null` — unrestricted
27+
* — when every row survives, so a group that ends up permitting everything is
28+
* stored as "no restriction" rather than as a list that silently freezes out
29+
* every integration added later.
30+
*/
31+
export function withAllowlistRows(
32+
universe: readonly string[],
33+
stored: readonly string[] | null,
34+
blockTypes: readonly string[],
35+
allowed: boolean
36+
): string[] | null {
37+
const rows = allowlistRowsFromStored(universe, stored) ?? new Set(universe)
38+
for (const blockType of blockTypes) {
39+
if (allowed) rows.add(blockType)
40+
else rows.delete(blockType)
41+
}
42+
const next = universe.filter((type) => rows.has(type))
43+
return next.length === universe.length ? null : next
44+
}
45+
46+
/** {@link withAllowlistRows} for one row, flipping whatever it is now. */
47+
export function toggleAllowlistRow(
48+
universe: readonly string[],
49+
stored: readonly string[] | null,
50+
blockType: string
51+
): string[] | null {
52+
const rows = allowlistRowsFromStored(universe, stored)
53+
return withAllowlistRows(universe, stored, [blockType], !(rows === null || rows.has(blockType)))
54+
}

apps/sim/lib/permission-groups/block-access.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
* @vitest-environment node
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
5+
import {
6+
isAccessControlAllowlistRow,
7+
isBlockTypeAccessControlExempt,
8+
} from '@/lib/permission-groups/block-access'
69
import { getBlock } from '@/blocks/registry'
710

811
const mockGetBlock = getBlock as unknown as ReturnType<typeof vi.fn>
@@ -81,3 +84,40 @@ describe('isBlockTypeAccessControlExempt', () => {
8184
expect(isBlockTypeAccessControlExempt('manual_trigger')).toBe(true)
8285
})
8386
})
87+
88+
describe('isAccessControlAllowlistRow', () => {
89+
beforeEach(() => {
90+
vi.clearAllMocks()
91+
})
92+
93+
/**
94+
* The bug this closes: the editor renders only visible blocks but used to
95+
* materialize an allowlist from every non-exempt one. Unchecking `slack_v2`
96+
* on a previously-unrestricted group therefore wrote `slack` into the stored
97+
* list, and the runtime resolves `slack` to `slack_v2` — re-allowing exactly
98+
* the integration the admin had just denied.
99+
*/
100+
it('is not a row for a superseded block, which has no row of its own', () => {
101+
registry({
102+
slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } },
103+
slack_v2: {},
104+
})
105+
106+
expect(isAccessControlAllowlistRow('slack')).toBe(false)
107+
expect(isBlockTypeAccessControlExempt('slack')).toBe(false)
108+
})
109+
110+
it('is a row for a current block', () => {
111+
registry({ slack_v2: {} })
112+
113+
expect(isAccessControlAllowlistRow('slack_v2')).toBe(true)
114+
})
115+
116+
/** Exempt block types are decided by no row at all. */
117+
it('is not a row for an exempt block', () => {
118+
registry({ thinking: { hideFromToolbar: true }, start_trigger: {} })
119+
120+
expect(isAccessControlAllowlistRow('thinking')).toBe(false)
121+
expect(isAccessControlAllowlistRow('start_trigger')).toBe(false)
122+
})
123+
})

apps/sim/lib/permission-groups/block-access.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,25 @@ export function isBlockTypeAccessControlExempt(blockType: string): boolean {
4848
const successor = resolveAccessControlBlockType(blockType)
4949
return successor === blockType || successor === UNIVERSAL_ENTRY_POINT
5050
}
51+
52+
/**
53+
* Whether `blockType` is a row in the Access Control editor's allowlist
54+
* universe — the set the editor materializes an allowlist from and compares
55+
* against to collapse one back to `null`.
56+
*
57+
* Narrower than {@link isBlockTypeAccessControlExempt} on purpose. A superseded
58+
* block must stay non-exempt at runtime (legacy `slack` reaches Slack and is
59+
* judged as `slack_v2`), but it must not be an editor row: the editor renders
60+
* only visible blocks, so an admin narrowing a previously-unrestricted
61+
* allowlist by unchecking `slack_v2` would still write the hidden `slack` into
62+
* it — and canonical resolution then reads that entry as `slack_v2` and allows
63+
* the very integration the admin just denied.
64+
*
65+
* Viewer-independent, like the exemption: it reads the pure registry and the
66+
* generated successor map, never the visibility projection, so a preview block
67+
* gated for the acting admin stays in the universe and keeps its stored grant.
68+
*/
69+
export function isAccessControlAllowlistRow(blockType: string): boolean {
70+
if (isBlockTypeAccessControlExempt(blockType)) return false
71+
return resolveAccessControlBlockType(blockType) === blockType
72+
}

0 commit comments

Comments
 (0)