diff --git a/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts index e67ebd571a2..81c615dd918 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts @@ -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' @@ -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/) + }) +}) diff --git a/apps/sim/lib/core/application/copilot-workspace-invocation.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.ts index 3fb3a1fcd07..8d023187323 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.ts @@ -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 { + 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

( principal: P, workspaceId: string, sourceAudiences: readonly string[], - useCase: Pick, 'delegationAudience'> + useCase: Pick, 'delegationAudience'>, + resourceScope?: NestedResourceScope ): P { if (principal.kind !== 'delegated' || principal.serviceId !== 'copilot') return principal if ( @@ -44,7 +75,13 @@ export function bindCopilotWorkspaceOperation

( '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 } diff --git a/apps/sim/lib/selectors/__integration__/copilot-nested-selectors.integration.ts b/apps/sim/lib/selectors/__integration__/copilot-nested-selectors.integration.ts new file mode 100644 index 00000000000..8a8251b4165 --- /dev/null +++ b/apps/sim/lib/selectors/__integration__/copilot-nested-selectors.integration.ts @@ -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) { + 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') + }) +}) diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index d2d7a29eeb9..df881f217fb 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -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, @@ -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, }) diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts index 8377443d14a..8594705d022 100644 --- a/apps/sim/lib/selectors/application/operations.ts +++ b/apps/sim/lib/selectors/application/operations.ts @@ -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({ diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts index 446d1bdf236..887e8019195 100644 --- a/apps/sim/lib/selectors/server/internal.ts +++ b/apps/sim/lib/selectors/server/internal.ts @@ -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' @@ -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>['workflows'][number] > = [] let cursorKeys: Awaited>['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', @@ -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, @@ -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, @@ -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) @@ -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), @@ -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) => { @@ -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() diff --git a/apps/sim/lib/selectors/server/providers/mcp.ts b/apps/sim/lib/selectors/server/providers/mcp.ts index 7852d84372c..9d9a7392d3b 100644 --- a/apps/sim/lib/selectors/server/providers/mcp.ts +++ b/apps/sim/lib/selectors/server/providers/mcp.ts @@ -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. */ @@ -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, @@ -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, diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index 0ac2d246512..113ac2e2174 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -1,5 +1,11 @@ import type { Principal, SessionPrincipal } from '@sim/auth/principal' import type { CredentialAccessResult } from '@/lib/auth/credential-access' +import { + bindCopilotWorkspaceOperation, + type NestedResourceScope, +} from '@/lib/core/application/copilot-workspace-invocation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application/operation' +import { SELECTOR_DELEGATION_AUDIENCE } from '@/lib/selectors/application/operations' import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' import type { SelectorKey, ServerSelectorKey } from '@/lib/selectors/manifest' import type { @@ -14,6 +20,28 @@ export type SelectorPrincipal = | Extract | (Extract & { serviceId: 'copilot' }) +/** + * The principal a selector hands to another domain's use case. A Copilot + * invocation is admitted under the selector audience, which no other domain + * accepts, so it is re-bound to the nested use case's own audience for the same + * workspace, naming the one resource it reaches when that domain scopes its + * delegations. Every other principal passes through unchanged. + */ +export function nestedSelectorPrincipal

( + principal: P, + workspaceId: string, + useCase: Pick, 'delegationAudience'>, + resourceScope?: NestedResourceScope +): P { + return bindCopilotWorkspaceOperation( + principal, + workspaceId, + [SELECTOR_DELEGATION_AUDIENCE], + useCase, + resourceScope + ) +} + export type SelectorDestinationPolicy = 'fixed' | 'credential-bound' | 'user-controlled' export type SelectorProtectedValueKind = 'secret' | 'reference' diff --git a/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts index 7768e5148e2..13118cd4e0b 100644 --- a/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts +++ b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts @@ -4,6 +4,7 @@ import { outboxEvent, permissions, user, + userTableDefinitions, workflow, workflowBlocks, workflowDeploymentOperation, @@ -16,7 +17,9 @@ import { import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { processOutboxEventById } from '@/lib/core/outbox/service' +import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' import { admitWorkflowState, saveAdmittedWorkflowState } from '@/lib/workflows/persistence/utils' import { getWorkspaceOperation } from '@/lib/workspaces/operations/application' @@ -517,6 +520,99 @@ describe('authorized fork and sync against PostgreSQL', () => { } }) + it('validates a mapped table dependent through a Copilot CLI push preview', async () => { + const childId = await createChild() + const [sourceVersion] = await db + .select() + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, sourceWorkflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + const sourceTableId = generateId() + const childTableId = generateId() + const schema = { columns: [{ id: 'col_key', name: 'key', type: 'string', unique: true }] } + await db.insert(userTableDefinitions).values([ + { + id: sourceTableId, + workspaceId: sourceWorkspaceId, + name: `Source table ${sourceTableId}`, + schema, + createdBy: userId, + }, + { id: childTableId, workspaceId: childId, name: 'Child table', schema, createdBy: userId }, + ]) + const sourceState = structuredClone(sourceVersion.state) as WorkflowState + sourceState.blocks.upsert = { + id: 'upsert', + type: 'table', + name: 'Upsert', + enabled: true, + position: { x: 400, y: 0 }, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'upsert_row' }, + tableSelector: { id: 'tableSelector', type: 'table-selector', value: sourceTableId }, + conflictColumnSelector: { + id: 'conflictColumnSelector', + type: 'column-selector', + value: 'col_key', + }, + }, + outputs: {}, + } + const transport = createScopedCliTransport('http://localhost:3000', { + userId, + workspaceId: sourceWorkspaceId, + chatId: generateId(), + }) + try { + await db + .update(workflowDeploymentVersion) + .set({ state: sourceState }) + .where(eq(workflowDeploymentVersion.id, sourceVersion.id)) + const response = await withWorkspaceInvocationScope({ workspaceId: sourceWorkspaceId }, () => + transport( + `http://localhost:3000/api/v2/workspaces/${sourceWorkspaceId}/fork/push/preview`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + otherWorkspaceId: childId, + mappings: [ + { resourceType: 'table', sourceId: sourceTableId, targetId: childTableId }, + ], + dependentValues: [ + { + sourceWorkflowId, + sourceBlockId: 'upsert', + subBlockKey: 'conflictColumnSelector', + value: 'col_key', + }, + ], + }), + } + ) + ) + const body = await response.json() + expect(response.status, JSON.stringify(body)).toBe(200) + expect(body.data.configuration).toContainEqual( + expect.objectContaining({ + sourceBlockId: 'upsert', + subBlockKey: 'conflictColumnSelector', + currentValue: 'col_key', + discoveryWorkspaceId: childId, + }) + ) + } finally { + await db + .update(workflowDeploymentVersion) + .set({ state: sourceVersion.state }) + .where(eq(workflowDeploymentVersion.id, sourceVersion.id)) + } + }) + it('refuses inherited folder locks with no committed mutation', async () => { const childId = await createChild() const [target] = await db.select().from(workflow).where(eq(workflow.workspaceId, childId))