From a5dda7c1dfdff84c51364a25d0edd13641db28c1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 11 Aug 2026 19:16:41 -0700 Subject: [PATCH 1/3] fix(authz): enforce credential and workspace boundaries --- .../mcp/servers/[id]/refresh/route.test.ts | 26 ++-- .../app/api/mcp/servers/[id]/refresh/route.ts | 2 +- .../workspace-forking/lib/create-fork.test.ts | 11 +- .../ee/workspace-forking/lib/create-fork.ts | 4 +- .../knowledge/application/connectors.test.ts | 127 ++++++++++++++++++ .../lib/knowledge/application/connectors.ts | 33 ++++- apps/sim/lib/mcp/oauth/revoke.test.ts | 29 +++- apps/sim/lib/mcp/oauth/revoke.ts | 17 ++- .../orchestration/server-lifecycle.test.ts | 5 +- .../lib/mcp/orchestration/server-lifecycle.ts | 6 +- apps/sim/lib/workspaces/permissions/utils.ts | 2 + 11 files changed, 225 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts index 0feba60d055..e31560a561d 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts @@ -5,9 +5,10 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockClearCache, mockDiscoverServerTools } = vi.hoisted(() => ({ +const { mockClearCache, mockDiscoverServerTools, requiredRoles } = vi.hoisted(() => ({ mockClearCache: vi.fn(), mockDiscoverServerTools: vi.fn(), + requiredRoles: [] as string[], })) vi.mock('@/lib/core/utils/with-route-handler', () => ({ @@ -15,21 +16,22 @@ vi.mock('@/lib/core/utils/with-route-handler', () => ({ })) vi.mock('@/lib/mcp/middleware', () => ({ - withMcpAuth: - () => - ( + withMcpAuth: (requiredRole: string) => { + requiredRoles.push(requiredRole) + return ( handler: ( request: NextRequest, context: { userId: string; workspaceId: string; requestId: string }, routeContext: { params: Promise<{ id: string }> } ) => Promise ) => - (request: NextRequest, routeContext: { params: Promise<{ id: string }> }) => - handler( - request, - { userId: 'user-1', workspaceId: 'workspace-1', requestId: 'request-1' }, - routeContext - ), + (request: NextRequest, routeContext: { params: Promise<{ id: string }> }) => + handler( + request, + { userId: 'user-1', workspaceId: 'workspace-1', requestId: 'request-1' }, + routeContext + ) + }, })) vi.mock('@/lib/mcp/service', () => ({ @@ -72,6 +74,10 @@ describe('MCP server refresh route', () => { resetDbChainMock() }) + it('requires workspace write permission because refresh persists workflow changes', () => { + expect(requiredRoles).toEqual(['write']) + }) + it('preserves the service-persisted OAuth pending status', async () => { mockDiscoverServerTools.mockRejectedValueOnce(new Error('OAuth authorization required')) diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 26bb6ddaaef..b1ceda9d016 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -158,7 +158,7 @@ async function syncToolSchemasToWorkflows( } export const POST = withRouteHandler( - withMcpAuth<{ id: string }>('read')( + withMcpAuth<{ id: string }>('write')( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { try { const paramsValidation = mcpServerIdParamsSchema.safeParse(await params) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index 6c165193d02..56278fa1b86 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -91,7 +91,7 @@ vi.mock('@/lib/workspaces/policy', () => ({ import { createFork } from '@/ee/workspace-forking/lib/create-fork' -const SOURCE = { id: 'src-ws', name: 'Parent' } as never +const SOURCE = { id: 'src-ws', name: 'Parent', allowPersonalApiKeys: false } as never const POLICY = { organizationId: null, workspaceMode: 'personal', @@ -210,6 +210,15 @@ describe('createFork storage headroom gate', () => { ) }) + it('preserves the source workspace personal API-key policy in the child', async () => { + const result = await createFork(forkParams()) + + expect(result.workspace.allowPersonalApiKeys).toBe(false) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ allowPersonalApiKeys: false }) + ) + }) + it('seeds identity mappings for copied FILES by storage key (a later sync must not re-offer them)', async () => { mockPlanForkFileCopies.mockResolvedValue({ keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]), diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 1653dcb0448..09821fafa5d 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -170,7 +170,7 @@ export async function createFork(params: CreateForkParams): Promise ({ deleteConnector: vi.fn(), syncConnector: vi.fn(), resolveBilling: vi.fn(), + getCredentialActorContext: vi.fn(), + canUseCredential: vi.fn(), resolveTokenIdentity: vi.fn(), refreshToken: vi.fn(), validateConnectorConfig: vi.fn(), @@ -56,6 +58,8 @@ vi.mock('@/lib/knowledge/orchestration/connectors', () => ({ })) vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getCredentialActorContext, + canUseCredential: mocks.canUseCredential, resolveCredentialTokenIdentity: mocks.resolveTokenIdentity, })) @@ -120,6 +124,17 @@ describe('knowledge connector application use cases', () => { mocks.resolvePermission.mockResolvedValue('write') mocks.resolveKnowledgeBase.mockResolvedValue(crossWorkspaceContext) mocks.resolveConnector.mockResolvedValue(connectorContext) + mocks.getCredentialActorContext.mockResolvedValue({ + credential: { id: 'credential-1', workspaceId: 'workspace-a' }, + member: { role: 'member' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: false, + }) + mocks.canUseCredential.mockImplementation( + (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) => + access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin) + ) mocks.resolveTokenIdentity.mockResolvedValue({ kind: 'oauth', userId: 'credential-owner' }) mocks.refreshToken.mockResolvedValue('access-token') mocks.validateConnectorConfig.mockResolvedValue({ valid: true }) @@ -296,6 +311,7 @@ describe('knowledge connector application use cases', () => { expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( mocks.updateConnector.mock.invocationCallOrder[0] ) + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'shared-user') expect(mocks.resolveTokenIdentity).toHaveBeenCalledWith('credential-1', 'workspace-a') expect(mocks.refreshToken).toHaveBeenCalledWith( 'credential-1', @@ -305,6 +321,117 @@ describe('knowledge connector application use cases', () => { expect(mocks.validateConnectorConfig).toHaveBeenCalledWith('access-token', { space: 'ENG' }) }) + it('rejects connector creation when the writer cannot use the workspace credential', async () => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + mocks.resolveKnowledgeBase.mockResolvedValueOnce(sameWorkspaceContext) + mocks.getCredentialActorContext.mockResolvedValueOnce({ + credential: { id: 'credential-1', workspaceId: 'workspace-a' }, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: false, + }) + mocks.createConnector.mockImplementationOnce( + async (input: { resolveAccessToken: (credentialId: string) => Promise }) => { + const accessToken = await input.resolveAccessToken('credential-1') + return accessToken + ? { success: true, connector: sameWorkspaceContext.connector } + : { + success: false, + error: 'Credential has no access token. Please reconnect your account.', + errorCode: 'validation', + } + } + ) + + await expect( + createKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-a', + assertedWorkspaceId: 'workspace-a', + connectorType: 'confluence', + credentialId: 'credential-1', + sourceConfig: {}, + syncIntervalMinutes: 1440, + resolveBillingAttribution: mocks.resolveBilling, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'shared-user') + expect(mocks.resolveTokenIdentity).not.toHaveBeenCalled() + expect(mocks.refreshToken).not.toHaveBeenCalled() + }) + + it('rejects source-config revalidation after credential membership is removed', async () => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) + mocks.updateConnector.mockResolvedValueOnce({ + success: true, + connector: { ...sameWorkspaceContext.connector, sourceConfig: { space: 'ENG' } }, + }) + + await updateKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + updates: { sourceConfig: { space: 'ENG' } }, + }, + }) + + const orchestrationInput = mocks.updateConnector.mock.calls[0]?.[0] as { + validateSourceConfig?: ( + connector: { + connectorType: string + credentialId: string + encryptedApiKey: null + }, + sourceConfig: Record + ) => Promise + } + if (!orchestrationInput.validateSourceConfig) { + throw new Error('Application command did not provide source-config validation') + } + mocks.getCredentialActorContext.mockResolvedValueOnce({ + credential: { id: 'credential-1', workspaceId: 'workspace-a' }, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: false, + }) + + await expect( + orchestrationInput.validateSourceConfig( + { + connectorType: 'confluence', + credentialId: 'credential-1', + encryptedApiKey: null, + }, + { space: 'ENG' } + ) + ).resolves.toEqual({ + message: 'Credential is no longer usable in this workspace. Please reconnect it.', + errorCode: 'validation', + }) + expect(mocks.resolveTokenIdentity).not.toHaveBeenCalled() + expect(mocks.refreshToken).not.toHaveBeenCalled() + expect(mocks.validateConnectorConfig).not.toHaveBeenCalled() + }) + it.each([ [ 'create', diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 9832dd1b36e..3c952638c66 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -6,7 +6,11 @@ import { decryptApiKey } from '@/lib/api-key/crypto' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' -import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' +import { + canUseCredential, + getCredentialActorContext, + resolveCredentialTokenIdentity, +} from '@/lib/credentials/access' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' import { @@ -116,13 +120,29 @@ function requireConnectorWorkspaceId(context: ActiveKnowledgeResourceBaseContext return context.workspaceId } +async function resolveAuthorizedConnectorCredentialIdentity(input: { + credentialId: string + workspaceId: string + actingUserId: string +}) { + const access = await getCredentialActorContext(input.credentialId, input.actingUserId) + if ( + !access.credential || + access.credential.workspaceId !== input.workspaceId || + !canUseCredential(access) + ) { + return null + } + return resolveCredentialTokenIdentity(input.credentialId, input.workspaceId) +} + async function resolveConnectorCredentialAccessToken(input: { credentialId: string workspaceId: string actingUserId: string requestId: string }): Promise { - const identity = await resolveCredentialTokenIdentity(input.credentialId, input.workspaceId) + const identity = await resolveAuthorizedConnectorCredentialIdentity(input) if (!identity) return null return refreshAccessTokenIfNeeded( input.credentialId, @@ -163,10 +183,11 @@ async function validateConnectorSourceConfig(input: { errorCode: 'validation', } } - const identity = await resolveCredentialTokenIdentity( - input.connector.credentialId, - input.workspaceId - ) + const identity = await resolveAuthorizedConnectorCredentialIdentity({ + credentialId: input.connector.credentialId, + workspaceId: input.workspaceId, + actingUserId: input.actingUserId, + }) if (!identity) { return { message: 'Credential is no longer usable in this workspace. Please reconnect it.', diff --git a/apps/sim/lib/mcp/oauth/revoke.test.ts b/apps/sim/lib/mcp/oauth/revoke.test.ts index 86bf50d4795..ba91b2cad97 100644 --- a/apps/sim/lib/mcp/oauth/revoke.test.ts +++ b/apps/sim/lib/mcp/oauth/revoke.test.ts @@ -8,7 +8,7 @@ * raw `fetch`. */ -import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const BLOCKED_ENDPOINT = 'http://169.254.170.2/v2/credentials/' @@ -104,7 +104,7 @@ describe('revokeMcpOauthTokens — SSRF guard', () => { }) it('routes metadata discovery through the SSRF-guarded fetch', async () => { - await revokeMcpOauthTokens('server-1') + await revokeMcpOauthTokens('server-1', 'workspace-1') expect(mockDiscoverOAuthServerInfo).toHaveBeenCalledTimes(1) const [, options] = mockDiscoverOAuthServerInfo.mock.calls[0] @@ -112,13 +112,13 @@ describe('revokeMcpOauthTokens — SSRF guard', () => { }) it('validates the attacker-controlled revocation_endpoint before issuing the request', async () => { - await revokeMcpOauthTokens('server-1') + await revokeMcpOauthTokens('server-1', 'workspace-1') expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(BLOCKED_ENDPOINT) }) it('never issues an outbound request to the blocked revocation endpoint', async () => { - await revokeMcpOauthTokens('server-1') + await revokeMcpOauthTokens('server-1', 'workspace-1') const allCalls = [ ...mockUndiciFetch.mock.calls, @@ -131,7 +131,7 @@ describe('revokeMcpOauthTokens — SSRF guard', () => { }) it('swallows the SSRF rejection — revocation is best-effort and never throws', async () => { - await expect(revokeMcpOauthTokens('server-1')).resolves.toBeUndefined() + await expect(revokeMcpOauthTokens('server-1', 'workspace-1')).resolves.toBeUndefined() }) it('still issues the revocation POST when the endpoint resolves to a public IP', async () => { @@ -143,7 +143,7 @@ describe('revokeMcpOauthTokens — SSRF guard', () => { }, }) - await revokeMcpOauthTokens('server-1') + await revokeMcpOauthTokens('server-1', 'workspace-1') expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(publicEndpoint) const revokeCalls = mockUndiciFetch.mock.calls.filter((call) => { @@ -153,4 +153,21 @@ describe('revokeMcpOauthTokens — SSRF guard', () => { expect(revokeCalls.length).toBeGreaterThan(0) expect(revokeCalls[0][1]).toMatchObject({ method: 'POST' }) }) + + it('loads no OAuth tokens when the server is outside the authorized workspace', async () => { + resetDbChainMock() + queueTableRows(schemaMock.mcpServers, []) + + await revokeMcpOauthTokens('server-1', 'workspace-other') + + expect(mockLoadOauthRow).not.toHaveBeenCalled() + expect(mockDiscoverOAuthServerInfo).not.toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ type: 'eq', right: 'workspace-other' }), + ]), + }) + ) + }) }) diff --git a/apps/sim/lib/mcp/oauth/revoke.ts b/apps/sim/lib/mcp/oauth/revoke.ts index 6ec08d63d9c..89c9760d2c3 100644 --- a/apps/sim/lib/mcp/oauth/revoke.ts +++ b/apps/sim/lib/mcp/oauth/revoke.ts @@ -4,7 +4,7 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { decryptSecret } from '@/lib/core/security/encryption' import { loadOauthRow } from '@/lib/mcp/oauth/storage' import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' @@ -15,12 +15,14 @@ const REVOKE_TIMEOUT_MS = 5000 /** * Best-effort RFC 7009 revocation of tokens at the authorization server. * Never throws — revocation is advisory and must not block disconnect/delete flows. + * The workspace scope is mandatory so a caller cannot revoke a server it has not + * already resolved inside its authorized workspace. */ -export async function revokeMcpOauthTokens(mcpServerId: string): Promise { +export async function revokeMcpOauthTokens( + mcpServerId: string, + workspaceId: string +): Promise { try { - const row = await loadOauthRow({ mcpServerId }) - if (!row?.tokens) return - const [server] = await db .select({ url: mcpServers.url, @@ -28,10 +30,13 @@ export async function revokeMcpOauthTokens(mcpServerId: string): Promise { oauthClientSecret: mcpServers.oauthClientSecret, }) .from(mcpServers) - .where(eq(mcpServers.id, mcpServerId)) + .where(and(eq(mcpServers.id, mcpServerId), eq(mcpServers.workspaceId, workspaceId))) .limit(1) if (!server?.url) return + const row = await loadOauthRow({ mcpServerId }) + if (!row?.tokens) return + const ssrfGuardedFetch = createSsrfGuardedMcpFetch() const info = await discoverOAuthServerInfo(server.url, { fetchFn: ssrfGuardedFetch }).catch( () => undefined diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index 34b7fe2fbd8..073c5a1d0fa 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -150,7 +150,7 @@ describe('MCP server lifecycle orchestration', () => { }) ) // ...and revoke the now-orphaned OAuth tokens rather than leaving them stored and valid. - expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1') + expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') }) it('resets to disconnected when a create/upsert flips an existing OAuth server to headers', async () => { @@ -196,7 +196,7 @@ describe('MCP server lifecycle orchestration', () => { }) ) // ...and revoke the now-orphaned OAuth tokens. - expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1') + expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') }) it('evicts the deleted server from the connection pool (row is already gone from clearCache)', async () => { @@ -211,6 +211,7 @@ describe('MCP server lifecycle orchestration', () => { }) expect(result.success).toBe(true) + expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') expect(mockEvictServerConnections).toHaveBeenCalledWith('server-1', expect.any(String)) }) }) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index c646cb0e5d2..6f378ffafd5 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -195,7 +195,7 @@ export async function createMcpServer( const oauthDisabled = existingServer.authType === 'oauth' && resolvedAuthType !== 'oauth' const shouldClearOauth = urlChanged || credsChanged || isRevival || oauthDisabled - if (shouldClearOauth) await revokeMcpOauthTokens(serverId) + if (shouldClearOauth) await revokeMcpOauthTokens(serverId, params.workspaceId) await db.transaction(async (tx) => { if (shouldClearOauth) { @@ -354,7 +354,7 @@ export async function updateMcpServer( updateData.lastError = null } - if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId) + if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId, params.workspaceId) const server = await db.transaction(async (tx) => { const [updated] = await tx @@ -400,7 +400,7 @@ export async function deleteMcpServer( params: Omit ): Promise { try { - await revokeMcpOauthTokens(params.serverId) + await revokeMcpOauthTokens(params.serverId, params.workspaceId) const [server] = await db .delete(mcpServers) .where( diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index f890badd998..16bb3251d5a 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -25,6 +25,7 @@ export interface WorkspaceWithOwner { organizationId: string | null workspaceMode: WorkspaceMode billedAccountUserId: string + allowPersonalApiKeys: boolean archivedAt?: Date | null } @@ -95,6 +96,7 @@ export async function getWorkspaceWithOwner( organizationId: workspace.organizationId, workspaceMode: workspace.workspaceMode, billedAccountUserId: workspace.billedAccountUserId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, archivedAt: workspace.archivedAt, }) .from(workspace) From ff561ec4227241741c717792467373d2fd263b2e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 11 Aug 2026 19:28:51 -0700 Subject: [PATCH 2/3] fix(api): preserve fork policy in workflow detail --- apps/sim/lib/api/contracts/workflows.test.ts | 8 ++++++++ apps/sim/lib/api/contracts/workflows.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/apps/sim/lib/api/contracts/workflows.test.ts b/apps/sim/lib/api/contracts/workflows.test.ts index 946fad49e5b..05b7834b68f 100644 --- a/apps/sim/lib/api/contracts/workflows.test.ts +++ b/apps/sim/lib/api/contracts/workflows.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { executeWorkflowBodySchema, + getWorkflowResponseDataSchema, updateWorkflowBodySchema, workflowListItemSchema, } from '@/lib/api/contracts/workflows' @@ -119,4 +120,11 @@ describe('workflow contracts', () => { }) expect(item.forkSyncExcluded).toBe(false) }) + + it('detail response preserves forkSyncExcluded and defaults old responses', () => { + const forkPolicySchema = getWorkflowResponseDataSchema.pick({ forkSyncExcluded: true }) + + expect(forkPolicySchema.parse({ forkSyncExcluded: true }).forkSyncExcluded).toBe(true) + expect(forkPolicySchema.parse({}).forkSyncExcluded).toBe(false) + }) }) diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index db8a03ab329..bb1c7f9524b 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -743,6 +743,7 @@ export const getWorkflowResponseDataSchema = z.object({ deployedAt: z.coerce.date().nullable(), isPublicApi: z.boolean(), locked: z.boolean(), + forkSyncExcluded: z.boolean().default(false), runCount: z.number(), lastRunAt: z.coerce.date().nullable(), archivedAt: z.coerce.date().nullable(), From 526b85ec07cf55c020a819bed9c14de99b57df5f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 11 Aug 2026 19:29:07 -0700 Subject: [PATCH 3/3] fix(knowledge): preserve credential access guidance --- .../server/knowledge/knowledge-base.test.ts | 27 +++++++++++++++++++ .../knowledge/application/connectors.test.ts | 13 ++++++--- .../lib/knowledge/application/connectors.ts | 5 +++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 711a9ca58eb..f4eff85a65f 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -631,6 +631,33 @@ describe('knowledge_base trusted application delegation', () => { }) }) + it('preserves credential access guidance for connector creation', async () => { + mockCreateKnowledgeConnector.mockRejectedValueOnce( + new OrchestrationError( + 'validation', + 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.' + ) + ) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_connector', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + connectorType: 'notion', + credentialId: 'credential-1', + }, + }, + CONTEXT + ) + + expect(result).toEqual({ + success: false, + message: + 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.', + }) + }) + it('preserves caller-actionable tag provenance conflicts', async () => { mockDeleteKnowledgeTag.mockRejectedValueOnce( new OrchestrationError( diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index ed5f4682ef2..243cbfc12b5 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -363,7 +363,11 @@ describe('knowledge connector application use cases', () => { resolveBillingAttribution: mocks.resolveBilling, }, }) - ).rejects.toMatchObject({ code: 'validation' }) + ).rejects.toMatchObject({ + code: 'validation', + message: + 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.', + }) expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'shared-user') expect(mocks.resolveTokenIdentity).not.toHaveBeenCalled() @@ -423,9 +427,10 @@ describe('knowledge connector application use cases', () => { }, { space: 'ENG' } ) - ).resolves.toEqual({ - message: 'Credential is no longer usable in this workspace. Please reconnect it.', - errorCode: 'validation', + ).rejects.toMatchObject({ + code: 'validation', + message: + 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.', }) expect(mocks.resolveTokenIdentity).not.toHaveBeenCalled() expect(mocks.refreshToken).not.toHaveBeenCalled() diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 3c952638c66..c7e501d9dc6 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -131,7 +131,10 @@ async function resolveAuthorizedConnectorCredentialIdentity(input: { access.credential.workspaceId !== input.workspaceId || !canUseCredential(access) ) { - return null + throw new OrchestrationError( + 'validation', + 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.' + ) } return resolveCredentialTokenIdentity(input.credentialId, input.workspaceId) }