Skip to content

Commit 9c82ba8

Browse files
icecrasher321claude
andcommitted
fix(selectors): rebind Copilot principals for nested domain use cases
Selectors backed by another domain (knowledge documents, table columns, workflows, sandboxes, MCP tools) forwarded the Copilot principal admitted under the selector audience into use cases that accept only their own audience. The refusal is a DelegatedWorkspaceAuthorizationError, which the v2 surface conceals as 404, so Chat saw "Workspace not found" on workspaces the user administers. Fork sync previews hit it whenever a saved dependent value (a table conflict column, a knowledge document) needed validation. Selectors now rebind through bindCopilotWorkspaceOperation, which can also project the single resource a nested call reaches, as the executor and Chat MCP paths do when they mint. A grant already narrowed to one resource is never moved to another. Managed MCP connections now reach their credential-group rule and return its 403 instead of the concealed 404. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 66e5705 commit 9c82ba8

9 files changed

Lines changed: 350 additions & 19 deletions

File tree

‎apps/sim/lib/core/application/copilot-workspace-invocation.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest'
2-
import { markCopilotWorkspaceInvocation } from '@/lib/core/application/copilot-workspace-invocation'
2+
import {
3+
bindCopilotWorkspaceOperation,
4+
markCopilotWorkspaceInvocation,
5+
} from '@/lib/core/application/copilot-workspace-invocation'
6+
import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization'
37
import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation'
48
import { tableDelegationPolicy } from '@/lib/table/application/authorization'
59

@@ -45,3 +49,22 @@ describe('private table workspace invocation', () => {
4549
)
4650
})
4751
})
52+
describe('nested workspace operation binding', () => {
53+
it('never moves a grant already narrowed to one resource onto another', () => {
54+
const caller = createCopilotChatPrincipal(
55+
{ userId: 'actor', workspaceId: 'workspace', chatId: 'chat' },
56+
'sim:selectors',
57+
{ credentialId: 'mcp-cg-granted' }
58+
)
59+
markCopilotWorkspaceInvocation(caller)
60+
expect(() =>
61+
bindCopilotWorkspaceOperation(
62+
caller,
63+
'workspace',
64+
['sim:selectors'],
65+
{ delegationAudience: MANAGED_MCP_DELEGATION_AUDIENCE },
66+
{ credentialId: 'mcp-cg-other' }
67+
)
68+
).toThrow(/resource scope/)
69+
})
70+
})

‎apps/sim/lib/core/application/copilot-workspace-invocation.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,43 @@ export function isCopilotWorkspaceInvocation(principal: DelegatedPrincipal): boo
2525
)
2626
}
2727

28-
/** Nested domain reads keep the admitted actor, resource restrictions, workspace, and expiry. */
28+
const NESTED_RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'credentialId', 'mcpServerId'] as const
29+
30+
export type NestedResourceScope = Partial<
31+
Record<(typeof NESTED_RESOURCE_SCOPE_KEYS)[number], string>
32+
>
33+
34+
/** Adds the nested target to the admitted scope; a key already granted never moves to another resource. */
35+
function narrowResourceScope(
36+
granted: DelegatedPrincipal['resourceScope'],
37+
target: NestedResourceScope
38+
): NonNullable<DelegatedPrincipal['resourceScope']> {
39+
const scope = { ...granted }
40+
for (const key of NESTED_RESOURCE_SCOPE_KEYS) {
41+
const value = target[key]
42+
if (value === undefined) continue
43+
if (!value.trim() || (scope[key] !== undefined && scope[key] !== value)) {
44+
throw new OrchestrationError(
45+
'forbidden',
46+
'Nested operation cannot move its delegated resource scope'
47+
)
48+
}
49+
scope[key] = value
50+
}
51+
return Object.freeze(scope)
52+
}
53+
54+
/**
55+
* Nested domain reads keep the admitted actor, resource restrictions, workspace, and expiry.
56+
* A nested operation bound to one resource names it in `resourceScope`, as the executor and
57+
* Chat tool paths do when they mint a principal for that resource.
58+
*/
2959
export function bindCopilotWorkspaceOperation<P extends Principal>(
3060
principal: P,
3161
workspaceId: string,
3262
sourceAudiences: readonly string[],
33-
useCase: Pick<OperationUseCase<ApplicationOperation, unknown, unknown>, 'delegationAudience'>
63+
useCase: Pick<OperationUseCase<ApplicationOperation, unknown, unknown>, 'delegationAudience'>,
64+
resourceScope?: NestedResourceScope
3465
): P {
3566
if (principal.kind !== 'delegated' || principal.serviceId !== 'copilot') return principal
3667
if (
@@ -44,7 +75,13 @@ export function bindCopilotWorkspaceOperation<P extends Principal>(
4475
'Nested operation requires the current workspace invocation'
4576
)
4677
}
47-
const derived = { ...principal, audience: useCase.delegationAudience }
78+
const derived = {
79+
...principal,
80+
audience: useCase.delegationAudience,
81+
...(resourceScope
82+
? { resourceScope: narrowResourceScope(principal.resourceScope, resourceScope) }
83+
: {}),
84+
}
4885
if (derived.kind === 'delegated') markCopilotWorkspaceInvocation(derived)
4986
return derived
5087
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/** Chat's in-process CLI reaching selectors whose options are owned by another domain. */
2+
3+
import { db } from '@sim/db'
4+
import {
5+
credential,
6+
credentialGroupEnrollment,
7+
mcpServers,
8+
permissions,
9+
user,
10+
workspace,
11+
} from '@sim/db/schema'
12+
import { sha256Hex } from '@sim/security/hash'
13+
import { generateId } from '@sim/utils/id'
14+
import { eq } from 'drizzle-orm'
15+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
16+
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
17+
import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service'
18+
import { encryptManagedMcpTokens } from '@/lib/credentials/managed-mcp'
19+
import { generateManagedMcpConnectionId } from '@/lib/mcp/utils'
20+
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
21+
22+
const ORIGIN = 'http://localhost:3000'
23+
const userId = generateId()
24+
const workspaceId = generateId()
25+
const connectionId = generateManagedMcpConnectionId()
26+
27+
function listSelector(selectorKey: string, context: Record<string, string>) {
28+
const transport = createScopedCliTransport(ORIGIN, { userId, workspaceId, chatId: generateId() })
29+
return withWorkspaceInvocationScope({ workspaceId }, () =>
30+
transport(`${ORIGIN}/api/v2/selectors/list`, {
31+
method: 'POST',
32+
headers: { 'content-type': 'application/json' },
33+
body: JSON.stringify({ workspaceId, selectorKey, context }),
34+
})
35+
)
36+
}
37+
38+
describe('Copilot selectors backed by another domain', () => {
39+
beforeAll(async () => {
40+
const now = new Date()
41+
await db.insert(user).values({
42+
id: userId,
43+
name: 'Selector fixture',
44+
email: `${userId}@selector.test`,
45+
emailVerified: true,
46+
createdAt: now,
47+
updatedAt: now,
48+
})
49+
await db.insert(workspace).values({
50+
id: workspaceId,
51+
name: 'Selector fixture',
52+
ownerId: userId,
53+
billedAccountUserId: userId,
54+
})
55+
await db.insert(permissions).values({
56+
id: generateId(),
57+
userId,
58+
entityType: 'workspace',
59+
entityId: workspaceId,
60+
permissionType: 'admin',
61+
})
62+
const group = await ensureWorkspaceAccountsGroup(workspaceId, userId)
63+
const serverId = generateId()
64+
await db.insert(mcpServers).values({
65+
id: serverId,
66+
workspaceId,
67+
credentialGroupId: group.id,
68+
managedConnectorId: 'notion',
69+
name: 'Notion',
70+
transport: 'streamable-http',
71+
url: 'https://mcp.notion.com/mcp',
72+
authType: 'oauth',
73+
createdBy: userId,
74+
})
75+
const enrollmentId = generateId()
76+
await db.insert(credentialGroupEnrollment).values({
77+
id: enrollmentId,
78+
credentialGroupId: group.id,
79+
userId,
80+
email: `${userId}@selector.test`,
81+
status: 'completed',
82+
invitationTokenHash: sha256Hex(generateId()),
83+
invitationExpiresAt: new Date(Date.now() + 60_000),
84+
invitedAt: now,
85+
})
86+
await db.insert(credential).values({
87+
id: connectionId,
88+
workspaceId,
89+
type: 'managed_mcp',
90+
displayName: 'Notion',
91+
grantedAt: now,
92+
mcpTools: [],
93+
credentialGroupEnrollmentId: enrollmentId,
94+
mcpServerId: serverId,
95+
managedOauthStatus: 'active',
96+
encryptedOauthTokenSet: await encryptManagedMcpTokens({
97+
access_token: 'fixture-access',
98+
token_type: 'Bearer',
99+
}),
100+
})
101+
})
102+
afterAll(async () => {
103+
await db.delete(workspace).where(eq(workspace.id, workspaceId))
104+
await db.delete(user).where(eq(user.id, userId))
105+
await db.$client.end()
106+
})
107+
108+
it('decides a managed MCP connection by its credential-group rule instead of concealing it', async () => {
109+
const response = await listSelector('mcp.tools', { mcpServerId: connectionId })
110+
const body = await response.json()
111+
expect(response.status, JSON.stringify(body)).toBe(403)
112+
expect(body.error.message).toBe('Credential Group credential access denied')
113+
})
114+
})

‎apps/sim/lib/selectors/application/execute-selector.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.ser
1212
import { OrchestrationError } from '@/lib/core/orchestration/types'
1313
import { authorizePersonalSearchSetup } from '@/lib/knowledge/application/personal-search-account'
1414
import { type CredentialAuditRequest, recordCredentialAccess } from '@/lib/oauth/token-resolution'
15-
import { selectorOperations } from '@/lib/selectors/application/operations'
15+
import {
16+
SELECTOR_DELEGATION_AUDIENCE,
17+
selectorOperations,
18+
} from '@/lib/selectors/application/operations'
1619
import {
1720
resolveSelectorApplicationContext,
1821
type SelectorApplicationContext,
@@ -327,7 +330,9 @@ const executeWorkspaceSelector = defineAuthorizedWorkspaceUseCase<
327330
if (context.workspaceId === undefined) throw new SelectorContextUnavailableError()
328331
return context
329332
},
330-
authorizationOptions: { delegation: { audience: 'sim:selectors', isWithinScope: () => true } },
333+
authorizationOptions: {
334+
delegation: { audience: SELECTOR_DELEGATION_AUDIENCE, isWithinScope: () => true },
335+
},
331336
authorizeResource: ({ input, context }) => validateAuthorizedInput(input, context),
332337
execute: executeAuthorizedSelector,
333338
})

‎apps/sim/lib/selectors/application/operations.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation'
22

3+
export const SELECTOR_DELEGATION_AUDIENCE = 'sim:selectors'
4+
35
export const selectorOperations = {
46
// permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a selector reaches. That decision is enforced from the use case by assertSelectorIntegrationAllowed, against the selector's own resource, ahead of the provider call.
57
execute: defineWorkspaceOperation({

‎apps/sim/lib/selectors/server/internal.ts‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
detailSelectorResult,
2323
type ExecuteServerSelectorArgs,
2424
listSelectorResult,
25+
nestedSelectorPrincipal,
26+
type SelectorPrincipal,
2527
type ServerSelectorAttachmentMap,
2628
} from '@/lib/selectors/server/types'
2729
import { readTableUseCase } from '@/lib/table/application/tables'
@@ -45,17 +47,14 @@ function labelWorkflow(
4547
return `${base} (${folder})`
4648
}
4749

48-
async function loadWorkflows(
49-
args: Parameters<(typeof listWorkflows)['execute']>[0]['principal'],
50-
workspaceId: string
51-
) {
50+
async function loadWorkflows(principal: SelectorPrincipal, workspaceId: string) {
5251
const workflows: Array<
5352
Awaited<ReturnType<(typeof listWorkflows)['execute']>>['workflows'][number]
5453
> = []
5554
let cursorKeys: Awaited<ReturnType<(typeof listWorkflows)['execute']>>['nextCursorKeys'] = null
5655
for (let page = 0; page < MAX_WORKFLOW_PAGES; page += 1) {
5756
const result = await listWorkflows.execute({
58-
principal: args,
57+
principal: nestedSelectorPrincipal(principal, workspaceId, listWorkflows),
5958
input: {
6059
workspaceId,
6160
scope: 'active',
@@ -82,7 +81,11 @@ export const internalSelectorAttachments = {
8281
const knowledgeBaseId = args.context.knowledgeBaseId!
8382
if (args.request.kind === 'detail') {
8483
const result = await readKnowledgeDocument.execute({
85-
principal: args.principal,
84+
principal: nestedSelectorPrincipal(
85+
args.principal,
86+
args.workspaceId,
87+
readKnowledgeDocument
88+
),
8689
input: {
8790
knowledgeBaseId,
8891
documentId: args.request.id,
@@ -98,7 +101,11 @@ export const internalSelectorAttachments = {
98101
const offset = args.request.cursor ? Number(args.request.cursor) : 0
99102
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid selector cursor')
100103
const result = await listKnowledgeDocuments.execute({
101-
principal: args.principal,
104+
principal: nestedSelectorPrincipal(
105+
args.principal,
106+
args.workspaceId,
107+
listKnowledgeDocuments
108+
),
102109
input: {
103110
knowledgeBaseId,
104111
assertedWorkspaceId: args.workspaceId,
@@ -151,7 +158,7 @@ export const internalSelectorAttachments = {
151158
async execute(args: ExecuteServerSelectorArgs) {
152159
if (!args.workspaceId) throw new SelectorContextUnavailableError()
153160
const { table } = await readTableUseCase.execute({
154-
principal: args.principal,
161+
principal: nestedSelectorPrincipal(args.principal, args.workspaceId, readTableUseCase),
155162
input: { tableId: args.context.tableId!, workspaceId: args.workspaceId },
156163
})
157164
const options = (table.schema?.columns ?? [])
@@ -169,7 +176,7 @@ export const internalSelectorAttachments = {
169176
async execute(args: ExecuteServerSelectorArgs) {
170177
if (!args.workspaceId) throw new SelectorContextUnavailableError()
171178
const { table } = await readTableUseCase.execute({
172-
principal: args.principal,
179+
principal: nestedSelectorPrincipal(args.principal, args.workspaceId, readTableUseCase),
173180
input: { tableId: args.context.tableId!, workspaceId: args.workspaceId },
174181
})
175182
const options = (table.schema?.columns ?? []).map((column) => ({
@@ -291,7 +298,11 @@ export const internalSelectorAttachments = {
291298
if (args.request.kind === 'detail') {
292299
const result = await getWorkspaceSandboxUseCase
293300
.execute({
294-
principal: args.principal,
301+
principal: nestedSelectorPrincipal(
302+
args.principal,
303+
args.workspaceId,
304+
getWorkspaceSandboxUseCase
305+
),
295306
input: { workspaceId: args.workspaceId, sandboxId: args.request.id },
296307
})
297308
.catch((error: unknown) => {
@@ -308,7 +319,11 @@ export const internalSelectorAttachments = {
308319
})
309320
}
310321
const { sandboxes, nextCursorKeys } = await listWorkspaceSandboxesUseCase.execute({
311-
principal: args.principal,
322+
principal: nestedSelectorPrincipal(
323+
args.principal,
324+
args.workspaceId,
325+
listWorkspaceSandboxesUseCase
326+
),
312327
input: { workspaceId: args.workspaceId, limit: 1000 },
313328
})
314329
if (nextCursorKeys) throw new SelectorOptionsUnavailableError()

‎apps/sim/lib/selectors/server/providers/mcp.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
definePreparedSelectorAttachment,
77
detailSelectorResult,
88
listSelectorResult,
9+
nestedSelectorPrincipal,
910
} from '@/lib/selectors/server/types'
1011

1112
/** The existing MCP use case binds the destination and credentials to an authorized server. */
@@ -25,7 +26,12 @@ export const mcpSelectorAttachments = {
2526
if (!isManagedMcpConnectionId(serverId))
2627
throw new OrchestrationError('validation', 'Invalid managed MCP connection ID')
2728
return discoverManagedMcpToolsUseCase.execute({
28-
principal: args.principal,
29+
principal: nestedSelectorPrincipal(
30+
args.principal,
31+
args.workspaceId,
32+
discoverManagedMcpToolsUseCase,
33+
{ credentialId: serverId }
34+
),
2935
input: {
3036
workspaceId: args.workspaceId,
3137
credentialId: serverId,
@@ -34,7 +40,12 @@ export const mcpSelectorAttachments = {
3440
})
3541
}
3642
return discoverMcpServerToolsUseCase.execute({
37-
principal: args.principal,
43+
principal: nestedSelectorPrincipal(
44+
args.principal,
45+
args.workspaceId,
46+
discoverMcpServerToolsUseCase,
47+
{ mcpServerId: serverId }
48+
),
3849
input: {
3950
workspaceId: args.workspaceId,
4051
serverId,

0 commit comments

Comments
 (0)