Skip to content

Commit 77c5cd4

Browse files
committed
Merge branch 'pgx/u2' into feat/permission-groups-coverage
2 parents 78744eb + 8d29616 commit 77c5cd4

21 files changed

Lines changed: 617 additions & 65 deletions

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/copilot/chat/post.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,41 @@ const {
6161
releaseChatSendClaim: vi.fn(),
6262
}))
6363

64+
/**
65+
* The root span, captured so a test can assert what a refused turn exported.
66+
* `withCopilotSpan` is a pass-through here — the nesting it provides is not
67+
* under test and a real tracer would need an exporter to observe.
68+
*/
69+
const { setInputMessages, setUserMessagePreview, startCopilotOtelRoot } = vi.hoisted(() => ({
70+
setInputMessages: vi.fn(),
71+
setUserMessagePreview: vi.fn(),
72+
startCopilotOtelRoot: vi.fn(),
73+
}))
74+
75+
vi.mock('@/lib/copilot/request/otel', async () => {
76+
const { ROOT_CONTEXT, trace } = await import('@opentelemetry/api')
77+
const span = () => trace.getTracer('post-test').startSpan('post-test')
78+
startCopilotOtelRoot.mockImplementation(() => ({
79+
span: span(),
80+
context: ROOT_CONTEXT,
81+
requestId: 'req-1',
82+
finish: vi.fn(),
83+
setUserMessagePreview,
84+
setInputMessages,
85+
setOutputMessages: vi.fn(),
86+
setRequestShape: vi.fn(),
87+
}))
88+
return {
89+
startCopilotOtelRoot,
90+
withCopilotSpan: (
91+
_name: string,
92+
_attrs: Record<string, unknown> | undefined,
93+
fn: (child: ReturnType<typeof span>) => unknown,
94+
_context?: unknown
95+
) => fn(span()),
96+
}
97+
})
98+
6499
const resolvePermissionGroupConfig = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig
65100

66101
const getSession = authMockFns.mockGetSession
@@ -1068,6 +1103,38 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => {
10681103
expect(createSSEStream).toHaveBeenCalledTimes(1)
10691104
})
10701105

1106+
/**
1107+
* Prompt content is exported only once the turn is going to run. GenAI
1108+
* message capture is gated on whether capture is enabled at all, not on
1109+
* whether this caller may send, so capturing at span start exported the
1110+
* message of every turn the gate then refused.
1111+
*/
1112+
it('exports no part of the prompt when the send is refused', async () => {
1113+
resolvePermissionGroupConfig.mockResolvedValue({
1114+
...DEFAULT_PERMISSION_GROUP_CONFIG,
1115+
hideCopilot: true,
1116+
})
1117+
1118+
const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true }))
1119+
1120+
expect(response.status).toBe(403)
1121+
expect(setInputMessages).not.toHaveBeenCalled()
1122+
expect(setUserMessagePreview).not.toHaveBeenCalled()
1123+
expect(startCopilotOtelRoot).toHaveBeenCalledWith(
1124+
expect.not.objectContaining({ userMessagePreview: expect.anything() })
1125+
)
1126+
})
1127+
1128+
it('captures the prompt once the send is allowed to run', async () => {
1129+
resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG)
1130+
1131+
const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true }))
1132+
1133+
expect(response.status).toBe(200)
1134+
expect(setUserMessagePreview).toHaveBeenCalledWith('Hello')
1135+
expect(setInputMessages).toHaveBeenCalledWith({ userMessage: 'Hello' })
1136+
})
1137+
10711138
/** A branch that lands in no workspace at all is governed by no group. */
10721139
it('does not consult a permission group when the branch resolves no workspace', async () => {
10731140
resolveWorkflowIdForUser.mockResolvedValue({

apps/sim/lib/copilot/chat/post.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,7 +1073,6 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10731073
executionId,
10741074
runId,
10751075
transport: CopilotTransport.Stream,
1076-
userMessagePreview: body.message,
10771076
})
10781077
if (otelRoot.requestId) {
10791078
requestId = otelRoot.requestId
@@ -1088,10 +1087,6 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10881087
if (authenticatedUserEmail) {
10891088
otelRoot.span.setAttribute(TraceAttr.UserEmail, authenticatedUserEmail)
10901089
}
1091-
// `setInputMessages` is internally gated on
1092-
// OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT; safe to call.
1093-
otelRoot.setInputMessages({ userMessage: body.message })
1094-
10951090
// Wrap the rest of the handler so nested spans attach to the
10961091
// root via AsyncLocalStorage (otherwise they orphan into new traces).
10971092
const activeOtelRoot = otelRoot
@@ -1160,6 +1155,17 @@ export async function handleUnifiedChatPost(req: NextRequest) {
11601155
return capabilityRefusalResponse(chatCapability)
11611156
}
11621157

1158+
/* Prompt content is captured only once the turn is going to run. Both
1159+
calls are internally gated on
1160+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, but the gate is on
1161+
whether capture is enabled at all, not on whether this caller may send
1162+
— so stamping them at span start exported the message of every turn the
1163+
capability check above then refused. Every refusal ahead of this point
1164+
(a rejected branch, a withheld `copilot.use`) now records the shape of
1165+
the request and none of its content. */
1166+
activeOtelRoot.setUserMessagePreview(body.message)
1167+
activeOtelRoot.setInputMessages({ userMessage: body.message })
1168+
11631169
let currentChat: ChatLoadResult['chat'] = null
11641170
let conversationHistory: unknown[] = []
11651171
let chatIsNew = false

0 commit comments

Comments
 (0)