Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { markCopilotWorkspaceInvocation } from '@/lib/core/application/copilot-workspace-invocation'
import {
bindCopilotWorkspaceOperation,
markCopilotWorkspaceInvocation,
} from '@/lib/core/application/copilot-workspace-invocation'
import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization'
import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation'
import { tableDelegationPolicy } from '@/lib/table/application/authorization'

Expand Down Expand Up @@ -45,3 +49,22 @@ describe('private table workspace invocation', () => {
)
})
})
describe('nested workspace operation binding', () => {
it('never moves a grant already narrowed to one resource onto another', () => {
const caller = createCopilotChatPrincipal(
{ userId: 'actor', workspaceId: 'workspace', chatId: 'chat' },
'sim:selectors',
{ credentialId: 'mcp-cg-granted' }
)
markCopilotWorkspaceInvocation(caller)
expect(() =>
bindCopilotWorkspaceOperation(
caller,
'workspace',
['sim:selectors'],
{ delegationAudience: MANAGED_MCP_DELEGATION_AUDIENCE },
{ credentialId: 'mcp-cg-other' }
)
).toThrow(/resource scope/)
})
})
43 changes: 40 additions & 3 deletions apps/sim/lib/core/application/copilot-workspace-invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,43 @@ export function isCopilotWorkspaceInvocation(principal: DelegatedPrincipal): boo
)
}

/** Nested domain reads keep the admitted actor, resource restrictions, workspace, and expiry. */
const NESTED_RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'credentialId', 'mcpServerId'] as const

export type NestedResourceScope = Partial<
Record<(typeof NESTED_RESOURCE_SCOPE_KEYS)[number], string>
>

/** Adds the nested target to the admitted scope; a key already granted never moves to another resource. */
function narrowResourceScope(
granted: DelegatedPrincipal['resourceScope'],
target: NestedResourceScope
): NonNullable<DelegatedPrincipal['resourceScope']> {
const scope = { ...granted }
for (const key of NESTED_RESOURCE_SCOPE_KEYS) {
const value = target[key]
if (value === undefined) continue
if (!value.trim() || (scope[key] !== undefined && scope[key] !== value)) {
throw new OrchestrationError(
'forbidden',
'Nested operation cannot move its delegated resource scope'
)
}
scope[key] = value
}
return Object.freeze(scope)
}

/**
* Nested domain reads keep the admitted actor, resource restrictions, workspace, and expiry.
* A nested operation bound to one resource names it in `resourceScope`, as the executor and
* Chat tool paths do when they mint a principal for that resource.
*/
export function bindCopilotWorkspaceOperation<P extends Principal>(
principal: P,
workspaceId: string,
sourceAudiences: readonly string[],
useCase: Pick<OperationUseCase<ApplicationOperation, unknown, unknown>, 'delegationAudience'>
useCase: Pick<OperationUseCase<ApplicationOperation, unknown, unknown>, 'delegationAudience'>,
resourceScope?: NestedResourceScope
): P {
if (principal.kind !== 'delegated' || principal.serviceId !== 'copilot') return principal
if (
Expand All @@ -44,7 +75,13 @@ export function bindCopilotWorkspaceOperation<P extends Principal>(
'Nested operation requires the current workspace invocation'
)
}
const derived = { ...principal, audience: useCase.delegationAudience }
const derived = {
...principal,
audience: useCase.delegationAudience,
...(resourceScope
? { resourceScope: narrowResourceScope(principal.resourceScope, resourceScope) }
: {}),
}
if (derived.kind === 'delegated') markCopilotWorkspaceInvocation(derived)
return derived
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/** Chat's in-process CLI reaching selectors whose options are owned by another domain. */

import { db } from '@sim/db'
import {
credential,
credentialGroupEnrollment,
mcpServers,
permissions,
user,
workspace,
} from '@sim/db/schema'
import { sha256Hex } from '@sim/security/hash'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service'
import { encryptManagedMcpTokens } from '@/lib/credentials/managed-mcp'
import { generateManagedMcpConnectionId } from '@/lib/mcp/utils'
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'

const ORIGIN = 'http://localhost:3000'
const userId = generateId()
const workspaceId = generateId()
const connectionId = generateManagedMcpConnectionId()

function listSelector(selectorKey: string, context: Record<string, string>) {
const transport = createScopedCliTransport(ORIGIN, { userId, workspaceId, chatId: generateId() })
return withWorkspaceInvocationScope({ workspaceId }, () =>
transport(`${ORIGIN}/api/v2/selectors/list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ workspaceId, selectorKey, context }),
})
)
}

describe('Copilot selectors backed by another domain', () => {
beforeAll(async () => {
const now = new Date()
await db.insert(user).values({
id: userId,
name: 'Selector fixture',
email: `${userId}@selector.test`,
emailVerified: true,
createdAt: now,
updatedAt: now,
})
await db.insert(workspace).values({
id: workspaceId,
name: 'Selector fixture',
ownerId: userId,
billedAccountUserId: userId,
})
await db.insert(permissions).values({
id: generateId(),
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'admin',
})
const group = await ensureWorkspaceAccountsGroup(workspaceId, userId)
const serverId = generateId()
await db.insert(mcpServers).values({
id: serverId,
workspaceId,
credentialGroupId: group.id,
managedConnectorId: 'notion',
name: 'Notion',
transport: 'streamable-http',
url: 'https://mcp.notion.com/mcp',
authType: 'oauth',
createdBy: userId,
})
const enrollmentId = generateId()
await db.insert(credentialGroupEnrollment).values({
id: enrollmentId,
credentialGroupId: group.id,
userId,
email: `${userId}@selector.test`,
status: 'completed',
invitationTokenHash: sha256Hex(generateId()),
invitationExpiresAt: new Date(Date.now() + 60_000),
invitedAt: now,
})
await db.insert(credential).values({
id: connectionId,
workspaceId,
type: 'managed_mcp',
displayName: 'Notion',
grantedAt: now,
mcpTools: [],
credentialGroupEnrollmentId: enrollmentId,
mcpServerId: serverId,
managedOauthStatus: 'active',
encryptedOauthTokenSet: await encryptManagedMcpTokens({
access_token: 'fixture-access',
token_type: 'Bearer',
}),
})
})
afterAll(async () => {
await db.delete(workspace).where(eq(workspace.id, workspaceId))
await db.delete(user).where(eq(user.id, userId))
await db.$client.end()
})

it('decides a managed MCP connection by its credential-group rule instead of concealing it', async () => {
const response = await listSelector('mcp.tools', { mcpServerId: connectionId })
const body = await response.json()
expect(response.status, JSON.stringify(body)).toBe(403)
expect(body.error.message).toBe('Credential Group credential access denied')
})
})
9 changes: 7 additions & 2 deletions apps/sim/lib/selectors/application/execute-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.ser
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { authorizePersonalSearchSetup } from '@/lib/knowledge/application/personal-search-account'
import { type CredentialAuditRequest, recordCredentialAccess } from '@/lib/oauth/token-resolution'
import { selectorOperations } from '@/lib/selectors/application/operations'
import {
SELECTOR_DELEGATION_AUDIENCE,
selectorOperations,
} from '@/lib/selectors/application/operations'
import {
resolveSelectorApplicationContext,
type SelectorApplicationContext,
Expand Down Expand Up @@ -327,7 +330,9 @@ const executeWorkspaceSelector = defineAuthorizedWorkspaceUseCase<
if (context.workspaceId === undefined) throw new SelectorContextUnavailableError()
return context
},
authorizationOptions: { delegation: { audience: 'sim:selectors', isWithinScope: () => true } },
authorizationOptions: {
delegation: { audience: SELECTOR_DELEGATION_AUDIENCE, isWithinScope: () => true },
},
authorizeResource: ({ input, context }) => validateAuthorizedInput(input, context),
execute: executeAuthorizedSelector,
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/selectors/application/operations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation'

export const SELECTOR_DELEGATION_AUDIENCE = 'sim:selectors'

export const selectorOperations = {
// 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.
execute: defineWorkspaceOperation({
Expand Down
47 changes: 34 additions & 13 deletions apps/sim/lib/selectors/server/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
detailSelectorResult,
type ExecuteServerSelectorArgs,
listSelectorResult,
nestedSelectorPrincipal,
type SelectorPrincipal,
type ServerSelectorAttachmentMap,
} from '@/lib/selectors/server/types'
import { readTableUseCase } from '@/lib/table/application/tables'
Expand All @@ -45,17 +47,14 @@ function labelWorkflow(
return `${base} (${folder})`
}

async function loadWorkflows(
args: Parameters<(typeof listWorkflows)['execute']>[0]['principal'],
workspaceId: string
) {
async function loadWorkflows(principal: SelectorPrincipal, workspaceId: string) {
const workflows: Array<
Awaited<ReturnType<(typeof listWorkflows)['execute']>>['workflows'][number]
> = []
let cursorKeys: Awaited<ReturnType<(typeof listWorkflows)['execute']>>['nextCursorKeys'] = null
for (let page = 0; page < MAX_WORKFLOW_PAGES; page += 1) {
const result = await listWorkflows.execute({
principal: args,
principal: nestedSelectorPrincipal(principal, workspaceId, listWorkflows),
input: {
workspaceId,
scope: 'active',
Expand All @@ -82,7 +81,11 @@ export const internalSelectorAttachments = {
const knowledgeBaseId = args.context.knowledgeBaseId!
if (args.request.kind === 'detail') {
const result = await readKnowledgeDocument.execute({
principal: args.principal,
principal: nestedSelectorPrincipal(
args.principal,
args.workspaceId,
readKnowledgeDocument
),
input: {
knowledgeBaseId,
documentId: args.request.id,
Expand All @@ -98,7 +101,11 @@ export const internalSelectorAttachments = {
const offset = args.request.cursor ? Number(args.request.cursor) : 0
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid selector cursor')
const result = await listKnowledgeDocuments.execute({
principal: args.principal,
principal: nestedSelectorPrincipal(
args.principal,
args.workspaceId,
listKnowledgeDocuments
),
input: {
knowledgeBaseId,
assertedWorkspaceId: args.workspaceId,
Expand Down Expand Up @@ -150,9 +157,12 @@ export const internalSelectorAttachments = {
destination: 'fixed',
async execute(args: ExecuteServerSelectorArgs) {
if (!args.workspaceId) throw new SelectorContextUnavailableError()
const tableId = args.context.tableId!
const { table } = await readTableUseCase.execute({
principal: args.principal,
input: { tableId: args.context.tableId!, workspaceId: args.workspaceId },
principal: nestedSelectorPrincipal(args.principal, args.workspaceId, readTableUseCase, {
tableId,
}),
input: { tableId, workspaceId: args.workspaceId },
})
const options = (table.schema?.columns ?? [])
.filter((column) => column.unique)
Expand All @@ -168,9 +178,12 @@ export const internalSelectorAttachments = {
destination: 'fixed',
async execute(args: ExecuteServerSelectorArgs) {
if (!args.workspaceId) throw new SelectorContextUnavailableError()
const tableId = args.context.tableId!
const { table } = await readTableUseCase.execute({
principal: args.principal,
input: { tableId: args.context.tableId!, workspaceId: args.workspaceId },
principal: nestedSelectorPrincipal(args.principal, args.workspaceId, readTableUseCase, {
tableId,
}),
input: { tableId, workspaceId: args.workspaceId },
})
const options = (table.schema?.columns ?? []).map((column) => ({
id: getColumnId(column),
Expand Down Expand Up @@ -291,7 +304,11 @@ export const internalSelectorAttachments = {
if (args.request.kind === 'detail') {
const result = await getWorkspaceSandboxUseCase
.execute({
principal: args.principal,
principal: nestedSelectorPrincipal(
args.principal,
args.workspaceId,
getWorkspaceSandboxUseCase
),
input: { workspaceId: args.workspaceId, sandboxId: args.request.id },
})
.catch((error: unknown) => {
Expand All @@ -308,7 +325,11 @@ export const internalSelectorAttachments = {
})
}
const { sandboxes, nextCursorKeys } = await listWorkspaceSandboxesUseCase.execute({
principal: args.principal,
principal: nestedSelectorPrincipal(
args.principal,
args.workspaceId,
listWorkspaceSandboxesUseCase
),
input: { workspaceId: args.workspaceId, limit: 1000 },
})
if (nextCursorKeys) throw new SelectorOptionsUnavailableError()
Expand Down
15 changes: 13 additions & 2 deletions apps/sim/lib/selectors/server/providers/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
definePreparedSelectorAttachment,
detailSelectorResult,
listSelectorResult,
nestedSelectorPrincipal,
} from '@/lib/selectors/server/types'

/** The existing MCP use case binds the destination and credentials to an authorized server. */
Expand All @@ -25,7 +26,12 @@ export const mcpSelectorAttachments = {
if (!isManagedMcpConnectionId(serverId))
throw new OrchestrationError('validation', 'Invalid managed MCP connection ID')
return discoverManagedMcpToolsUseCase.execute({
principal: args.principal,
principal: nestedSelectorPrincipal(
args.principal,
args.workspaceId,
discoverManagedMcpToolsUseCase,
{ credentialId: serverId }
),
input: {
workspaceId: args.workspaceId,
credentialId: serverId,
Expand All @@ -34,7 +40,12 @@ export const mcpSelectorAttachments = {
})
}
return discoverMcpServerToolsUseCase.execute({
principal: args.principal,
principal: nestedSelectorPrincipal(
args.principal,
args.workspaceId,
discoverMcpServerToolsUseCase,
{ mcpServerId: serverId }
),
input: {
workspaceId: args.workspaceId,
serverId,
Expand Down
Loading
Loading