From 57ebc895a2411bf6fc5167c495c4cbd35de0d334 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 13:27:00 -0700 Subject: [PATCH 1/4] fix(api): align v2 permissions and resource behavior --- .../v2/files/[fileId]/content/route.test.ts | 2 +- .../api/v2/files/[fileId]/content/route.ts | 2 +- .../v2/files/[fileId]/metadata/route.test.ts | 6 +- .../api/v2/files/[fileId]/metadata/route.ts | 2 +- .../app/api/v2/files/[fileId]/route.test.ts | 12 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 6 +- .../app/api/v2/files/[fileId]/share/route.ts | 4 +- apps/sim/app/api/v2/files/uploads/utils.ts | 8 +- .../[id]/documents/[documentId]/route.ts | 2 +- .../api/v2/knowledge/[id]/documents/route.ts | 2 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 2 +- .../app/api/v2/knowledge/search/route.test.ts | 4 +- apps/sim/app/api/v2/knowledge/search/route.ts | 1 + .../sim/app/api/v2/logs/[runId]/route.test.ts | 6 +- apps/sim/app/api/v2/logs/[runId]/route.ts | 2 +- .../api/v2/tables/[tableId]/exports/route.ts | 2 +- .../api/v2/tables/[tableId]/groups/route.ts | 8 +- apps/sim/app/api/v2/tables/[tableId]/route.ts | 2 +- .../tables/[tableId]/views/[viewId]/route.ts | 2 +- .../api/v2/tables/[tableId]/views/route.ts | 2 +- .../exports/[exportId]/download/route.ts | 2 +- .../api/v2/tables/exports/[exportId]/route.ts | 4 +- .../imports/[importId]/complete/route.ts | 2 +- .../tables/imports/[importId]/parts/route.ts | 2 +- .../api/v2/tables/imports/[importId]/route.ts | 4 +- apps/sim/app/api/v2/tables/imports/route.ts | 2 +- apps/sim/app/api/v2/tables/utils.ts | 11 -- .../v2/workflows/[id]/deploy/route.test.ts | 5 +- .../app/api/v2/workflows/[id]/deploy/route.ts | 4 +- .../v2/workflows/[id]/execute/route.test.ts | 6 +- .../api/v2/workflows/[id]/execute/route.ts | 5 +- .../v2/workflows/[id]/export/route.test.ts | 5 +- .../app/api/v2/workflows/[id]/export/route.ts | 2 +- .../v2/workflows/[id]/rollback/route.test.ts | 3 +- .../api/v2/workflows/[id]/rollback/route.ts | 2 +- .../app/api/v2/workflows/[id]/route.test.ts | 8 +- apps/sim/app/api/v2/workflows/[id]/route.ts | 6 +- .../[id]/runs/[runId]/cancel/route.ts | 2 +- .../[id]/runs/[runId]/resume/route.test.ts | 1 - .../[id]/runs/[runId]/resume/route.ts | 2 +- .../workflows/[id]/runs/[runId]/route.test.ts | 8 +- .../v2/workflows/[id]/runs/[runId]/route.ts | 2 +- .../api/v2/workflows/[id]/runs/route.test.ts | 8 +- .../app/api/v2/workflows/[id]/runs/route.ts | 2 +- .../[id]/versions/[version]/route.ts | 2 +- .../api/v2/workflows/[id]/versions/route.ts | 2 +- .../api/v2/workflows/folders/route.test.ts | 1 - .../app/api/v2/workflows/import/route.test.ts | 1 - apps/sim/lib/api/server/routes/index.ts | 1 - .../routes/v2-resource-concealment.test.ts | 109 ------------------ .../server/routes/v2-resource-concealment.ts | 35 ------ .../application/audit-log-use-cases.test.ts | 20 ++++ .../audit-logs/application/list-audit-logs.ts | 4 + apps/sim/lib/audit-logs/query.test.ts | 30 ++++- apps/sim/lib/audit-logs/query.ts | 21 +++- .../application/billing-use-cases.test.ts | 59 ++++++++-- .../billing/application/list-billing-logs.ts | 37 +++--- apps/sim/lib/billing/core/usage-log.test.ts | 48 ++++++++ apps/sim/lib/billing/core/usage-log.ts | 40 +++++-- .../lib/knowledge/api/route-policies.test.ts | 28 +++-- apps/sim/lib/knowledge/api/route-policies.ts | 4 - apps/sim/lib/logs/api/route-policies.ts | 11 +- .../lib/mcp/application/operations.test.ts | 20 ++++ apps/sim/lib/mcp/application/operations.ts | 8 +- .../sim/lib/mcp/application/use-cases.test.ts | 17 ++- apps/sim/lib/table/api/route-policies.ts | 18 +-- apps/sim/lib/table/api/row-route-policies.ts | 2 +- .../lib/workflows/api/route-policies.test.ts | 35 +++--- apps/sim/lib/workflows/api/route-policies.ts | 7 -- .../lib/workflows/application/cancel-run.ts | 10 +- .../workflows/application/execute-workflow.ts | 13 +-- .../application/list-workflow-runs.ts | 14 +-- .../workflows/application/principal-scope.ts | 3 +- .../read-workflow-copilot-metadata.test.ts | 2 +- .../read-workflow-deployment-overview.test.ts | 4 +- .../application/read-workflow-run.ts | 10 +- .../lib/workflows/application/resume-run.ts | 10 +- .../run-workflow-from-copilot.test.ts | 2 +- .../application/workflow-crud.test.ts | 10 +- .../application/workflow-run-control.test.ts | 8 -- .../application/workflow-runs.test.ts | 5 - .../lib/workspace-files/api/route-policies.ts | 5 - .../application/operations.test.ts | 12 ++ .../workspace-files/application/operations.ts | 11 +- .../sim/lib/workspaces/public-queries.test.ts | 1 + apps/sim/lib/workspaces/public-queries.ts | 5 +- 86 files changed, 416 insertions(+), 444 deletions(-) delete mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts delete mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.ts create mode 100644 apps/sim/lib/mcp/application/operations.test.ts diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index acef3a88a5f..ba587b41e56 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -115,7 +115,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { const response = await callPut('{not-json') - expect(response.status).toBe(404) + expect(response.status).toBe(403) expect(mocks.admit).toHaveBeenCalledWith(auth.principal, FILE_ID) expect(mocks.updateContent).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index c85c5251366..59ffd948dfc 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -19,7 +19,7 @@ export const PUT = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.updateContent, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + errorPolicy: v2FileErrorPolicies.default, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 8a6434bd566..7378a3ca864 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -113,13 +113,13 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect(mocks.readMetadata).not.toHaveBeenCalled() }) - it('conceals an authorization failure as not found', async () => { + it('returns forbidden for an authorization failure', async () => { mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError()) const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - expect(response.status).toBe(404) - expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') }) it('returns the v2 metadata projection through the shared use case', async () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 68d1420c541..a5c9111b5d5 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -14,7 +14,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.readMetadata, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + errorPolicy: v2FileErrorPolicies.default, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index 907303c57b0..fa01357b539 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -141,7 +141,7 @@ describe('v2 single-file routes', () => { }) }) - it('conceals download authorization failures', async () => { + it('returns forbidden for download authorization failures', async () => { mocks.download.mockRejectedValue(new NoWorkspaceAccessError()) const response = await GET( @@ -149,8 +149,8 @@ describe('v2 single-file routes', () => { context ) - expect(response.status).toBe(404) - expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') }) it('renames through the shared use case and v2 presenter', async () => { @@ -170,7 +170,7 @@ describe('v2 single-file routes', () => { }) }) - it('maps rename conflicts and conceals absent workspace access', async () => { + it('maps rename conflicts and returns forbidden for absent workspace access', async () => { mocks.rename.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) const conflict = await PATCH( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { @@ -183,7 +183,7 @@ describe('v2 single-file routes', () => { expect(conflict.status).toBe(409) mocks.rename.mockRejectedValueOnce(new NoWorkspaceAccessError()) - const concealed = await PATCH( + const forbidden = await PATCH( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -191,7 +191,7 @@ describe('v2 single-file routes', () => { }), context ) - expect(concealed.status).toBe(404) + expect(forbidden.status).toBe(403) }) it('returns forbidden when the current workspace role cannot rename the file', async () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 10b1ca6c94d..34d3b32347c 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -33,7 +33,7 @@ export const GET = defineV2BinaryRoute({ auth: v2ApiKeyAuth, operation: fileOperations.download, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + errorPolicy: v2FileErrorPolicies.default, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, @@ -59,7 +59,7 @@ export const PATCH = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.rename, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + errorPolicy: v2FileErrorPolicies.default, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, @@ -81,7 +81,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.delete, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + errorPolicy: v2FileErrorPolicies.default, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 5b6e5a6a015..e11c99117f1 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -15,7 +15,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.readShare, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + errorPolicy: v2FileErrorPolicies.default, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, @@ -29,7 +29,7 @@ export const PUT = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.updateShare, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + errorPolicy: v2FileErrorPolicies.default, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index 45d60afd0c5..4293d4fb9ad 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -39,7 +39,7 @@ function uploadStatus(status: string): V2UploadStatus { import type { Principal } from '@sim/auth/principal' import type { NextRequest, NextResponse } from 'next/server' import { authenticateV2ApiKey } from '@/lib/api/server/routes/v2-api-key-auth' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' +import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' /** Re-authenticates the API key for each upload control leg. */ export async function authenticateUploadPrincipal(request: NextRequest): Promise { @@ -47,9 +47,7 @@ export async function authenticateUploadPrincipal(request: NextRequest): Promise return auth.principal } -/** Resource-ID upload controls conceal authorization failures as absence. */ +/** Renders upload-control application failures without rewriting authorization status. */ export function v2UploadControlError(error: unknown): NextResponse | null { - const response = v2CaughtOrchestrationError(error) - if (!response) return null - return response.status === 403 ? v2Error('NOT_FOUND', 'Upload session not found') : response + return v2CaughtOrchestrationError(error) } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 2a0faf317f5..175eeeffd04 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -33,7 +33,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.readDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.default, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index a8b04e82093..1e75f9a67ed 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -71,7 +71,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.listDocuments, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.default, mapInput: ({ params, query }) => { const decodedCursor = query.cursor ? decodeCursor<{ offset: number }>(query.cursor) : null if ( diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index c2047280fc9..5b22f32750f 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -24,7 +24,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.read, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.default, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index 6a307e41b96..916f140dab5 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -70,13 +70,14 @@ describe('POST /api/v2/knowledge/search', () => { }) }) - it('delegates normalized IDs through the semantic operation', async () => { + it('delegates normalized IDs and the selected search mode through the semantic operation', async () => { const request = buildRequest( JSON.stringify({ workspaceId: WORKSPACE_ID, knowledgeBaseIds: 'kb-1', query: 'hello', topK: 10, + searchMode: 'hybrid', }) ) @@ -91,6 +92,7 @@ describe('POST /api/v2/knowledge/search', () => { query: 'hello', topK: 10, tagFilters: undefined, + searchMode: 'hybrid', }, request, }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 07ba633ed00..0d6ac51d69e 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -26,6 +26,7 @@ export const POST = defineV2JsonRoute({ query: body.query, topK: body.topK, tagFilters: body.tagFilters, + searchMode: body.searchMode, }), useCase: searchKnowledge, present: (result) => ({ data: result }), diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index 907a1b00e1a..e109099438c 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -94,7 +94,7 @@ describe('GET /api/v2/logs/[runId]', () => { }) }) - it('conceals canonical workspace authorization as log not-found', async () => { + it('returns forbidden for canonical workspace authorization failures', async () => { mocks.execute.mockRejectedValueOnce( new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') ) @@ -103,9 +103,9 @@ describe('GET /api/v2/logs/[runId]', () => { params: Promise.resolve({ runId: 'run-1' }), }) - expect(response.status).toBe(404) + expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ - error: { code: 'NOT_FOUND', message: 'Log not found' }, + error: { code: 'FORBIDDEN', message: 'Workspace API key cannot perform this operation' }, }) }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index 1902a4b44b0..7d5d59bae91 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -16,7 +16,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: logOperations.readDetail, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2LogErrorPolicies.concealDetailAuthorization, + errorPolicy: v2LogErrorPolicies.default, mapInput: ({ params }) => ({ runId: params.runId }), useCase: getPublicLog, present: ({ log, workflowFolderPath, executionData }) => { diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 9de6249cbe0..cf3f0c48933 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.createExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, body }) => ({ tableId: params.tableId, workspaceId: body.workspaceId, diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts index 8910958f55a..0a2663b09bb 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -24,7 +24,7 @@ export const GET = defineV2JsonRoute({ useCase: listTableGroupsUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), present: ({ groups }) => ({ data: groups, nextCursor: null }), }) @@ -35,7 +35,7 @@ export const POST = defineV2JsonRoute({ useCase: createTableGroupUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ data: { group, columns: table.schema.columns.map(normalizeColumn) }, @@ -48,7 +48,7 @@ export const PATCH = defineV2JsonRoute({ useCase: updateTableGroupUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ data: { group, columns: table.schema.columns.map(normalizeColumn) }, @@ -61,7 +61,7 @@ export const DELETE = defineV2JsonRoute({ useCase: deleteTableGroupUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, groupId }) => ({ data: { diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index b3ec07a61d1..80fdc0c31be 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -48,7 +48,7 @@ export const GET = defineV2JsonRoute({ useCase: readTableUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), present: async ({ table, folderPath }) => ({ data: { table: await toApiTable(table, folderPath) }, diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts index 64de962a819..2561cc7c666 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -32,7 +32,7 @@ export const GET = defineV2JsonRoute({ useCase: readTableViewUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }), present: presentView, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts index cf87f47068f..303399cc61c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -19,7 +19,7 @@ export const GET = defineV2JsonRoute({ useCase: listTableViewsUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), present: async ({ views }) => { const emailByUserId = await getUserEmailsByIds( diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts index 88c00c6a9e1..2078ce20a58 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -12,7 +12,7 @@ export const GET = defineV2JsonRoute({ operation: tableOperations.downloadExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index d962454a3ad..5ccbb4ae231 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -16,7 +16,7 @@ export const GET = defineV2JsonRoute({ operation: tableOperations.readExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, @@ -30,7 +30,7 @@ export const DELETE = defineV2JsonRoute({ operation: tableOperations.cancelExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 368970ec9b9..9d27cec9afe 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.completeImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 1f4ddb01d01..9c9778a23d9 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -12,7 +12,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.createImportParts, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query, headers, body }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index 7092782c602..41f7511a968 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -16,7 +16,7 @@ export const GET = defineV2JsonRoute({ operation: tableOperations.readImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query }) => ({ importId: params.importId, workspaceId: query.workspaceId, @@ -30,7 +30,7 @@ export const DELETE = defineV2JsonRoute({ operation: tableOperations.cancelImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index ea630f4f9c7..31a07ddccfb 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.createImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + errorPolicy: v2TableErrorPolicies.default, mapInput: ({ body }) => ({ body }), useCase: createTableImportUseCase, present: ({ import: tableImport }) => presentV2CreateTableImport(tableImport), diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 63c251764da..f46d3fa1c17 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -191,17 +191,6 @@ export function v2CsvBodyCapError(request: { headers: Headers }): NextResponse | ) } -/** - * Renders a failed {@link checkAccess} result on a MUTATION path: a missing - * table stays 404, a missing permission stays 403. Read paths instead mask both - * as 404 inline so cross-workspace resource existence is never leaked. - */ -export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): NextResponse { - return result.status === 404 - ? v2Error('NOT_FOUND', 'Table not found') - : v2Error('FORBIDDEN', 'Access denied') -} - /** * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope, * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts index a00bc304b68..a7f99f19936 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts @@ -10,7 +10,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), - createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -33,7 +32,7 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => { expect(POST).toMatchObject({ operation: workflowOperations.deploy, useCase: deployWorkflow, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, parseOptions: { optionalJsonBody: true }, }) expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( @@ -89,7 +88,7 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => { expect(DELETE).toMatchObject({ operation: workflowOperations.undeploy, useCase: undeployWorkflow, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, }) const body = Reflect.get( DELETE, diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index d4a3051c075..40daae86be1 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -19,7 +19,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.deploy, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, parseOptions: { optionalJsonBody: true, invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), @@ -50,7 +50,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.undeploy, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), useCase: undeployWorkflow, present: (result) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index 5cce4b1bad5..781f203c61e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -408,7 +408,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(mockPreprocessExecution).not.toHaveBeenCalled() }) - it('masks a workspace-key/workflow mismatch as 404', async () => { + it('returns forbidden for a workspace-key/workflow mismatch', async () => { mockAuthenticateV2ApiKey.mockResolvedValue({ principal: { kind: 'workspace_api_key', @@ -423,8 +423,8 @@ describe('POST /api/v2/workflows/[id]/execute', () => { const res = await callExecute({ input: {} }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') }) it('rejects personal keys when the workspace disallows them', async () => { diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 6b7b120aaab..0a460ebd4b4 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -244,9 +244,8 @@ export const POST = withRouteHandler( userId, action: 'read', }) - // Mask authorization failures as 404 so cross-workspace existence never leaks. if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { - return v2Error('NOT_FOUND', 'Workflow not found') + return v2Error('FORBIDDEN', 'Insufficient workspace permissions') } result = await executeWorkflowService({ workflowId, @@ -306,7 +305,7 @@ export const POST = withRouteHandler( { headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } } ) } catch (error) { - const classified = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) + const classified = v2WorkflowErrorPolicies.default.render(error) if (classified) return classified logger.error(`[${requestId}] v2 execute failed`, { workflowId, diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts index 7cebfc70335..c87817af3de 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), - createV2ResourceConcealmentPolicy: vi.fn((options) => options), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -20,11 +19,11 @@ import { workflowOperations } from '@/lib/workflows/application/operations' import { GET } from '@/app/api/v2/workflows/[id]/export/route' describe('/api/v2/workflows/[id]/export route definition', () => { - it('uses canonical workflow authorization with concealment', () => { + it('uses canonical workflow authorization', () => { expect(GET).toMatchObject({ operation: workflowOperations.export, useCase: exportWorkflow, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, }) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index d4011a4f64c..7bb690a9173 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -12,7 +12,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.export, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: exportWorkflow, present: ({ payload, folderPath }) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts index 41996501de0..6a721a611bd 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts @@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), - createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -28,7 +27,7 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => { expect(POST).toMatchObject({ operation: workflowOperations.activateVersion, useCase: activateWorkflowVersion, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, parseOptions: { optionalJsonBody: true }, }) expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index ae0e1a3519f..9b7cda7ec1d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -15,7 +15,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.activateVersion, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, parseOptions: { optionalJsonBody: true, invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 61fadb6fe00..4e5cfbe5758 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -121,20 +121,20 @@ describe('/api/v2/workflows/[id]', () => { }) }) - it('conceals absent workspace access as workflow absence', async () => { + it('returns forbidden for absent workspace access', async () => { mocks.readWorkflow.mockRejectedValue(new NoWorkspaceAccessError()) const response = await GET( new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), routeContext ) - expect(response.status).toBe(404) + expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ - error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, }) }) - it('preserves the personal-key-disabled 403 instead of concealing it', async () => { + it('returns the personal-key-disabled policy failure as forbidden', async () => { mocks.readWorkflow.mockRejectedValue(new PersonalApiKeysDisabledError()) const response = await GET( new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 43c40be983f..175f1e80816 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -17,7 +17,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.read, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: readWorkflow, present: ({ workflow, workspaceId, folderPath, inputs }) => ({ @@ -44,7 +44,7 @@ export const PATCH = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.update, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params, body }) => ({ workflowId: params.id, ...body }), useCase: updateWorkflow, present: ({ workflow, workspaceId, folderPath, deployment }) => ({ @@ -69,7 +69,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.delete, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: deleteWorkflow, present: ({ workflowId }) => ({ data: { id: workflowId, deleted: true as const } }), diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts index 5418355124d..d29eee2a416 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.cancelRun, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params }) => ({ workflowId: params.id, runId: params.runId }), useCase: cancelWorkflowRun, present: (result) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts index a5d64d343bd..9e0db743b2d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts @@ -35,7 +35,6 @@ vi.mock('@/lib/api/server/routes', () => { return { admitV2Request: mocks.admit, createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), - createV2ResourceConcealmentPolicy: vi.fn(() => ({ render: renderOrchestrationError })), V2RouteInfrastructureError, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index cc0938d8232..a5ed1764f83 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -104,7 +104,7 @@ export const POST = withRouteHandler( { headers } ) } catch (error) { - const domainResponse = v2WorkflowErrorPolicies.concealRunAuthorization.render(error) + const domainResponse = v2WorkflowErrorPolicies.default.render(error) if (domainResponse) return domainResponse if (error instanceof ResumeWorkflowExecutionError) { if (!error.safeForPublicApi) throw error diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts index 774f60af9ae..3bb30a1fc54 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts @@ -196,15 +196,15 @@ describe('v2 run detail and cancel adapters', () => { expect(body.data.paused).not.toHaveProperty('pausedExecutionId') }) - it('conceals canonical run authorization failures as absence', async () => { + it('returns forbidden for canonical run authorization failures', async () => { mocks.readRun.mockRejectedValueOnce(new NoWorkspaceAccessError()) const response = await callStatus() - expect(response.status).toBe(404) + expect(response.status).toBe(403) expect((await response.json()).error).toMatchObject({ - code: 'NOT_FOUND', - message: 'Run not found', + code: 'FORBIDDEN', + message: 'Insufficient workspace permissions', }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts index f511c4391a8..beb9c1567c9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts @@ -21,7 +21,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.readRun, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params, query }) => ({ workflowId: params.id, runId: params.runId, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts index 74f355d59f8..0019a33aab3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts @@ -172,15 +172,15 @@ describe('GET /api/v2/workflows/[id]/runs', () => { expect(mocks.listRuns).not.toHaveBeenCalled() }) - it('conceals workflow authorization failures as absence', async () => { + it('returns forbidden for workflow authorization failures', async () => { mocks.listRuns.mockRejectedValueOnce(new NoWorkspaceAccessError()) const response = await callGet() - expect(response.status).toBe(404) + expect(response.status).toBe(403) expect((await response.json()).error).toMatchObject({ - code: 'NOT_FOUND', - message: 'Workflow not found', + code: 'FORBIDDEN', + message: 'Insufficient workspace permissions', }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index 6893f79f822..6afb6628ed5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -20,7 +20,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.listRuns, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params, query }) => { const { status, trigger, startDate, endDate, limit, cursor, order } = query const sort = cursorSortKey('startedAt', order) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index 351e2de49fa..c1a94477cc8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -13,7 +13,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.readVersion, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), useCase: readWorkflowVersion, present: ({ version }) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index 1fbb169fe33..d976dfac5f3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -19,7 +19,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.listVersions, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + errorPolicy: v2WorkflowErrorPolicies.default, mapInput: ({ params, query }) => { const after = query.cursor ? decodeCursor(query.cursor) : null if (query.cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts index 2e728cb94db..1a1a9dc75c6 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.test.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), - createV2ResourceConcealmentPolicy: vi.fn((options) => options), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, diff --git a/apps/sim/app/api/v2/workflows/import/route.test.ts b/apps/sim/app/api/v2/workflows/import/route.test.ts index c1d7239647c..59fcb056cf2 100644 --- a/apps/sim/app/api/v2/workflows/import/route.test.ts +++ b/apps/sim/app/api/v2/workflows/import/route.test.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), - createV2ResourceConcealmentPolicy: vi.fn((options) => options), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 03951db9c67..fb1002d524a 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -25,4 +25,3 @@ export { v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' -export { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/v2-resource-concealment' diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts deleted file mode 100644 index 818352f754d..00000000000 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import type { V2ErrorPolicy } from '@/lib/api/server/routes' -import { - DelegatedWorkspaceAuthorizationError, - InsufficientWorkspacePermissionsError, - NoWorkspaceAccessError, - PersonalApiKeysDisabledError, - PrincipalKindAuthorizationError, - WorkspaceApiKeyAuthorizationError, -} from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' -import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' -import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' -import { v2FileErrorPolicies } from '@/lib/workspace-files/api/route-policies' - -const policies: Array<{ - domain: string - policy: V2ErrorPolicy - notFoundMessage: string -}> = [ - { - domain: 'file', - policy: v2FileErrorPolicies.concealResourceAuthorization, - notFoundMessage: 'File not found', - }, - { - domain: 'workflow', - policy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - notFoundMessage: 'Workflow not found', - }, - { - domain: 'workflow run', - policy: v2WorkflowErrorPolicies.concealRunAuthorization, - notFoundMessage: 'Run not found', - }, - { - domain: 'table', - policy: v2TableErrorPolicies.concealTableAuthorization, - notFoundMessage: 'Table not found', - }, - { - domain: 'table import', - policy: v2TableErrorPolicies.concealImportAuthorization, - notFoundMessage: 'Table import not found', - }, - { - domain: 'table export', - policy: v2TableErrorPolicies.concealExportAuthorization, - notFoundMessage: 'Table export not found', - }, - { - domain: 'knowledge base', - policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, - notFoundMessage: 'Knowledge base not found', - }, -] - -const resourceAuthorizationErrors = [ - new NoWorkspaceAccessError(), - new WorkspaceApiKeyAuthorizationError(), - new DelegatedWorkspaceAuthorizationError(), - new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), -] - -describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => { - it.each(resourceAuthorizationErrors)( - 'conceals typed resource authorization: %s', - async (error) => { - const response = policy.render(error) - expect(response?.status).toBe(404) - await expect(response?.json()).resolves.toEqual({ - error: { code: 'NOT_FOUND', message: notFoundMessage }, - }) - } - ) - - it('preserves workspace personal-key policy denial as forbidden', async () => { - const response = policy.render(new PersonalApiKeysDisabledError()) - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toEqual({ - error: { - code: 'FORBIDDEN', - message: 'Personal API keys are not allowed for this workspace', - }, - }) - }) - - it('preserves insufficient workspace role as forbidden', async () => { - const response = policy.render(new InsufficientWorkspacePermissionsError()) - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toEqual({ - error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, - }) - }) - - it('does not classify generic forbidden errors by message', async () => { - const response = policy.render( - new OrchestrationError('forbidden', 'Insufficient workspace permissions') - ) - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toEqual({ - error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, - }) - }) -}) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts deleted file mode 100644 index 82d56f4e4b3..00000000000 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' -import { - DelegatedWorkspaceAuthorizationError, - NoWorkspaceAccessError, - PrincipalKindAuthorizationError, - WorkspaceApiKeyAuthorizationError, -} from '@/lib/core/application' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' - -type V2ErrorRenderer = V2ErrorPolicy['render'] - -function isResourceAuthorizationError(error: unknown): boolean { - return ( - error instanceof DelegatedWorkspaceAuthorizationError || - error instanceof NoWorkspaceAccessError || - error instanceof PrincipalKindAuthorizationError || - error instanceof WorkspaceApiKeyAuthorizationError - ) -} - -/** Conceals only typed resource-authorization failures without hiding workspace policy denials. */ -export function createV2ResourceConcealmentPolicy(options: { - notFoundMessage: string - render?: V2ErrorRenderer -}): V2ErrorPolicy { - const render = options.render ?? v2CaughtOrchestrationError - return { - render(error) { - if (isResourceAuthorizationError(error)) { - return v2Error('NOT_FOUND', options.notFoundMessage) - } - return render(error) - }, - } -} diff --git a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts index afce912a58d..60c4def062d 100644 --- a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts +++ b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ getOrgWorkspaceIds: vi.fn(), buildOrgScopeCondition: vi.fn(), buildFilterConditions: vi.fn(), + decodeAuditLogCursor: vi.fn(), queryAuditLogs: vi.fn(), recordAudit: vi.fn(), })) @@ -22,6 +23,7 @@ vi.mock('@/lib/audit-logs/query', () => ({ getOrgWorkspaceIds: mocks.getOrgWorkspaceIds, buildOrgScopeCondition: mocks.buildOrgScopeCondition, buildFilterConditions: mocks.buildFilterConditions, + decodeAuditLogCursor: mocks.decodeAuditLogCursor, queryAuditLogs: mocks.queryAuditLogs, })) @@ -58,6 +60,10 @@ describe('audit-log application use cases', () => { mocks.getOrgWorkspaceIds.mockResolvedValue(['workspace-1']) mocks.buildOrgScopeCondition.mockReturnValue({ type: 'scope' }) mocks.buildFilterConditions.mockReturnValue([]) + mocks.decodeAuditLogCursor.mockReturnValue({ + createdAt: '2026-01-01T00:00:00.000Z', + id: 'audit-1', + }) mocks.queryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) }) @@ -96,6 +102,20 @@ describe('audit-log application use cases', () => { expect(mocks.queryAuditLogs).not.toHaveBeenCalled() }) + it('rejects a malformed cursor instead of restarting at page one', async () => { + mocks.decodeAuditLogCursor.mockReturnValueOnce(null) + + await expect( + listAuditLogs.execute({ + principal: sessionPrincipal, + input: { ...listInput, cursor: 'not-a-cursor' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'Invalid audit-log cursor' }) + + expect(mocks.getOrgWorkspaceIds).not.toHaveBeenCalled() + expect(mocks.queryAuditLogs).not.toHaveBeenCalled() + }) + it('returns a typed not-found only after applying organization scope', async () => { dbChainMockFns.limit.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/audit-logs/application/list-audit-logs.ts b/apps/sim/lib/audit-logs/application/list-audit-logs.ts index a387267fb5a..89b552ab9e6 100644 --- a/apps/sim/lib/audit-logs/application/list-audit-logs.ts +++ b/apps/sim/lib/audit-logs/application/list-audit-logs.ts @@ -4,6 +4,7 @@ import { type AuditLogFilterParams, buildFilterConditions, buildOrgScopeCondition, + decodeAuditLogCursor, getOrgWorkspaceIds, queryAuditLogs, } from '@/lib/audit-logs/query' @@ -23,6 +24,9 @@ export const listAuditLogs = defineAuthorizedAuditLogUseCase({ operation: auditLogOperations.list, organizationId: (input: ListAuditLogsInput) => input.organizationId, execute: async ({ input, context }): Promise => { + if (input.cursor && !decodeAuditLogCursor(input.cursor)) { + throw new OrchestrationError('validation', 'Invalid audit-log cursor') + } const orgWorkspaceIds = await getOrgWorkspaceIds(context.organizationId) if (input.filters.workspaceId && !orgWorkspaceIds.includes(input.filters.workspaceId)) { throw new OrchestrationError('validation', 'workspaceId does not belong to your organization') diff --git a/apps/sim/lib/audit-logs/query.test.ts b/apps/sim/lib/audit-logs/query.test.ts index 78b82b90767..08105b6f908 100644 --- a/apps/sim/lib/audit-logs/query.test.ts +++ b/apps/sim/lib/audit-logs/query.test.ts @@ -7,7 +7,11 @@ */ import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' +import { + buildOrgScopeCondition, + decodeAuditLogCursor, + getOrgWorkspaceIds, +} from '@/lib/audit-logs/query' const ORG_ID = 'org-1' const MEMBER_IDS = ['user-1', 'user-2'] @@ -167,3 +171,27 @@ describe('getOrgWorkspaceIds', () => { ) }) }) + +describe('decodeAuditLogCursor', () => { + it('accepts the exact timestamp and ID cursor shape', () => { + const cursor = Buffer.from( + JSON.stringify({ createdAt: '2026-01-01T00:00:00.000Z', id: 'audit-1' }) + ).toString('base64') + + expect(decodeAuditLogCursor(cursor)).toEqual({ + createdAt: '2026-01-01T00:00:00.000Z', + id: 'audit-1', + }) + }) + + it.each([ + 'not-base64', + Buffer.from('{}').toString('base64'), + Buffer.from(JSON.stringify({ createdAt: 'not-a-date', id: 'audit-1' })).toString('base64'), + Buffer.from(JSON.stringify({ createdAt: '2026-01-01T00:00:00.000Z', id: 1 })).toString( + 'base64' + ), + ])('rejects malformed cursor %s', (cursor) => { + expect(decodeAuditLogCursor(cursor)).toBeNull() + }) +}) diff --git a/apps/sim/lib/audit-logs/query.ts b/apps/sim/lib/audit-logs/query.ts index 70fbea8fde1..586d2e43d1a 100644 --- a/apps/sim/lib/audit-logs/query.ts +++ b/apps/sim/lib/audit-logs/query.ts @@ -15,9 +15,20 @@ function encodeCursor(data: CursorData): string { return Buffer.from(JSON.stringify(data)).toString('base64') } -function decodeCursor(cursor: string): CursorData | null { +export function decodeAuditLogCursor(cursor: string): CursorData | null { try { - return JSON.parse(Buffer.from(cursor, 'base64').toString()) + const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64').toString()) + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) return null + const { createdAt, id } = decoded as Partial + if ( + typeof createdAt !== 'string' || + Number.isNaN(new Date(createdAt).getTime()) || + typeof id !== 'string' || + !id + ) { + return null + } + return { createdAt, id } } catch { return null } @@ -116,11 +127,9 @@ export function buildOrgScopeCondition(params: OrgScopeParams): SQL { } function buildCursorCondition(cursor: string): SQL | null { - const cursorData = decodeCursor(cursor) - if (!cursorData?.createdAt || !cursorData.id) return null - + const cursorData = decodeAuditLogCursor(cursor) + if (!cursorData) return null const cursorDate = new Date(cursorData.createdAt) - if (Number.isNaN(cursorDate.getTime())) return null return or( lt(auditLog.createdAt, cursorDate), diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index d3ebabc2a66..9e51e5d4d4a 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -22,7 +22,7 @@ const mocks = vi.hoisted(() => ({ getUserStorageLimit: vi.fn(), getUserStorageUsage: vi.fn(), getUsageLogs: vi.fn(), - getCredits: vi.fn(), + getWorkspaceUsageLogs: vi.fn(), recordAudit: vi.fn(), })) @@ -56,7 +56,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ vi.mock('@/lib/billing/core/usage-log', () => ({ deriveBillingContext: mocks.deriveBillingContext, getUserUsageLogs: mocks.getUsageLogs, - getUsageCreditsByLogId: mocks.getCredits, + getWorkspaceUsageLogs: mocks.getWorkspaceUsageLogs, })) vi.mock('@/lib/billing/storage', () => ({ @@ -123,7 +123,11 @@ describe('billing application use cases', () => { summary: { totalCost: 0, bySource: {} }, pagination: { hasMore: false }, }) - mocks.getCredits.mockResolvedValue({}) + mocks.getWorkspaceUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) }) it('rejects unsupported principals before protected loading', async () => { @@ -186,7 +190,7 @@ describe('billing application use cases', () => { expect(mocks.loadWorkspace).not.toHaveBeenCalled() }) - it('uses the billing owner only as the workspace ledger attribution', async () => { + it('lists the complete workspace ledger for a workspace key', async () => { await listBillingLogs.execute({ principal: workspacePrincipal, input: { @@ -196,14 +200,55 @@ describe('billing application use cases', () => { }, }) - expect(mocks.getUsageLogs).toHaveBeenCalledWith( - 'billing-owner-1', - expect.objectContaining({ workspaceId: 'workspace-1' }) + expect(mocks.getWorkspaceUsageLogs).toHaveBeenCalledWith( + 'workspace-1', + expect.objectContaining({ includeSummary: false, limit: 50 }) ) + expect(mocks.getUsageLogs).not.toHaveBeenCalled() expect(mocks.resolvePermission).not.toHaveBeenCalled() expect(mocks.recordAudit).not.toHaveBeenCalled() }) + it('keeps personal-key billing logs actor-scoped and workspace-filtered', async () => { + await listBillingLogs.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: new Date('2026-02-01T00:00:00Z'), + limit: 50, + }, + }) + + expect(mocks.getUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ workspaceId: 'workspace-1', includeSummary: false, limit: 50 }) + ) + expect(mocks.getWorkspaceUsageLogs).not.toHaveBeenCalled() + }) + + it('apportions credits only across the bounded page', async () => { + mocks.getWorkspaceUsageLogs.mockResolvedValueOnce({ + logs: [ + { id: 'log-1', cost: 0.003 }, + { id: 'log-2', cost: 0.003 }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-2' }, + }) + + const result = await listBillingLogs.execute({ + principal: workspacePrincipal, + input: { + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: new Date('2026-02-01T00:00:00Z'), + limit: 2, + }, + }) + + expect(result.creditsByLogId).toEqual({ 'log-1': 1, 'log-2': 0 }) + }) + it('propagates workspace-store failures', async () => { const failure = new Error('database unavailable') mocks.loadWorkspace.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/billing/application/list-billing-logs.ts b/apps/sim/lib/billing/application/list-billing-logs.ts index 7986d2ea290..7ef71730e06 100644 --- a/apps/sim/lib/billing/application/list-billing-logs.ts +++ b/apps/sim/lib/billing/application/list-billing-logs.ts @@ -1,11 +1,11 @@ import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' import { billingOperations } from '@/lib/billing/application/operations' -import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' import { - getUsageCreditsByLogId, getUserUsageLogs, + getWorkspaceUsageLogs, type UsageLogSource, } from '@/lib/billing/core/usage-log' +import { apportionCredits } from '@/lib/billing/credits/conversion' export interface ListBillingLogsInput { workspaceId?: string @@ -26,31 +26,26 @@ export const listBillingLogs = defineAuthorizedBillingReadUseCase({ requestedWorkspaceId: (input: ListBillingLogsInput) => input.workspaceId, execute: async ({ principal, input, scope }): Promise => { const workspaceId = scope.kind === 'workspace' ? scope.workspace.workspaceId : undefined - let ledgerUserId: string + const query = { + source: input.source, + startDate: input.startDate, + endDate: input.endDate, + limit: input.limit, + cursor: input.cursor, + includeSummary: false, + } + let usage: ListBillingLogsResult['usage'] if (principal.kind === 'personal_api_key') { - ledgerUserId = principal.userId + usage = await getUserUsageLogs(principal.userId, { ...query, workspaceId }) } else { if (scope.kind !== 'workspace') { throw new Error('Workspace API key billing logs require a workspace scope') } - ledgerUserId = (await resolveSystemBillingAttribution(scope.workspace.workspaceId)) - .billedAccountUserId - } - const filter = { - source: input.source, - workspaceId, - startDate: input.startDate, - endDate: input.endDate, + usage = await getWorkspaceUsageLogs(scope.workspace.workspaceId, query) } - const [usage, creditsByLogId] = await Promise.all([ - getUserUsageLogs(ledgerUserId, { - ...filter, - limit: input.limit, - cursor: input.cursor, - includeSummary: false, - }), - getUsageCreditsByLogId(ledgerUserId, filter), - ]) + const creditsByLogId = apportionCredits( + usage.logs.map((log) => ({ key: log.id, dollars: log.cost })) + ) return { usage, creditsByLogId } }, }) diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index a4512999cdb..9812f0777c3 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -36,6 +36,8 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ import { CUMULATIVE_COST_EPSILON, CumulativeUsageContextMismatchError, + getUserUsageLogs, + getWorkspaceUsageLogs, recordCumulativeUsage, recordUsage, resolveCumulativeTopUp, @@ -390,3 +392,49 @@ describe('recordCumulativeUsage', () => { expect(executedSqlContaining(tx, 'hashtextextended')).toBe(true) }) }) + +interface MockCondition { + type?: string + conditions?: MockCondition[] + left?: string + right?: string +} + +function latestWhereCondition(): MockCondition { + const condition = dbChainMockFns.where.mock.calls.at(-1)?.[0] + if (!condition) throw new Error('Expected a usage-log where condition') + return condition as MockCondition +} + +describe('usage-log query scopes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('queries a complete workspace ledger without an actor predicate', async () => { + await getWorkspaceUsageLogs('workspace-1', { limit: 25, includeSummary: false }) + + expect(latestWhereCondition()).toMatchObject({ + type: 'and', + conditions: [{ type: 'eq', left: 'workspaceId', right: 'workspace-1' }], + }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(26) + }) + + it('keeps personal queries actor-scoped with an optional workspace filter', async () => { + await getUserUsageLogs('user-1', { + workspaceId: 'workspace-1', + limit: 25, + includeSummary: false, + }) + + expect(latestWhereCondition()).toMatchObject({ + type: 'and', + conditions: [ + { type: 'eq', left: 'userId', right: 'user-1' }, + { type: 'eq', left: 'workspaceId', right: 'workspace-1' }, + ], + }) + }) +}) diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 1eb8fd93f54..0fd588b922d 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -613,8 +613,14 @@ interface UsageLogFilter { endDate?: Date } -function buildUsageLogConditions(userId: string, filter: UsageLogFilter) { - const conditions = [eq(usageLog.userId, userId)] +type UsageLogScope = { kind: 'user'; userId: string } | { kind: 'workspace'; workspaceId: string } + +function buildUsageLogConditions(scope: UsageLogScope, filter: UsageLogFilter) { + const conditions = [ + scope.kind === 'user' + ? eq(usageLog.userId, scope.userId) + : eq(usageLog.workspaceId, scope.workspaceId), + ] if (filter.source) { conditions.push( Array.isArray(filter.source) @@ -643,7 +649,7 @@ export async function getUsageCreditsByLogId( const rows = await dbReplica .select({ id: usageLog.id, cost: usageLog.cost }) .from(usageLog) - .where(and(...buildUsageLogConditions(userId, filter))) + .where(and(...buildUsageLogConditions({ kind: 'user', userId }, filter))) .orderBy(desc(usageLog.createdAt), desc(usageLog.id)) return apportionCredits( @@ -718,10 +724,10 @@ export interface UsageLogsResult { } /** - * Get usage logs for a user with optional filtering and pagination + * Gets one bounded usage-log page for an explicit actor or workspace scope. */ -export async function getUserUsageLogs( - userId: string, +async function getUsageLogs( + scope: UsageLogScope, options: GetUsageLogsOptions = {} ): Promise { const { @@ -736,7 +742,7 @@ export async function getUserUsageLogs( } = options try { - const conditions = buildUsageLogConditions(userId, { source, workspaceId, startDate, endDate }) + const conditions = buildUsageLogConditions(scope, { source, workspaceId, startDate, endDate }) if (cursor) { let resolvedCursorCreatedAt = cursorCreatedAt @@ -803,7 +809,7 @@ export async function getUserUsageLogs( let totalCost = 0 if (includeSummary) { - const summaryConditions = buildUsageLogConditions(userId, { + const summaryConditions = buildUsageLogConditions(scope, { source, workspaceId, startDate, @@ -841,9 +847,25 @@ export async function getUserUsageLogs( } catch (error) { logger.error('Failed to get usage logs', { error: toError(error).message, - userId, + scope, options, }) throw error } } + +/** Gets usage logs whose actor is the selected user. */ +export function getUserUsageLogs( + userId: string, + options: GetUsageLogsOptions = {} +): Promise { + return getUsageLogs({ kind: 'user', userId }, options) +} + +/** Gets usage logs attributed to the selected workspace, regardless of actor. */ +export function getWorkspaceUsageLogs( + workspaceId: string, + options: Omit = {} +): Promise { + return getUsageLogs({ kind: 'workspace', workspaceId }, options) +} diff --git a/apps/sim/lib/knowledge/api/route-policies.test.ts b/apps/sim/lib/knowledge/api/route-policies.test.ts index f2b3b905539..d7c3b739a50 100644 --- a/apps/sim/lib/knowledge/api/route-policies.test.ts +++ b/apps/sim/lib/knowledge/api/route-policies.test.ts @@ -19,18 +19,14 @@ describe('v2 knowledge error policies', () => { new WorkspaceApiKeyAuthorizationError(), new DelegatedWorkspaceAuthorizationError(), new PrincipalKindAuthorizationError('workspace_api_key', 'knowledge.read'), - ])('conceals canonical resource authorization failures as absence', async (error) => { - const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(error) - expect(response?.status).toBe(404) - expect(await response?.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Knowledge base not found' }, - }) + ])('returns forbidden for canonical resource authorization failures', async (error) => { + const response = v2KnowledgeErrorPolicies.default.render(error) + expect(response?.status).toBe(403) + expect(await response?.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) it('preserves the personal-api-key policy failure as forbidden', async () => { - const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render( - new PersonalApiKeysDisabledError() - ) + const response = v2KnowledgeErrorPolicies.default.render(new PersonalApiKeysDisabledError()) expect(response?.status).toBe(403) expect(await response?.json()).toEqual({ error: { @@ -40,8 +36,8 @@ describe('v2 knowledge error policies', () => { }) }) - it('does not conceal unrelated forbidden business errors', async () => { - const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render( + it('preserves unrelated forbidden business errors', async () => { + const response = v2KnowledgeErrorPolicies.default.render( new OrchestrationError('forbidden', 'Knowledge base transition is forbidden') ) expect(response?.status).toBe(403) @@ -49,4 +45,14 @@ describe('v2 knowledge error policies', () => { error: { code: 'FORBIDDEN', message: 'Knowledge base transition is forbidden' }, }) }) + + it('preserves genuine not-found failures', async () => { + const response = v2KnowledgeErrorPolicies.default.render( + new OrchestrationError('not_found', 'Knowledge base not found') + ) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Knowledge base not found' }, + }) + }) }) diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index 83e7cc498b0..b5262da2449 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -1,6 +1,5 @@ import { createInternalSessionOrExecutorAuth, - createV2ResourceConcealmentPolicy, type InternalErrorPolicy, internalErrorResponse, internalPlainOrchestrationErrorPolicy, @@ -92,7 +91,4 @@ export const v2KnowledgeErrorPolicies = { return v2KnowledgeUsageErrorPolicy.render(error) }, } satisfies V2ErrorPolicy, - concealKnowledgeBaseAuthorization: createV2ResourceConcealmentPolicy({ - notFoundMessage: 'Knowledge base not found', - }), } as const diff --git a/apps/sim/lib/logs/api/route-policies.ts b/apps/sim/lib/logs/api/route-policies.ts index 0c1ca12ed39..cb1196a7593 100644 --- a/apps/sim/lib/logs/api/route-policies.ts +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -1,14 +1,5 @@ -import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' +import { v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' export const v2LogErrorPolicies = { default: v2OrchestrationErrorPolicy, - concealDetailAuthorization: { - render(error) { - const response = v2CaughtOrchestrationError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'Log not found') - return response - }, - } satisfies V2ErrorPolicy, } as const diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts new file mode 100644 index 00000000000..0a0ea749ab4 --- /dev/null +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -0,0 +1,20 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mcpServerOperations } from '@/lib/mcp/application/operations' + +describe('MCP server operation registry', () => { + it('requires a human subject for tool discovery', () => { + expect(mcpServerOperations.discoverTools).toMatchObject({ + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + }) + + it('uses unique stable operation IDs', () => { + const ids = Object.values(mcpServerOperations).map((operation) => operation.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index 4e89347a41a..396ff1cf5be 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -4,6 +4,10 @@ const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const HUMAN_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const mcpServerOperations = { list: defineWorkspaceOperation({ @@ -15,8 +19,8 @@ export const mcpServerOperations = { discoverTools: defineWorkspaceOperation({ id: 'mcp_servers.tools.discover', minimumRole: 'read', - workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + workspaceApiKey: 'deny', + ...HUMAN_PRINCIPAL_POLICY, }), listWorkflowDeployments: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.list', diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index 4cf037aa241..46e84e343c0 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -45,7 +45,7 @@ vi.mock('@/lib/mcp/queries', () => ({ listWorkspaceMcpServers: vi.fn(), })) -import { createMcpServerUseCase } from '@/lib/mcp/application/use-cases' +import { createMcpServerUseCase, discoverMcpToolsUseCase } from '@/lib/mcp/application/use-cases' type McpServerRow = typeof mcpServers.$inferSelect const workspace = { @@ -148,6 +148,21 @@ describe('MCP server application use cases', () => { expect(mocks.effects).not.toHaveBeenCalled() }) + it('rejects workspace-key tool discovery before protected loading', async () => { + await expect( + discoverMcpToolsUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', + }, + input: { workspaceId: workspace.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadContext).not.toHaveBeenCalled() + }) + it('fails fast when a post-audit domain effect fails', async () => { mocks.effects.mockRejectedValueOnce(new Error('cache unavailable')) diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index 41d1eda13ff..bb87e0a170e 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -1,8 +1,4 @@ -import { - createInternalSessionOrExecutorAuth, - createV2ResourceConcealmentPolicy, - type V2ErrorPolicy, -} from '@/lib/api/server/routes' +import { createInternalSessionOrExecutorAuth, type V2ErrorPolicy } from '@/lib/api/server/routes' import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' import { TableOperationError } from '@/lib/table/application/errors' import { TableLockedError } from '@/lib/table/mutation-locks' @@ -40,16 +36,4 @@ export const v2TableErrorPolicies = { default: { render: renderTableError, } satisfies V2ErrorPolicy, - concealTableAuthorization: createV2ResourceConcealmentPolicy({ - notFoundMessage: 'Table not found', - render: renderTableError, - }), - concealImportAuthorization: createV2ResourceConcealmentPolicy({ - notFoundMessage: 'Table import not found', - render: renderTableError, - }), - concealExportAuthorization: createV2ResourceConcealmentPolicy({ - notFoundMessage: 'Table export not found', - render: renderTableError, - }), } as const diff --git a/apps/sim/lib/table/api/row-route-policies.ts b/apps/sim/lib/table/api/row-route-policies.ts index 08dba0ffdda..183dcc95f35 100644 --- a/apps/sim/lib/table/api/row-route-policies.ts +++ b/apps/sim/lib/table/api/row-route-policies.ts @@ -8,6 +8,6 @@ export const v2TableRowsErrorPolicy = { if (error instanceof TableRowsValidationError) { return v2Error('BAD_REQUEST', error.message, { details: error.details }) } - return v2TableErrorPolicies.concealTableAuthorization.render(error) + return v2TableErrorPolicies.default.render(error) }, } satisfies V2ErrorPolicy diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts index 02174e11a38..f117625769f 100644 --- a/apps/sim/lib/workflows/api/route-policies.test.ts +++ b/apps/sim/lib/workflows/api/route-policies.test.ts @@ -33,18 +33,14 @@ describe('v2 workflow error policies', () => { new WorkspaceApiKeyAuthorizationError(), new DelegatedWorkspaceAuthorizationError(), new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'), - ])('conceals workflow authorization failures as absence', async (error) => { - const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) - expect(response?.status).toBe(404) - expect(await response?.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Workflow not found' }, - }) + ])('returns forbidden for workflow authorization failures', async (error) => { + const response = v2WorkflowErrorPolicies.default.render(error) + expect(response?.status).toBe(403) + expect(await response?.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) - it('preserves the personal-api-key workspace policy failure as forbidden', async () => { - const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( - new PersonalApiKeysDisabledError() - ) + it('returns forbidden for the personal-api-key workspace policy failure', async () => { + const response = v2WorkflowErrorPolicies.default.render(new PersonalApiKeysDisabledError()) expect(response?.status).toBe(403) expect(await response?.json()).toEqual({ error: { @@ -54,22 +50,23 @@ describe('v2 workflow error policies', () => { }) }) - it('uses run-specific concealment text for canonical run operations', async () => { - const response = v2WorkflowErrorPolicies.concealRunAuthorization.render( - new NoWorkspaceAccessError() + it('preserves unrelated forbidden business errors', async () => { + const response = v2WorkflowErrorPolicies.default.render( + new OrchestrationError('forbidden', 'Workflow transition is forbidden') ) + expect(response?.status).toBe(403) expect(await response?.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Run not found' }, + error: { code: 'FORBIDDEN', message: 'Workflow transition is forbidden' }, }) }) - it('does not conceal unrelated forbidden business errors', async () => { - const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( - new OrchestrationError('forbidden', 'Workflow transition is forbidden') + it('preserves genuine not-found failures', async () => { + const response = v2WorkflowErrorPolicies.default.render( + new OrchestrationError('not_found', 'Workflow not found') ) - expect(response?.status).toBe(403) + expect(response?.status).toBe(404) expect(await response?.json()).toEqual({ - error: { code: 'FORBIDDEN', message: 'Workflow transition is forbidden' }, + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, }) }) }) diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index bb5e1ec6930..934898445ef 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -1,7 +1,6 @@ import type { Principal } from '@sim/auth/principal' import { createInternalSessionOrExecutorAuth, - createV2ResourceConcealmentPolicy, type InternalAuthPolicy, type InternalErrorPolicy, InternalUnauthenticatedError, @@ -25,12 +24,6 @@ export const v2WorkflowErrorPolicies = { return v2CaughtOrchestrationError(error) }, } satisfies V2ErrorPolicy, - concealWorkflowAuthorization: createV2ResourceConcealmentPolicy({ - notFoundMessage: 'Workflow not found', - }), - concealRunAuthorization: createV2ResourceConcealmentPolicy({ - notFoundMessage: 'Run not found', - }), } as const export const internalWorkflowSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ diff --git a/apps/sim/lib/workflows/application/cancel-run.ts b/apps/sim/lib/workflows/application/cancel-run.ts index c3f52c3c4c4..848fb3c64cc 100644 --- a/apps/sim/lib/workflows/application/cancel-run.ts +++ b/apps/sim/lib/workflows/application/cancel-run.ts @@ -1,4 +1,3 @@ -import type { Principal } from '@sim/auth/principal' import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { @@ -14,19 +13,12 @@ export interface CancelWorkflowRunInput { runId: string } -function assertedWorkspaceId(principal: Principal): string | undefined { - return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' - ? principal.workspaceId - : undefined -} - export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.cancelRun, - resolveContext: ({ principal, input }: { principal: Principal; input: CancelWorkflowRunInput }) => + resolveContext: ({ input }: { input: CancelWorkflowRunInput }) => resolveActiveWorkflowRunApplicationContext({ runId: input.runId, assertedWorkflowId: input.workflowId, - assertedWorkspaceId: assertedWorkspaceId(principal), }), async execute({ principal, context }) { const attribution = resolvePrincipalAttribution(principal, { diff --git a/apps/sim/lib/workflows/application/execute-workflow.ts b/apps/sim/lib/workflows/application/execute-workflow.ts index aeee3f2de65..77b726ee154 100644 --- a/apps/sim/lib/workflows/application/execute-workflow.ts +++ b/apps/sim/lib/workflows/application/execute-workflow.ts @@ -24,23 +24,14 @@ export interface ExecuteWorkflowInput { includeToolCalls?: boolean } -function assertedWorkspaceId(principal: Principal): string | undefined { - return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' - ? principal.workspaceId - : undefined -} - function authenticatesExecutionCredentials(principal: Principal): boolean { return principal.kind !== 'workspace_api_key' } export const executeWorkflowOperation = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.execute, - resolveContext: ({ principal, input }: { principal: Principal; input: ExecuteWorkflowInput }) => - resolveActiveWorkflowApplicationContext({ - workflowId: input.workflowId, - assertedWorkspaceId: assertedWorkspaceId(principal), - }), + resolveContext: ({ input }: { input: ExecuteWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), async execute({ principal, context, input }): Promise { const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.ts b/apps/sim/lib/workflows/application/list-workflow-runs.ts index 6fd4ba4ab04..ab2b88b6a1c 100644 --- a/apps/sim/lib/workflows/application/list-workflow-runs.ts +++ b/apps/sim/lib/workflows/application/list-workflow-runs.ts @@ -1,4 +1,3 @@ -import type { Principal } from '@sim/auth/principal' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -11,19 +10,10 @@ export interface ListWorkflowRunsInput extends Omit - resolveActiveWorkflowApplicationContext({ - workflowId: input.workflowId, - assertedWorkspaceId: assertedWorkspaceId(principal), - }), + resolveContext: ({ input }: { input: ListWorkflowRunsInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), async execute({ context, input }) { const result = await listWorkflowExecutions({ workflowId: context.workflowId, diff --git a/apps/sim/lib/workflows/application/principal-scope.ts b/apps/sim/lib/workflows/application/principal-scope.ts index 807bd095e39..f98347c0874 100644 --- a/apps/sim/lib/workflows/application/principal-scope.ts +++ b/apps/sim/lib/workflows/application/principal-scope.ts @@ -1,11 +1,12 @@ import type { Principal } from '@sim/auth/principal' +/** Leaves scoped-principal workspace mismatches to canonical authorization so they remain 403s. */ export function assertedWorkflowWorkspaceId( principal: Principal, assertedWorkspaceId?: string ): string | undefined { if (principal.kind === 'workspace_api_key' || principal.kind === 'delegated') { - return principal.workspaceId + return undefined } return assertedWorkspaceId } diff --git a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts index 3510e4ddb4d..aae4484ac3e 100644 --- a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts +++ b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts @@ -107,7 +107,7 @@ describe('Copilot workflow metadata application queries', () => { expect(mocks.resolveContext).toHaveBeenCalledWith({ workflowId: 'workflow-1', - assertedWorkspaceId: 'workspace-1', + assertedWorkspaceId: undefined, }) expect(result).toEqual({ blocks: [ diff --git a/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts index 6861fdf160d..f2062dc020a 100644 --- a/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts +++ b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts @@ -169,7 +169,7 @@ describe('readWorkflowDeploymentOverview', () => { expect(result.mcpToolsTruncated).toBe(true) }) - it('rejects a cross-workspace assertion before protected status loads', async () => { + it('returns forbidden for a cross-workspace delegated principal before protected status loads', async () => { queueTableRows(schemaMock.workflow, [ { workflowId: workflowRecord.id, @@ -183,7 +183,7 @@ describe('readWorkflowDeploymentOverview', () => { principal: { ...principal, workspaceId: 'workspace-2' }, input: { workflowId: workflowRecord.id }, }) - ).rejects.toMatchObject({ code: 'not_found' }) + ).rejects.toMatchObject({ code: 'forbidden' }) expect(mocks.deploymentSummary).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index baa9de4de4c..185d8f0c5b2 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -1,4 +1,3 @@ -import type { Principal } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, @@ -16,19 +15,12 @@ export interface ReadWorkflowRunInput { selectedOutputs: string[] } -function assertedWorkspaceId(principal: Principal): string | undefined { - return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' - ? principal.workspaceId - : undefined -} - export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.readRun, - resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowRunInput }) => + resolveContext: ({ input }: { input: ReadWorkflowRunInput }) => resolveActiveWorkflowRunApplicationContext({ runId: input.runId, assertedWorkflowId: input.workflowId, - assertedWorkspaceId: assertedWorkspaceId(principal), }), async execute({ context, input }) { try { diff --git a/apps/sim/lib/workflows/application/resume-run.ts b/apps/sim/lib/workflows/application/resume-run.ts index 08f9a94cc78..c21f1972da9 100644 --- a/apps/sim/lib/workflows/application/resume-run.ts +++ b/apps/sim/lib/workflows/application/resume-run.ts @@ -1,4 +1,3 @@ -import type { Principal } from '@sim/auth/principal' import { resolvePrincipalAttribution } from '@sim/auth/principal' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' @@ -12,19 +11,12 @@ export interface ResumeWorkflowRunInput { resumeInput: unknown } -function assertedWorkspaceId(principal: Principal): string | undefined { - return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' - ? principal.workspaceId - : undefined -} - export const resumeWorkflowRun = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.resumeRun, - resolveContext: ({ principal, input }: { principal: Principal; input: ResumeWorkflowRunInput }) => + resolveContext: ({ input }: { input: ResumeWorkflowRunInput }) => resolveActiveWorkflowRunApplicationContext({ runId: input.runId, assertedWorkflowId: input.workflowId, - assertedWorkspaceId: assertedWorkspaceId(principal), }), async execute({ principal, input, context }) { const attribution = resolvePrincipalAttribution(principal, { diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts index 18755a4f70f..dcec0fdb8a8 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -126,7 +126,7 @@ describe('Copilot workflow run application commands', () => { expect(result).toMatchObject({ success: true, output: { ok: true } }) expect(mocks.resolveContext).toHaveBeenCalledWith({ workflowId: 'workflow-1', - assertedWorkspaceId: 'workspace-1', + assertedWorkspaceId: undefined, }) expect(mocks.permission).toHaveBeenCalledBefore(mocks.loadDraft) expect(mocks.admission).toHaveBeenCalledWith( diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts index 162b2e149d9..24e730cb69a 100644 --- a/apps/sim/lib/workflows/application/workflow-crud.test.ts +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -276,18 +276,16 @@ describe('authorized workflow CRUD and version reads', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) - it('binds workspace keys to canonical workflow scope before protected reads', async () => { - mocks.resolveWorkflowContext.mockRejectedValue(new Error('canonical mismatch')) - + it('returns forbidden when a workspace key does not match canonical workflow scope', async () => { await expect( readWorkflow.execute({ principal: { ...workspacePrincipal, workspaceId: 'workspace-other' }, input: { workflowId: WORKFLOW_ID }, }) - ).rejects.toThrow('canonical mismatch') + ).rejects.toMatchObject({ code: 'forbidden' }) expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ workflowId: WORKFLOW_ID, - assertedWorkspaceId: 'workspace-other', + assertedWorkspaceId: undefined, }) expect(mocks.loadSnapshot).not.toHaveBeenCalled() }) @@ -327,7 +325,7 @@ describe('authorized workflow CRUD and version reads', () => { expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ workflowId: WORKFLOW_ID, - assertedWorkspaceId: WORKSPACE_ID, + assertedWorkspaceId: undefined, }) expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', WORKSPACE_ID, null, undefined, { forUpdate: undefined, diff --git a/apps/sim/lib/workflows/application/workflow-run-control.test.ts b/apps/sim/lib/workflows/application/workflow-run-control.test.ts index 262f99361b6..7478d7d6f91 100644 --- a/apps/sim/lib/workflows/application/workflow-run-control.test.ts +++ b/apps/sim/lib/workflows/application/workflow-run-control.test.ts @@ -121,10 +121,6 @@ describe('workflow run-control application use cases', () => { expect(mocks.resolveRunContext).toHaveBeenCalledWith({ runId: 'parent-run-1', assertedWorkflowId: 'workflow-1', - assertedWorkspaceId: - principal.kind === 'workspace_api_key' || principal.kind === 'delegated' - ? 'workspace-1' - : undefined, }) expect(mocks.cancel).toHaveBeenCalledWith({ executionId: 'parent-run-1', @@ -153,10 +149,6 @@ describe('workflow run-control application use cases', () => { expect(mocks.resolveRunContext).toHaveBeenCalledWith({ runId: 'parent-run-1', assertedWorkflowId: 'workflow-1', - assertedWorkspaceId: - principal.kind === 'workspace_api_key' || principal.kind === 'delegated' - ? 'workspace-1' - : undefined, }) expect(mocks.resume).toHaveBeenCalledWith({ workflowId: 'workflow-1', diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts index 173bbab87ce..3d93fd066f0 100644 --- a/apps/sim/lib/workflows/application/workflow-runs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -90,10 +90,6 @@ describe('workflow run application use cases', () => { expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ workflowId: 'workflow-1', - assertedWorkspaceId: - principal.kind === 'workspace_api_key' || principal.kind === 'delegated' - ? 'workspace-1' - : undefined, }) expect(mocks.list).toHaveBeenCalledWith( expect.objectContaining({ workflowId: 'workflow-1', limit: 25, order: 'desc' }) @@ -115,7 +111,6 @@ describe('workflow run application use cases', () => { expect(mocks.resolveRunContext).toHaveBeenCalledWith({ runId: 'run-1', assertedWorkflowId: 'workflow-1', - assertedWorkspaceId: 'workspace-1', }) expect(mocks.getStatus).toHaveBeenCalledWith({ workflowId: 'workflow-1', diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index d8d2d4cdb91..c0c3173a920 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -1,7 +1,5 @@ import { createInternalSessionOrExecutorAuth, - createV2ResourceConcealmentPolicy, - type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' @@ -16,7 +14,4 @@ export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, - concealResourceAuthorization: createV2ResourceConcealmentPolicy({ - notFoundMessage: 'File not found', - }) satisfies V2ErrorPolicy, } as const diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index 858b83c2f63..7bc8f72f270 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -63,6 +63,18 @@ describe('file operation registry', () => { expect(fileOperations.updateShare.delegatedServices).toEqual(['copilot', 'executor']) }) + it('keeps resumable workspace-file uploads on credential-bound principals', () => { + for (const operation of [ + fileOperations.uploadCreate, + fileOperations.uploadParts, + fileOperations.uploadComplete, + fileOperations.uploadCancel, + ]) { + expect(operation.principalKinds).toEqual(['session', 'personal_api_key', 'workspace_api_key']) + expect(operation.delegatedServices).toBeUndefined() + } + }) + it('restricts compiled checks to authenticated sessions', () => { expect(fileOperations.compiledCheck).toMatchObject({ id: 'files.compiled_check', diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 4ffa34a5eb3..a4e0c89839c 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -12,6 +12,9 @@ const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'delegated'], delegatedServices: ['copilot', 'executor'], } as const +const UPLOAD_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], +} as const export const fileOperations = { list: defineWorkspaceOperation({ @@ -153,25 +156,25 @@ export const fileOperations = { id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', - ...ALL_COPILOT_PRINCIPAL_POLICY, + ...UPLOAD_PRINCIPAL_POLICY, }), uploadParts: defineWorkspaceOperation({ id: 'files.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', - ...ALL_COPILOT_PRINCIPAL_POLICY, + ...UPLOAD_PRINCIPAL_POLICY, }), uploadComplete: defineWorkspaceOperation({ id: 'files.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', - ...ALL_COPILOT_PRINCIPAL_POLICY, + ...UPLOAD_PRINCIPAL_POLICY, }), uploadCancel: defineWorkspaceOperation({ id: 'files.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', - ...ALL_COPILOT_PRINCIPAL_POLICY, + ...UPLOAD_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/workspaces/public-queries.test.ts b/apps/sim/lib/workspaces/public-queries.test.ts index b33facca025..1e2fa85a38b 100644 --- a/apps/sim/lib/workspaces/public-queries.test.ts +++ b/apps/sim/lib/workspaces/public-queries.test.ts @@ -67,6 +67,7 @@ describe('queryPublicWorkspaceMembers', () => { ]) expect(page?.nextEmail).toBeNull() expect(dbChainMockFns.limit).toHaveBeenCalledWith(11) + expect(dbChainMockFns.leftJoin).toHaveBeenCalledWith(schemaMock.member, expect.anything()) }) it('returns null when the workspace is not active', async () => { diff --git a/apps/sim/lib/workspaces/public-queries.ts b/apps/sim/lib/workspaces/public-queries.ts index c4846107819..988e5e19a5d 100644 --- a/apps/sim/lib/workspaces/public-queries.ts +++ b/apps/sim/lib/workspaces/public-queries.ts @@ -117,6 +117,9 @@ export async function queryPublicWorkspaceMembers( const emailOrder = sql`${user.email} COLLATE "C"` const emailCursor = options.afterEmail ? gt(emailOrder, options.afterEmail) : undefined const sourceLimit = options.limit + 1 + const explicitMemberJoin = workspaceRow.organizationId + ? and(eq(member.userId, user.id), eq(member.organizationId, workspaceRow.organizationId)) + : sql`false` const explicitPromise = db .select({ @@ -130,7 +133,7 @@ export async function queryPublicWorkspaceMembers( }) .from(permissions) .innerJoin(user, eq(permissions.userId, user.id)) - .leftJoin(member, eq(member.userId, user.id)) + .leftJoin(member, explicitMemberJoin) .where( and( eq(permissions.entityType, 'workspace'), From 36649539a498f80c2d75e2670c73fc07fb3dbdfc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 13:51:48 -0700 Subject: [PATCH 2/4] fix(api): refine resource authorization boundaries --- .../api/v2/custom-tools/[id]/route.test.ts | 15 +++ .../sim/app/api/v2/custom-tools/[id]/route.ts | 12 +- .../v2/files/[fileId]/content/route.test.ts | 2 +- .../api/v2/files/[fileId]/content/route.ts | 2 +- .../v2/files/[fileId]/metadata/route.test.ts | 6 +- .../api/v2/files/[fileId]/metadata/route.ts | 2 +- .../app/api/v2/files/[fileId]/route.test.ts | 12 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 6 +- .../app/api/v2/files/[fileId]/share/route.ts | 4 +- apps/sim/app/api/v2/files/uploads/utils.ts | 10 +- .../[id]/documents/[documentId]/route.ts | 4 +- .../api/v2/knowledge/[id]/documents/route.ts | 4 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 6 +- apps/sim/app/api/v2/knowledge/search/route.ts | 2 +- .../sim/app/api/v2/logs/[runId]/route.test.ts | 12 +- apps/sim/app/api/v2/logs/[runId]/route.ts | 2 +- .../app/api/v2/mcp-servers/[id]/route.test.ts | 15 +++ apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 12 +- apps/sim/app/api/v2/skills/[id]/route.test.ts | 15 +++ apps/sim/app/api/v2/skills/[id]/route.ts | 12 +- .../api/v2/tables/[tableId]/columns/route.ts | 6 +- .../api/v2/tables/[tableId]/exports/route.ts | 2 +- .../api/v2/tables/[tableId]/groups/route.ts | 8 +- apps/sim/app/api/v2/tables/[tableId]/route.ts | 6 +- .../tables/[tableId]/views/[viewId]/route.ts | 6 +- .../api/v2/tables/[tableId]/views/route.ts | 4 +- .../exports/[exportId]/download/route.ts | 2 +- .../api/v2/tables/exports/[exportId]/route.ts | 4 +- .../imports/[importId]/complete/route.ts | 2 +- .../tables/imports/[importId]/parts/route.ts | 2 +- .../api/v2/tables/imports/[importId]/route.ts | 4 +- apps/sim/app/api/v2/tables/imports/route.ts | 2 +- .../v2/workflows/[id]/deploy/route.test.ts | 5 +- .../app/api/v2/workflows/[id]/deploy/route.ts | 4 +- .../v2/workflows/[id]/execute/route.test.ts | 28 ++++- .../api/v2/workflows/[id]/execute/route.ts | 12 +- .../v2/workflows/[id]/export/route.test.ts | 5 +- .../app/api/v2/workflows/[id]/export/route.ts | 2 +- .../v2/workflows/[id]/rollback/route.test.ts | 3 +- .../api/v2/workflows/[id]/rollback/route.ts | 2 +- .../app/api/v2/workflows/[id]/route.test.ts | 6 +- apps/sim/app/api/v2/workflows/[id]/route.ts | 6 +- .../[id]/runs/[runId]/cancel/route.ts | 2 +- .../[id]/runs/[runId]/resume/route.test.ts | 5 + .../[id]/runs/[runId]/resume/route.ts | 2 +- .../workflows/[id]/runs/[runId]/route.test.ts | 8 +- .../v2/workflows/[id]/runs/[runId]/route.ts | 2 +- .../api/v2/workflows/[id]/runs/route.test.ts | 8 +- .../app/api/v2/workflows/[id]/runs/route.ts | 2 +- .../[id]/versions/[version]/route.ts | 2 +- .../api/v2/workflows/[id]/versions/route.ts | 2 +- .../api/v2/workflows/folders/route.test.ts | 1 + .../app/api/v2/workflows/import/route.test.ts | 1 + apps/sim/lib/api/server/routes/index.ts | 1 + .../routes/v2-resource-concealment.test.ts | 118 ++++++++++++++++++ .../server/routes/v2-resource-concealment.ts | 36 ++++++ apps/sim/lib/core/application/index.ts | 1 + .../workspace-authorization.test.ts | 22 +++- .../application/workspace-authorization.ts | 11 +- .../lib/knowledge/api/route-policies.test.ts | 27 ++-- apps/sim/lib/knowledge/api/route-policies.ts | 36 ++++-- apps/sim/lib/logs/api/route-policies.ts | 8 +- apps/sim/lib/table/api/route-policies.ts | 18 ++- apps/sim/lib/table/api/row-route-policies.ts | 2 +- .../lib/workflows/api/route-policies.test.ts | 39 ++++-- apps/sim/lib/workflows/api/route-policies.ts | 7 ++ .../lib/workspace-files/api/route-policies.ts | 5 + .../sim/lib/workspaces/public-queries.test.ts | 47 ++++++- apps/sim/lib/workspaces/public-queries.ts | 22 +++- 69 files changed, 565 insertions(+), 146 deletions(-) create mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.ts diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts index 269daa147be..b55abafc968 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -3,6 +3,10 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { class MockV2ApiKeyUnauthenticatedError extends Error {} @@ -160,4 +164,15 @@ describe('/api/v2/custom-tools/[id]', () => { expect(response.status).toBe(401) expect(mocks.update).not.toHaveBeenCalled() }) + + it('conceals cross-tenant access while preserving same-workspace role denials', async () => { + mocks.get.mockRejectedValueOnce(new NoWorkspaceAccessError()) + expect((await GET(request('GET'), context)).status).toBe(404) + + mocks.update.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + expect( + (await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID, code: 'return 2' }), context)) + .status + ).toBe(403) + }) }) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts index 6e04d53abc7..47d4dbaf864 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -4,9 +4,9 @@ import { v2UpdateCustomToolContract, } from '@/lib/api/contracts/v2/custom-tools' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { customToolOperations } from '@/lib/custom-tools/application/operations' @@ -20,13 +20,17 @@ import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +const customToolResourceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Custom tool not found', +}) + /** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ export const GET = defineV2JsonRoute({ contract: v2GetCustomToolContract, operation: customToolOperations.read, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: customToolResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, toolId: params.id }), useCase: getWorkspaceCustomToolUseCase, present: ({ tool }) => ({ data: { customTool: toV2CustomTool(tool) } }), @@ -38,7 +42,7 @@ export const PATCH = defineV2JsonRoute({ operation: customToolOperations.update, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: customToolResourceErrorPolicy, mapInput: ({ params, body }) => ({ ...body, toolId: params.id, @@ -54,7 +58,7 @@ export const DELETE = defineV2JsonRoute({ operation: customToolOperations.delete, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: customToolResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, toolId: params.id, diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index ba587b41e56..acef3a88a5f 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -115,7 +115,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { const response = await callPut('{not-json') - expect(response.status).toBe(403) + expect(response.status).toBe(404) expect(mocks.admit).toHaveBeenCalledWith(auth.principal, FILE_ID) expect(mocks.updateContent).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 59ffd948dfc..c85c5251366 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -19,7 +19,7 @@ export const PUT = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.updateContent, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.default, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 7378a3ca864..3c26fadfaad 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -113,13 +113,13 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect(mocks.readMetadata).not.toHaveBeenCalled() }) - it('returns forbidden for an authorization failure', async () => { + it('conceals cross-workspace authorization as not found', async () => { mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError()) const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - expect(response.status).toBe(403) - expect((await response.json()).error.code).toBe('FORBIDDEN') + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) it('returns the v2 metadata projection through the shared use case', async () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index a5c9111b5d5..68d1420c541 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -14,7 +14,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.readMetadata, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.default, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index fa01357b539..770f1eafddd 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -141,7 +141,7 @@ describe('v2 single-file routes', () => { }) }) - it('returns forbidden for download authorization failures', async () => { + it('conceals cross-workspace download authorization', async () => { mocks.download.mockRejectedValue(new NoWorkspaceAccessError()) const response = await GET( @@ -149,8 +149,8 @@ describe('v2 single-file routes', () => { context ) - expect(response.status).toBe(403) - expect((await response.json()).error.code).toBe('FORBIDDEN') + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) it('renames through the shared use case and v2 presenter', async () => { @@ -170,7 +170,7 @@ describe('v2 single-file routes', () => { }) }) - it('maps rename conflicts and returns forbidden for absent workspace access', async () => { + it('maps rename conflicts and conceals absent workspace access', async () => { mocks.rename.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) const conflict = await PATCH( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { @@ -183,7 +183,7 @@ describe('v2 single-file routes', () => { expect(conflict.status).toBe(409) mocks.rename.mockRejectedValueOnce(new NoWorkspaceAccessError()) - const forbidden = await PATCH( + const concealed = await PATCH( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -191,7 +191,7 @@ describe('v2 single-file routes', () => { }), context ) - expect(forbidden.status).toBe(403) + expect(concealed.status).toBe(404) }) it('returns forbidden when the current workspace role cannot rename the file', async () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 34d3b32347c..10b1ca6c94d 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -33,7 +33,7 @@ export const GET = defineV2BinaryRoute({ auth: v2ApiKeyAuth, operation: fileOperations.download, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.default, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, @@ -59,7 +59,7 @@ export const PATCH = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.rename, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.default, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, @@ -81,7 +81,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.delete, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.default, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index e11c99117f1..5b6e5a6a015 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -15,7 +15,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.readShare, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.default, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, @@ -29,7 +29,7 @@ export const PUT = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: fileOperations.updateShare, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.default, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index 4293d4fb9ad..1a311506316 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -38,8 +38,12 @@ function uploadStatus(status: string): V2UploadStatus { import type { Principal } from '@sim/auth/principal' import type { NextRequest, NextResponse } from 'next/server' +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' import { authenticateV2ApiKey } from '@/lib/api/server/routes/v2-api-key-auth' -import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' + +const uploadControlErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Upload session not found', +}) /** Re-authenticates the API key for each upload control leg. */ export async function authenticateUploadPrincipal(request: NextRequest): Promise { @@ -47,7 +51,7 @@ export async function authenticateUploadPrincipal(request: NextRequest): Promise return auth.principal } -/** Renders upload-control application failures without rewriting authorization status. */ +/** Conceals cross-tenant upload-session authorization while preserving same-workspace denials. */ export function v2UploadControlError(error: unknown): NextResponse | null { - return v2CaughtOrchestrationError(error) + return uploadControlErrorPolicy.render(error) } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 175eeeffd04..7cc8fc571ba 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -33,7 +33,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.readDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.default, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, @@ -71,7 +71,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.deleteDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.default, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 1e75f9a67ed..88d2a5ccfd5 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -71,7 +71,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.listDocuments, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.default, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => { const decodedCursor = query.cursor ? decodeCursor<{ offset: number }>(query.cursor) : null if ( @@ -106,7 +106,7 @@ export const POST = defineV2BodyLifecycleRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.documentUpload, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, admission: { mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index 5b22f32750f..c840e2f8987 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -24,7 +24,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.read, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.default, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, @@ -41,7 +41,7 @@ export const PATCH = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.update, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.default, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, @@ -66,7 +66,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.delete, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.default, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 0d6ac51d69e..ed70c6b9d0d 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -14,7 +14,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.search, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2KnowledgeErrorPolicies.usage, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUsageAuthorization, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index e109099438c..8b0c445a4d5 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/logs/application/get-public-log', () => ({ getPublicLog: { operation: { id: 'logs.read_detail' }, execute: mocks.execute }, })) -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { NoWorkspaceAccessError } from '@/lib/core/application' import { GET } from '@/app/api/v2/logs/[runId]/route' const auth = { @@ -94,18 +94,16 @@ describe('GET /api/v2/logs/[runId]', () => { }) }) - it('returns forbidden for canonical workspace authorization failures', async () => { - mocks.execute.mockRejectedValueOnce( - new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') - ) + it('conceals canonical workspace authorization as log not-found', async () => { + mocks.execute.mockRejectedValueOnce(new NoWorkspaceAccessError()) const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { params: Promise.resolve({ runId: 'run-1' }), }) - expect(response.status).toBe(403) + expect(response.status).toBe(404) expect(await response.json()).toMatchObject({ - error: { code: 'FORBIDDEN', message: 'Workspace API key cannot perform this operation' }, + error: { code: 'NOT_FOUND', message: 'Log not found' }, }) }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index 7d5d59bae91..1902a4b44b0 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -16,7 +16,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: logOperations.readDetail, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2LogErrorPolicies.default, + errorPolicy: v2LogErrorPolicies.concealDetailAuthorization, mapInput: ({ params }) => ({ runId: params.runId }), useCase: getPublicLog, present: ({ log, workflowFolderPath, executionData }) => { diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts index 7aba50ef58a..01b9dffcb7e 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -4,6 +4,10 @@ import type { mcpServers } from '@sim/db/schema' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { class MockV2ApiKeyUnauthenticatedError extends Error {} @@ -178,4 +182,15 @@ describe('/api/v2/mcp-servers/[id]', () => { expect(response.status).toBe(401) expect(mocks.update).not.toHaveBeenCalled() }) + + it('conceals cross-tenant access while preserving same-workspace role denials', async () => { + mocks.get.mockRejectedValueOnce(new NoWorkspaceAccessError()) + expect((await GET(request('GET'), context)).status).toBe(404) + + mocks.update.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + expect( + (await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID, name: 'New docs' }), context)) + .status + ).toBe(403) + }) }) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts index 3fcb02503bf..ed175b26ac9 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -4,9 +4,9 @@ import { v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { mcpServerOperations } from '@/lib/mcp/application/operations' @@ -21,13 +21,17 @@ import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +const mcpServerResourceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'MCP server not found', +}) + /** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ export const GET = defineV2JsonRoute({ contract: v2GetMcpServerContract, operation: mcpServerOperations.read, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: mcpServerResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, serverId: params.id }), useCase: getMcpServerUseCase, present: ({ server }) => ({ data: { mcpServer: toV2McpServer(server) } }), @@ -39,7 +43,7 @@ export const PATCH = defineV2JsonRoute({ operation: mcpServerOperations.update, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: mcpServerResourceErrorPolicy, mapInput: ({ params, body }) => ({ ...body, serverId: params.id, source: 'api' as const }), useCase: updateMcpServerUseCase, present: ({ server }) => ({ data: { mcpServer: toV2McpServer(server) } }), @@ -51,7 +55,7 @@ export const DELETE = defineV2JsonRoute({ operation: mcpServerOperations.delete, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: mcpServerResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, serverId: params.id, diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts index 9444c0d799f..ff48f0de897 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.test.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -3,6 +3,10 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { class MockV2ApiKeyUnauthenticatedError extends Error {} @@ -165,4 +169,15 @@ describe('/api/v2/skills/[id]', () => { expect(response.status).toBe(401) expect(mocks.update).not.toHaveBeenCalled() }) + + it('conceals cross-tenant access while preserving same-workspace role denials', async () => { + mocks.get.mockRejectedValueOnce(new NoWorkspaceAccessError()) + expect((await GET(request('GET'), context)).status).toBe(404) + + mocks.update.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + expect( + (await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID, content: '# Updated' }), context)) + .status + ).toBe(403) + }) }) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts index eb896725050..29fad982ea8 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -4,9 +4,9 @@ import { v2UpdateSkillContract, } from '@/lib/api/contracts/v2/skills' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' @@ -21,13 +21,17 @@ import { toV2Skill } from '@/app/api/v2/skills/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +const skillResourceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Skill not found', +}) + /** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ export const GET = defineV2JsonRoute({ contract: v2GetSkillContract, operation: skillOperations.read, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, skillId: params.id }), useCase: getSkillUseCase, present: ({ skill }) => ({ data: { skill: toV2Skill(skill) } }), @@ -39,7 +43,7 @@ export const PATCH = defineV2JsonRoute({ operation: skillOperations.update, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, body }) => ({ ...body, skillId: params.id, @@ -69,7 +73,7 @@ export const DELETE = defineV2JsonRoute({ operation: skillOperations.delete, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: skillResourceErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, skillId: params.id, diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 38127237a15..03030a64729 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -27,7 +27,7 @@ export const POST = defineV2JsonRoute({ useCase: addTableColumnUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: presentColumns, }) @@ -38,7 +38,7 @@ export const PATCH = defineV2JsonRoute({ useCase: updateTableColumnUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: presentColumns, }) @@ -49,7 +49,7 @@ export const DELETE = defineV2JsonRoute({ useCase: deleteTableColumnUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: presentColumns, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index cf3f0c48933..9de6249cbe0 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.createExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, workspaceId: body.workspaceId, diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts index 0a2663b09bb..8910958f55a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -24,7 +24,7 @@ export const GET = defineV2JsonRoute({ useCase: listTableGroupsUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), present: ({ groups }) => ({ data: groups, nextCursor: null }), }) @@ -35,7 +35,7 @@ export const POST = defineV2JsonRoute({ useCase: createTableGroupUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ data: { group, columns: table.schema.columns.map(normalizeColumn) }, @@ -48,7 +48,7 @@ export const PATCH = defineV2JsonRoute({ useCase: updateTableGroupUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ data: { group, columns: table.schema.columns.map(normalizeColumn) }, @@ -61,7 +61,7 @@ export const DELETE = defineV2JsonRoute({ useCase: deleteTableGroupUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, groupId }) => ({ data: { diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 80fdc0c31be..a81f4039c3a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -48,7 +48,7 @@ export const GET = defineV2JsonRoute({ useCase: readTableUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), present: async ({ table, folderPath }) => ({ data: { table: await toApiTable(table, folderPath) }, @@ -61,7 +61,7 @@ export const PATCH = defineV2JsonRoute({ useCase: updateTableUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: async (result) => { rethrowUpdateFailure(result) @@ -78,7 +78,7 @@ export const DELETE = defineV2JsonRoute({ useCase: deleteTableUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), onSuccess: ({ result }) => { captureServerEvent( diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts index 2561cc7c666..541a4932f8a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -32,7 +32,7 @@ export const GET = defineV2JsonRoute({ useCase: readTableViewUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }), present: presentView, }) @@ -43,7 +43,7 @@ export const PATCH = defineV2JsonRoute({ useCase: updateTableViewUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ ...params, ...body }), present: presentView, }) @@ -54,7 +54,7 @@ export const DELETE = defineV2JsonRoute({ useCase: deleteTableViewUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }), present: ({ viewId }) => ({ data: { id: viewId } }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts index 303399cc61c..a0b80ba1664 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -19,7 +19,7 @@ export const GET = defineV2JsonRoute({ useCase: listTableViewsUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), present: async ({ views }) => { const emailByUserId = await getUserEmailsByIds( @@ -43,7 +43,7 @@ export const POST = defineV2JsonRoute({ useCase: createTableViewUseCase, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: async ({ view }) => ({ data: { diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts index 2078ce20a58..88c00c6a9e1 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -12,7 +12,7 @@ export const GET = defineV2JsonRoute({ operation: tableOperations.downloadExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 5ccbb4ae231..d962454a3ad 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -16,7 +16,7 @@ export const GET = defineV2JsonRoute({ operation: tableOperations.readExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, @@ -30,7 +30,7 @@ export const DELETE = defineV2JsonRoute({ operation: tableOperations.cancelExport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 9d27cec9afe..368970ec9b9 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.completeImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 9c9778a23d9..1f4ddb01d01 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -12,7 +12,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.createImportParts, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query, headers, body }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index 41f7511a968..7092782c602 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -16,7 +16,7 @@ export const GET = defineV2JsonRoute({ operation: tableOperations.readImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query }) => ({ importId: params.importId, workspaceId: query.workspaceId, @@ -30,7 +30,7 @@ export const DELETE = defineV2JsonRoute({ operation: tableOperations.cancelImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 31a07ddccfb..ea630f4f9c7 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ operation: tableOperations.createImport, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2TableErrorPolicies.default, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ body }) => ({ body }), useCase: createTableImportUseCase, present: ({ import: tableImport }) => presentV2CreateTableImport(tableImport), diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts index a7f99f19936..e298f0d09aa 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -32,7 +33,7 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => { expect(POST).toMatchObject({ operation: workflowOperations.deploy, useCase: deployWorkflow, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, parseOptions: { optionalJsonBody: true }, }) expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( @@ -88,7 +89,7 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => { expect(DELETE).toMatchObject({ operation: workflowOperations.undeploy, useCase: undeployWorkflow, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, }) const body = Reflect.get( DELETE, diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 40daae86be1..d4a3051c075 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -19,7 +19,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.deploy, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, parseOptions: { optionalJsonBody: true, invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), @@ -50,7 +50,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.undeploy, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), useCase: undeployWorkflow, present: (result) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index 781f203c61e..ffeeefec4e6 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -408,7 +408,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(mockPreprocessExecution).not.toHaveBeenCalled() }) - it('returns forbidden for a workspace-key/workflow mismatch', async () => { + it('conceals a workspace-key/workflow mismatch as not found', async () => { mockAuthenticateV2ApiKey.mockResolvedValue({ principal: { kind: 'workspace_api_key', @@ -423,8 +423,8 @@ describe('POST /api/v2/workflows/[id]/execute', () => { const res = await callExecute({ input: {} }) - expect(res.status).toBe(403) - expect((await res.json()).error.code).toBe('FORBIDDEN') + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') }) it('rejects personal keys when the workspace disallows them', async () => { @@ -539,6 +539,28 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(asyncRes.status).toBe(400) }) + it('returns not found when a public workflow disappears before authorization', async () => { + dbChainMockFns.limit.mockReset() + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + mockAuthorize.mockResolvedValueOnce({ + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + }) + + const response = await callPublicExecute({ input: {} }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + it('rejects anonymous abuse before looking up the workflow', async () => { mockCheckPreAuthRate.mockResolvedValueOnce({ allowed: false, diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 0a460ebd4b4..877f8421e7a 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -245,7 +245,15 @@ export const POST = withRouteHandler( action: 'read', }) if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { - return v2Error('FORBIDDEN', 'Insufficient workspace permissions') + if (workflowAuthorization.status === 404) { + return v2Error('NOT_FOUND', 'Workflow not found') + } + if (workflowAuthorization.status === 403) { + return v2Error('FORBIDDEN', 'Insufficient workspace permissions') + } + throw new Error( + `Unexpected workflow authorization status: ${workflowAuthorization.status}` + ) } result = await executeWorkflowService({ workflowId, @@ -305,7 +313,7 @@ export const POST = withRouteHandler( { headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } } ) } catch (error) { - const classified = v2WorkflowErrorPolicies.default.render(error) + const classified = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) if (classified) return classified logger.error(`[${requestId}] v2 execute failed`, { workflowId, diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts index c87817af3de..205517eda7e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -19,11 +20,11 @@ import { workflowOperations } from '@/lib/workflows/application/operations' import { GET } from '@/app/api/v2/workflows/[id]/export/route' describe('/api/v2/workflows/[id]/export route definition', () => { - it('uses canonical workflow authorization', () => { + it('uses canonical workflow authorization with tenant-boundary concealment', () => { expect(GET).toMatchObject({ operation: workflowOperations.export, useCase: exportWorkflow, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, }) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index 7bb690a9173..d4011a4f64c 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -12,7 +12,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.export, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: exportWorkflow, present: ({ payload, folderPath }) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts index 6a721a611bd..690d53fa3b9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -27,7 +28,7 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => { expect(POST).toMatchObject({ operation: workflowOperations.activateVersion, useCase: activateWorkflowVersion, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, parseOptions: { optionalJsonBody: true }, }) expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 9b7cda7ec1d..ae0e1a3519f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -15,7 +15,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.activateVersion, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, parseOptions: { optionalJsonBody: true, invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 4e5cfbe5758..d16cabfc429 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -121,16 +121,16 @@ describe('/api/v2/workflows/[id]', () => { }) }) - it('returns forbidden for absent workspace access', async () => { + it('conceals absent workspace access as workflow absence', async () => { mocks.readWorkflow.mockRejectedValue(new NoWorkspaceAccessError()) const response = await GET( new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), routeContext ) - expect(response.status).toBe(403) + expect(response.status).toBe(404) expect(await response.json()).toMatchObject({ - error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 175f1e80816..43c40be983f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -17,7 +17,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.read, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: readWorkflow, present: ({ workflow, workspaceId, folderPath, inputs }) => ({ @@ -44,7 +44,7 @@ export const PATCH = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.update, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, body }) => ({ workflowId: params.id, ...body }), useCase: updateWorkflow, present: ({ workflow, workspaceId, folderPath, deployment }) => ({ @@ -69,7 +69,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.delete, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: deleteWorkflow, present: ({ workflowId }) => ({ data: { id: workflowId, deleted: true as const } }), diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts index d29eee2a416..5418355124d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts @@ -13,7 +13,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.cancelRun, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, mapInput: ({ params }) => ({ workflowId: params.id, runId: params.runId }), useCase: cancelWorkflowRun, present: (result) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts index 9e0db743b2d..3d1165aad5f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts @@ -35,6 +35,11 @@ vi.mock('@/lib/api/server/routes', () => { return { admitV2Request: mocks.admit, createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), + createV2ResourceConcealmentPolicy: vi.fn( + ({ render }: { render?: (error: unknown) => Response | null }) => ({ + render: render ?? renderOrchestrationError, + }) + ), V2RouteInfrastructureError, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index a5ed1764f83..cc0938d8232 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -104,7 +104,7 @@ export const POST = withRouteHandler( { headers } ) } catch (error) { - const domainResponse = v2WorkflowErrorPolicies.default.render(error) + const domainResponse = v2WorkflowErrorPolicies.concealRunAuthorization.render(error) if (domainResponse) return domainResponse if (error instanceof ResumeWorkflowExecutionError) { if (!error.safeForPublicApi) throw error diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts index 3bb30a1fc54..774f60af9ae 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts @@ -196,15 +196,15 @@ describe('v2 run detail and cancel adapters', () => { expect(body.data.paused).not.toHaveProperty('pausedExecutionId') }) - it('returns forbidden for canonical run authorization failures', async () => { + it('conceals canonical run authorization failures as absence', async () => { mocks.readRun.mockRejectedValueOnce(new NoWorkspaceAccessError()) const response = await callStatus() - expect(response.status).toBe(403) + expect(response.status).toBe(404) expect((await response.json()).error).toMatchObject({ - code: 'FORBIDDEN', - message: 'Insufficient workspace permissions', + code: 'NOT_FOUND', + message: 'Run not found', }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts index beb9c1567c9..f511c4391a8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts @@ -21,7 +21,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.readRun, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, mapInput: ({ params, query }) => ({ workflowId: params.id, runId: params.runId, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts index 0019a33aab3..74f355d59f8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts @@ -172,15 +172,15 @@ describe('GET /api/v2/workflows/[id]/runs', () => { expect(mocks.listRuns).not.toHaveBeenCalled() }) - it('returns forbidden for workflow authorization failures', async () => { + it('conceals workflow authorization failures as absence', async () => { mocks.listRuns.mockRejectedValueOnce(new NoWorkspaceAccessError()) const response = await callGet() - expect(response.status).toBe(403) + expect(response.status).toBe(404) expect((await response.json()).error).toMatchObject({ - code: 'FORBIDDEN', - message: 'Insufficient workspace permissions', + code: 'NOT_FOUND', + message: 'Workflow not found', }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index 6afb6628ed5..6893f79f822 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -20,7 +20,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.listRuns, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, query }) => { const { status, trigger, startDate, endDate, limit, cursor, order } = query const sort = cursorSortKey('startedAt', order) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index c1a94477cc8..351e2de49fa 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -13,7 +13,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.readVersion, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), useCase: readWorkflowVersion, present: ({ version }) => ({ diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index d976dfac5f3..1fbb169fe33 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -19,7 +19,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workflowOperations.listVersions, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2WorkflowErrorPolicies.default, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, query }) => { const after = query.cursor ? decodeCursor(query.cursor) : null if (query.cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts index 1a1a9dc75c6..eee8e019850 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.test.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, diff --git a/apps/sim/app/api/v2/workflows/import/route.test.ts b/apps/sim/app/api/v2/workflows/import/route.test.ts index 59fcb056cf2..bfd43dad013 100644 --- a/apps/sim/app/api/v2/workflows/import/route.test.ts +++ b/apps/sim/app/api/v2/workflows/import/route.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index fb1002d524a..03951db9c67 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -25,3 +25,4 @@ export { v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' +export { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/v2-resource-concealment' diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts new file mode 100644 index 00000000000..c7ca4a35a4a --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api/route-policies' + +const policies: Array<{ + domain: string + policy: V2ErrorPolicy + notFoundMessage: string +}> = [ + { + domain: 'file', + policy: v2FileErrorPolicies.concealResourceAuthorization, + notFoundMessage: 'File not found', + }, + { + domain: 'workflow', + policy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + notFoundMessage: 'Workflow not found', + }, + { + domain: 'workflow run', + policy: v2WorkflowErrorPolicies.concealRunAuthorization, + notFoundMessage: 'Run not found', + }, + { + domain: 'table', + policy: v2TableErrorPolicies.concealTableAuthorization, + notFoundMessage: 'Table not found', + }, + { + domain: 'table import', + policy: v2TableErrorPolicies.concealImportAuthorization, + notFoundMessage: 'Table import not found', + }, + { + domain: 'table export', + policy: v2TableErrorPolicies.concealExportAuthorization, + notFoundMessage: 'Table export not found', + }, + { + domain: 'knowledge base', + policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + notFoundMessage: 'Knowledge base not found', + }, +] + +const crossTenantAuthorizationErrors = [ + new NoWorkspaceAccessError(), + new WorkspaceApiKeyScopeAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), +] + +describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => { + it.each(crossTenantAuthorizationErrors)( + 'conceals cross-tenant authorization: %s', + async (error) => { + const response = policy.render(error) + expect(response?.status).toBe(404) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'NOT_FOUND', message: notFoundMessage }, + }) + } + ) + + it('preserves workspace personal-key policy denial as forbidden', async () => { + const response = policy.render(new PersonalApiKeysDisabledError()) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + }, + }) + }) + + it('preserves insufficient workspace role as forbidden', async () => { + const response = policy.render(new InsufficientWorkspacePermissionsError()) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, + }) + }) + + it.each([ + new WorkspaceApiKeyAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), + ])('preserves same-workspace principal policy denial as forbidden: %s', async (error) => { + const response = policy.render(error) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ error: { code: 'FORBIDDEN' } }) + }) + + it('does not classify generic forbidden errors by message', async () => { + const response = policy.render( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, + }) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts new file mode 100644 index 00000000000..f3a783cca3e --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts @@ -0,0 +1,36 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { + DelegatedWorkspaceAuthorizationError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +type V2ErrorRenderer = V2ErrorPolicy['render'] + +function isCrossTenantAuthorizationError(error: unknown): boolean { + return ( + error instanceof DelegatedWorkspaceAuthorizationError || + error instanceof NoWorkspaceAccessError || + error instanceof WorkspaceApiKeyScopeAuthorizationError + ) +} + +/** + * Conceals cross-tenant authorization failures while preserving same-workspace + * policy and role denials as 403 responses. + */ +export function createV2ResourceConcealmentPolicy(options: { + notFoundMessage: string + render?: V2ErrorRenderer +}): V2ErrorPolicy { + const render = options.render ?? v2CaughtOrchestrationError + return { + render(error) { + if (isCrossTenantAuthorizationError(error)) { + return v2Error('NOT_FOUND', options.notFoundMessage) + } + return render(error) + }, + } +} diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 113aaaf7010..7693dbdb111 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -24,6 +24,7 @@ export { PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, WorkspaceApiKeyAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' export { defineWorkspaceOperation, diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index d9834e93dab..3f37d3bcf8e 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import type { SessionPrincipal } from '@sim/auth/principal' +import type { SessionPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -21,6 +21,7 @@ import { defineWorkspaceOperation, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' const writeOperation = defineWorkspaceOperation({ @@ -36,6 +37,19 @@ const principal: SessionPrincipal = { sessionId: 'session-1', } +const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'test.workspace-key-write', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], +}) + +const workspaceKeyPrincipal: WorkspaceApiKeyPrincipal = { + kind: 'workspace_api_key', + workspaceId: 'workspace-other', + keyId: 'key-1', +} + const context = { workspaceId: 'workspace-1', workspaceOrganizationId: 'organization-1', @@ -70,4 +84,10 @@ describe('authorizeWorkspaceOperation', () => { authorizeWorkspaceOperation(principal, writeOperation, context) ).resolves.toBeUndefined() }) + + it('classifies a workspace-key tenant mismatch separately from role denials', async () => { + await expect( + authorizeWorkspaceOperation(workspaceKeyPrincipal, workspaceKeyOperation, context) + ).rejects.toBeInstanceOf(WorkspaceApiKeyScopeAuthorizationError) + }) }) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index fc153feec1b..d90c62c1682 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -56,6 +56,13 @@ export class WorkspaceApiKeyAuthorizationError extends OrchestrationError { } } +export class WorkspaceApiKeyScopeAuthorizationError extends OrchestrationError { + constructor() { + super('forbidden', 'Workspace API key cannot access this workspace') + this.name = 'WorkspaceApiKeyScopeAuthorizationError' + } +} + export class DelegatedWorkspaceAuthorizationError extends OrchestrationError { constructor() { super('forbidden', 'Delegated workspace access is no longer valid') @@ -139,8 +146,10 @@ export async function authorizeWorkspaceOperation { it.each([ new NoWorkspaceAccessError(), - new WorkspaceApiKeyAuthorizationError(), + new WorkspaceApiKeyScopeAuthorizationError(), new DelegatedWorkspaceAuthorizationError(), + ])('conceals cross-tenant knowledge authorization failures', async (error) => { + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(error) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Knowledge base not found' }, + }) + }) + + it.each([ + new WorkspaceApiKeyAuthorizationError(), new PrincipalKindAuthorizationError('workspace_api_key', 'knowledge.read'), - ])('returns forbidden for canonical resource authorization failures', async (error) => { - const response = v2KnowledgeErrorPolicies.default.render(error) + ])('preserves same-workspace principal policy failures as forbidden', async (error) => { + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(error) expect(response?.status).toBe(403) expect(await response?.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) it('preserves the personal-api-key policy failure as forbidden', async () => { - const response = v2KnowledgeErrorPolicies.default.render(new PersonalApiKeysDisabledError()) + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render( + new PersonalApiKeysDisabledError() + ) expect(response?.status).toBe(403) expect(await response?.json()).toEqual({ error: { @@ -36,8 +49,8 @@ describe('v2 knowledge error policies', () => { }) }) - it('preserves unrelated forbidden business errors', async () => { - const response = v2KnowledgeErrorPolicies.default.render( + it('does not conceal unrelated forbidden business errors', async () => { + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render( new OrchestrationError('forbidden', 'Knowledge base transition is forbidden') ) expect(response?.status).toBe(403) @@ -47,7 +60,7 @@ describe('v2 knowledge error policies', () => { }) it('preserves genuine not-found failures', async () => { - const response = v2KnowledgeErrorPolicies.default.render( + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render( new OrchestrationError('not_found', 'Knowledge base not found') ) expect(response?.status).toBe(404) diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index b5262da2449..6b20b06a984 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -1,5 +1,6 @@ import { createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, type InternalErrorPolicy, internalErrorResponse, internalPlainOrchestrationErrorPolicy, @@ -77,18 +78,31 @@ const v2KnowledgeUsageErrorPolicy = { }, } satisfies V2ErrorPolicy +const v2KnowledgeDocumentUploadErrorPolicy = { + render(error) { + if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message) + } + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2KnowledgeUsageErrorPolicy.render(error) + }, +} satisfies V2ErrorPolicy + export const v2KnowledgeErrorPolicies = { default: v2OrchestrationErrorPolicy, usage: v2KnowledgeUsageErrorPolicy, - documentUpload: { - render(error) { - if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { - return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message) - } - if (isPayloadSizeLimitError(error)) { - return v2Error('PAYLOAD_TOO_LARGE', error.message) - } - return v2KnowledgeUsageErrorPolicy.render(error) - }, - } satisfies V2ErrorPolicy, + documentUpload: v2KnowledgeDocumentUploadErrorPolicy, + concealKnowledgeBaseAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Knowledge base not found', + }), + concealKnowledgeBaseUsageAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Knowledge base not found', + render: v2KnowledgeUsageErrorPolicy.render, + }), + concealKnowledgeBaseUploadAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Knowledge base not found', + render: v2KnowledgeDocumentUploadErrorPolicy.render, + }), } as const diff --git a/apps/sim/lib/logs/api/route-policies.ts b/apps/sim/lib/logs/api/route-policies.ts index cb1196a7593..aa1e8da8cf1 100644 --- a/apps/sim/lib/logs/api/route-policies.ts +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -1,5 +1,11 @@ -import { v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' +import { + createV2ResourceConcealmentPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' export const v2LogErrorPolicies = { default: v2OrchestrationErrorPolicy, + concealDetailAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Log not found', + }), } as const diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index bb87e0a170e..41d1eda13ff 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -1,4 +1,8 @@ -import { createInternalSessionOrExecutorAuth, type V2ErrorPolicy } from '@/lib/api/server/routes' +import { + createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, + type V2ErrorPolicy, +} from '@/lib/api/server/routes' import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' import { TableOperationError } from '@/lib/table/application/errors' import { TableLockedError } from '@/lib/table/mutation-locks' @@ -36,4 +40,16 @@ export const v2TableErrorPolicies = { default: { render: renderTableError, } satisfies V2ErrorPolicy, + concealTableAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table not found', + render: renderTableError, + }), + concealImportAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table import not found', + render: renderTableError, + }), + concealExportAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table export not found', + render: renderTableError, + }), } as const diff --git a/apps/sim/lib/table/api/row-route-policies.ts b/apps/sim/lib/table/api/row-route-policies.ts index 183dcc95f35..08dba0ffdda 100644 --- a/apps/sim/lib/table/api/row-route-policies.ts +++ b/apps/sim/lib/table/api/row-route-policies.ts @@ -8,6 +8,6 @@ export const v2TableRowsErrorPolicy = { if (error instanceof TableRowsValidationError) { return v2Error('BAD_REQUEST', error.message, { details: error.details }) } - return v2TableErrorPolicies.default.render(error) + return v2TableErrorPolicies.concealTableAuthorization.render(error) }, } satisfies V2ErrorPolicy diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts index f117625769f..e313c01ada5 100644 --- a/apps/sim/lib/workflows/api/route-policies.test.ts +++ b/apps/sim/lib/workflows/api/route-policies.test.ts @@ -9,6 +9,7 @@ import { PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, WorkspaceApiKeyAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -30,17 +31,29 @@ import { describe('v2 workflow error policies', () => { it.each([ new NoWorkspaceAccessError(), - new WorkspaceApiKeyAuthorizationError(), + new WorkspaceApiKeyScopeAuthorizationError(), new DelegatedWorkspaceAuthorizationError(), + ])('conceals cross-tenant workflow authorization failures', async (error) => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) + }) + + it.each([ + new WorkspaceApiKeyAuthorizationError(), new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'), - ])('returns forbidden for workflow authorization failures', async (error) => { - const response = v2WorkflowErrorPolicies.default.render(error) + ])('preserves same-workspace principal policy failures as forbidden', async (error) => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) expect(response?.status).toBe(403) expect(await response?.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) - it('returns forbidden for the personal-api-key workspace policy failure', async () => { - const response = v2WorkflowErrorPolicies.default.render(new PersonalApiKeysDisabledError()) + it('preserves the personal-api-key workspace policy failure as forbidden', async () => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( + new PersonalApiKeysDisabledError() + ) expect(response?.status).toBe(403) expect(await response?.json()).toEqual({ error: { @@ -50,8 +63,18 @@ describe('v2 workflow error policies', () => { }) }) - it('preserves unrelated forbidden business errors', async () => { - const response = v2WorkflowErrorPolicies.default.render( + it('uses run-specific concealment text for canonical run operations', async () => { + const response = v2WorkflowErrorPolicies.concealRunAuthorization.render( + new NoWorkspaceAccessError() + ) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Run not found' }, + }) + }) + + it('does not conceal unrelated forbidden business errors', async () => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( new OrchestrationError('forbidden', 'Workflow transition is forbidden') ) expect(response?.status).toBe(403) @@ -61,7 +84,7 @@ describe('v2 workflow error policies', () => { }) it('preserves genuine not-found failures', async () => { - const response = v2WorkflowErrorPolicies.default.render( + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( new OrchestrationError('not_found', 'Workflow not found') ) expect(response?.status).toBe(404) diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index 934898445ef..bb5e1ec6930 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -1,6 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, type InternalAuthPolicy, type InternalErrorPolicy, InternalUnauthenticatedError, @@ -24,6 +25,12 @@ export const v2WorkflowErrorPolicies = { return v2CaughtOrchestrationError(error) }, } satisfies V2ErrorPolicy, + concealWorkflowAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workflow not found', + }), + concealRunAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Run not found', + }), } as const export const internalWorkflowSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index c0c3173a920..d8d2d4cdb91 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -1,5 +1,7 @@ import { createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, + type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' @@ -14,4 +16,7 @@ export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, + concealResourceAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'File not found', + }) satisfies V2ErrorPolicy, } as const diff --git a/apps/sim/lib/workspaces/public-queries.test.ts b/apps/sim/lib/workspaces/public-queries.test.ts index 1e2fa85a38b..2b5fe5b5d3f 100644 --- a/apps/sim/lib/workspaces/public-queries.test.ts +++ b/apps/sim/lib/workspaces/public-queries.test.ts @@ -21,7 +21,7 @@ describe('queryPublicWorkspaceMembers', () => { image: null, role: 'write', joinedAt: new Date('2026-01-01T00:00:00.000Z'), - userOrganizationId: 'org-1', + hasRelevantOrganizationMembership: true, }, { userId: 'user-2', @@ -30,7 +30,7 @@ describe('queryPublicWorkspaceMembers', () => { image: null, role: 'read', joinedAt: new Date('2026-01-02T00:00:00.000Z'), - userOrganizationId: null, + hasRelevantOrganizationMembership: false, }, ]) queueTableRows(schemaMock.member, [ @@ -67,7 +67,48 @@ describe('queryPublicWorkspaceMembers', () => { ]) expect(page?.nextEmail).toBeNull() expect(dbChainMockFns.limit).toHaveBeenCalledWith(11) - expect(dbChainMockFns.leftJoin).toHaveBeenCalledWith(schemaMock.member, expect.anything()) + expect(dbChainMockFns.leftJoin).not.toHaveBeenCalled() + }) + + it('marks organization members as external collaborators on a personal workspace', async () => { + queueTableRows(schemaMock.workspace, [{ ownerId: 'user-1', organizationId: null }]) + queueTableRows(schemaMock.permissions, [ + { + userId: 'user-1', + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + hasRelevantOrganizationMembership: true, + }, + { + userId: 'user-2', + email: 'grace@example.com', + name: 'Grace', + image: null, + role: 'read', + joinedAt: new Date('2026-01-02T00:00:00.000Z'), + hasRelevantOrganizationMembership: true, + }, + { + userId: 'user-3', + email: 'katherine@example.com', + name: 'Katherine', + image: null, + role: 'read', + joinedAt: new Date('2026-01-03T00:00:00.000Z'), + hasRelevantOrganizationMembership: false, + }, + ]) + + const page = await queryPublicWorkspaceMembers('workspace-1', { limit: 10 }) + + expect(page?.members.map(({ email, isExternal }) => ({ email, isExternal }))).toEqual([ + { email: 'ada@example.com', isExternal: false }, + { email: 'grace@example.com', isExternal: true }, + { email: 'katherine@example.com', isExternal: false }, + ]) }) it('returns null when the workspace is not active', async () => { diff --git a/apps/sim/lib/workspaces/public-queries.ts b/apps/sim/lib/workspaces/public-queries.ts index 988e5e19a5d..32304adc3a5 100644 --- a/apps/sim/lib/workspaces/public-queries.ts +++ b/apps/sim/lib/workspaces/public-queries.ts @@ -117,9 +117,18 @@ export async function queryPublicWorkspaceMembers( const emailOrder = sql`${user.email} COLLATE "C"` const emailCursor = options.afterEmail ? gt(emailOrder, options.afterEmail) : undefined const sourceLimit = options.limit + 1 - const explicitMemberJoin = workspaceRow.organizationId - ? and(eq(member.userId, user.id), eq(member.organizationId, workspaceRow.organizationId)) - : sql`false` + const hasRelevantOrganizationMembership = workspaceRow.organizationId + ? sql`EXISTS ( + SELECT 1 + FROM ${member} + WHERE ${member.userId} = ${user.id} + AND ${member.organizationId} = ${workspaceRow.organizationId} + )` + : sql`EXISTS ( + SELECT 1 + FROM ${member} + WHERE ${member.userId} = ${user.id} + )` const explicitPromise = db .select({ @@ -129,11 +138,10 @@ export async function queryPublicWorkspaceMembers( image: user.image, role: permissions.permissionType, joinedAt: permissions.createdAt, - userOrganizationId: member.organizationId, + hasRelevantOrganizationMembership, }) .from(permissions) .innerJoin(user, eq(permissions.userId, user.id)) - .leftJoin(member, explicitMemberJoin) .where( and( eq(permissions.entityType, 'workspace'), @@ -178,7 +186,9 @@ export async function queryPublicWorkspaceMembers( role: row.role, isExternal: row.userId !== workspaceRow.ownerId && - row.userOrganizationId !== workspaceRow.organizationId, + (workspaceRow.organizationId + ? !row.hasRelevantOrganizationMembership + : row.hasRelevantOrganizationMembership), joinedAt: row.joinedAt, }) } From 89a27cad51f74cc8cf51b4fb4151eff031be4a73 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 14:01:58 -0700 Subject: [PATCH 3/4] fix(tables): restore large durable imports --- apps/docs/openapi-v2-tables.json | 2 +- apps/sim/app/api/table/[tableId]/import/route.ts | 4 ++-- apps/sim/app/api/table/import-csv/route.ts | 4 ++-- apps/sim/hooks/queries/tables.ts | 6 +++--- apps/sim/lib/api/contracts/tables.ts | 6 +++--- .../lib/api/contracts/v2/__tests__/tables.test.ts | 8 +++++--- apps/sim/lib/api/contracts/v2/tables.ts | 7 +++++-- apps/sim/lib/table/import.ts | 12 +++++++++--- .../table/orchestration/import-resource.test.ts | 15 +++++++++------ .../lib/table/orchestration/import-resource.ts | 13 ++++++++----- 10 files changed, 47 insertions(+), 30 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 8af5c3373f1..965fd7b8274 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -7017,7 +7017,7 @@ "size": { "type": "integer", "minimum": 1, - "maximum": 26214400, + "maximum": 5368709120, "description": "Exact CSV file size in bytes." } }, diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 465777f46a3..b7b1d106df7 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -16,7 +16,7 @@ import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table' +import { CSV_SYNC_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table' import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { @@ -53,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro let parsed: Awaited> try { parsed = await readMultipart(request, { - maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + maxFileBytes: CSV_SYNC_MAX_FILE_SIZE_BYTES, requiredFieldsBeforeFile: ['workspaceId'], signal: request.signal, }) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index ef4f1cc7547..06981c6e43b 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -10,7 +10,7 @@ import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' +import { CSV_SYNC_MAX_FILE_SIZE_BYTES } from '@/lib/table' import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -39,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let parsed: Awaited> try { parsed = await readMultipart(request, { - maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, + maxFileBytes: CSV_SYNC_MAX_FILE_SIZE_BYTES, requiredFieldsBeforeFile: ['workspaceId'], signal: request.signal, }) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index bafd798b470..6d0848b4caf 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1835,7 +1835,7 @@ export function useImportCsv() { }, onError: (error) => { logger.error('Failed to start CSV import:', error) - toast.error(error.message, { duration: 5000 }) + toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 }) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) @@ -1879,7 +1879,7 @@ export function useImportFileAsTable() { }, onError: (error) => { logger.error('Failed to start import from file:', error) - toast.error(error.message, { duration: 5000 }) + toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 }) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) @@ -1936,7 +1936,7 @@ export function useImportCsvIntoTable() { onError: (error, variables) => { if (handleTableLockRejection(error, queryClient, variables.tableId)) return logger.error('Failed to start CSV import:', error) - toast.error(error.message, { duration: 5000 }) + toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 }) }, onSettled: (_data, _error, variables) => { invalidateRowCount(queryClient, variables.tableId) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 0c86d08a1d6..8813cd97d2c 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -34,7 +34,7 @@ import { SORT_DIRECTIONS, TABLE_LIMITS, } from '@/lib/table/constants' -import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' +import { CSV_SYNC_MAX_FILE_SIZE_BYTES, CSV_SYNC_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' import { getTablePredicateTreeSizeError, MAX_PREDICATE_GROUP_SIZE, @@ -1063,10 +1063,10 @@ export const csvFileSchema = z ctx.addIssue({ code: 'custom', message: 'CSV file is required' }) return } - if (value.size > CSV_MAX_FILE_SIZE_BYTES) { + if (value.size > CSV_SYNC_MAX_FILE_SIZE_BYTES) { ctx.addIssue({ code: 'custom', - message: CSV_MAX_FILE_SIZE_MESSAGE, + message: CSV_SYNC_MAX_FILE_SIZE_MESSAGE, }) } }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index af703dc2803..77b2b6f6405 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -11,7 +11,7 @@ import { v2UpdateTableColumnBodySchema, } from '@/lib/api/contracts/v2/tables' import { TABLE_LIMITS } from '@/lib/table/constants' -import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' +import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -85,10 +85,12 @@ function existingTableImport(overrides: Record = {}) { describe('v2 table import contracts', () => { it('accepts the exact CSV byte limit and rejects one byte over it', () => { expect( - v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES)).success + v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_DURABLE_MAX_FILE_SIZE_BYTES)) + .success ).toBe(true) expect( - v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES + 1)).success + v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_DURABLE_MAX_FILE_SIZE_BYTES + 1)) + .success ).toBe(false) }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index aae2fe53172..358f4cbc590 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -62,7 +62,10 @@ import { v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { TABLE_LIMITS } from '@/lib/table/constants' -import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' +import { + CSV_DURABLE_MAX_FILE_SIZE_BYTES, + CSV_DURABLE_MAX_FILE_SIZE_MESSAGE, +} from '@/lib/table/import' import type { RowData } from '@/lib/table/types' /** @@ -1405,7 +1408,7 @@ export const v2TableUploadImportSourceSchema = z .number() .int() .min(1) - .max(CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE) + .max(CSV_DURABLE_MAX_FILE_SIZE_BYTES, CSV_DURABLE_MAX_FILE_SIZE_MESSAGE) .describe('Exact CSV file size in bytes.'), }) .strict() diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index e6f597e67b7..44f857baf1a 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -18,6 +18,7 @@ import type { ColumnType } from '@/lib/table/column-types' import { parseCurrencyInput } from '@/lib/table/currency' import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' /** * Field separators we sniff for, in tie-break priority order. Semicolon files are @@ -230,10 +231,15 @@ export const CSV_MAX_BATCH_SIZE = 5000 /** Maximum serialized CSV row data retained before an import batch is flushed. */ export const CSV_MAX_BATCH_SIZE_BYTES = 5 * 1024 * 1024 -/** Maximum CSV/TSV file size accepted by import routes (25 MB). */ -export const CSV_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 +/** Maximum CSV/TSV size accepted by legacy multipart routes that buffer request bodies. */ +export const CSV_SYNC_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 -export const CSV_MAX_FILE_SIZE_MESSAGE = `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB` +export const CSV_SYNC_MAX_FILE_SIZE_MESSAGE = `File exceeds maximum allowed size of ${CSV_SYNC_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB` + +/** Maximum CSV/TSV size accepted by the bounded streaming import worker. */ +export const CSV_DURABLE_MAX_FILE_SIZE_BYTES = MAX_WORKSPACE_FILE_SIZE + +export const CSV_DURABLE_MAX_FILE_SIZE_MESSAGE = 'File exceeds maximum allowed size of 5 GB' /** * Error thrown when the user-supplied mapping or CSV does not line up with the diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts index 143b360d4e6..7270f8d1674 100644 --- a/apps/sim/lib/table/orchestration/import-resource.test.ts +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -46,7 +46,7 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ })) vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings })) -import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' +import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' import { createAuthorizedTableImportResource } from '@/lib/table/orchestration/import-resource' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -108,7 +108,7 @@ describe('createAuthorizedTableImportResource workspace file size', () => { }) it('accepts a workspace CSV at the exact byte limit', async () => { - mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES)) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_DURABLE_MAX_FILE_SIZE_BYTES)) const result = await createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }) @@ -118,7 +118,7 @@ describe('createAuthorizedTableImportResource workspace file size', () => { }) it('rejects a workspace CSV one byte over the limit before creating a table', async () => { - mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES + 1)) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_DURABLE_MAX_FILE_SIZE_BYTES + 1)) await expect( createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }) @@ -150,13 +150,16 @@ describe('createAuthorizedTableImportResource upload size', () => { type: 'upload', name: 'data.csv', contentType: 'text/csv', - size: CSV_MAX_FILE_SIZE_BYTES, + size: CSV_DURABLE_MAX_FILE_SIZE_BYTES, }, target: TARGET, }) expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ fileSize: CSV_MAX_FILE_SIZE_BYTES, purpose: 'table_import' }) + expect.objectContaining({ + fileSize: CSV_DURABLE_MAX_FILE_SIZE_BYTES, + purpose: 'table_import', + }) ) }) @@ -168,7 +171,7 @@ describe('createAuthorizedTableImportResource upload size', () => { type: 'upload', name: 'data.csv', contentType: 'text/csv', - size: CSV_MAX_FILE_SIZE_BYTES + 1, + size: CSV_DURABLE_MAX_FILE_SIZE_BYTES + 1, }, target: TARGET, }) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index d58ca0c9a57..e49817440c3 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -22,7 +22,10 @@ import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { findActiveFolder } from '@/lib/folders/queries' import { getWorkspaceTableLimits } from '@/lib/table/billing' -import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' +import { + CSV_DURABLE_MAX_FILE_SIZE_BYTES, + CSV_DURABLE_MAX_FILE_SIZE_MESSAGE, +} from '@/lib/table/import' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' @@ -85,8 +88,8 @@ async function createTableImportResourceCore( if (body.source.type === 'upload') { assertCsvFileName(body.source.name) - if (body.source.size > CSV_MAX_FILE_SIZE_BYTES) { - throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) + if (body.source.size > CSV_DURABLE_MAX_FILE_SIZE_BYTES) { + throw new OrchestrationError('validation', CSV_DURABLE_MAX_FILE_SIZE_MESSAGE) } const upload = await createUploadSession({ id: importId, @@ -523,8 +526,8 @@ async function requireWorkspaceSource( if (!resolved || resolved.id !== fileId || resolved.workspaceId !== workspaceId) { throw new OrchestrationError('not_found', 'Workspace file not found') } - if (resolved.size > CSV_MAX_FILE_SIZE_BYTES) { - throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) + if (resolved.size > CSV_DURABLE_MAX_FILE_SIZE_BYTES) { + throw new OrchestrationError('validation', CSV_DURABLE_MAX_FILE_SIZE_MESSAGE) } return resolved } From 14b0a4895103dddaaafbefec57dbf84dcba93e04 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 14:18:28 -0700 Subject: [PATCH 4/4] fix(files): classify missing archive targets --- .../[id]/files/folders/[folderId]/route.test.ts | 14 ++++++++++++++ .../workspace/workspace-file-folder-manager.ts | 7 +++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts index 8a9873a8eb0..da38e42cde5 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts @@ -29,6 +29,7 @@ vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ }, })) +import { WorkspaceFileItemsNotFoundError } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { POST as RESTORE } from '@/app/api/workspaces/[id]/files/folders/[folderId]/restore/route' import { DELETE, PATCH } from '@/app/api/workspaces/[id]/files/folders/[folderId]/route' @@ -116,6 +117,19 @@ describe('/api/workspaces/[id]/files/folders/[folderId]', () => { }) }) + it('returns not found when deleting an already archived folder', async () => { + mocks.deleteFolder.mockRejectedValueOnce(new WorkspaceFileItemsNotFoundError([], [FOLDER_ID])) + + const response = await DELETE(request('DELETE'), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + success: false, + error: `Workspace file items not found (folders: ${FOLDER_ID})`, + }) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) + it('restores a folder through the shared use case', async () => { const response = await RESTORE(request('POST'), context) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index b0a18325452..7b3645a9161 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -48,15 +48,14 @@ export class WorkspaceFileMoveConflictError extends Error { } } -export class WorkspaceFileItemsNotFoundError extends Error { - readonly code = 'WORKSPACE_FILE_ITEMS_NOT_FOUND' as const - +export class WorkspaceFileItemsNotFoundError extends OrchestrationError { constructor(fileIds: string[], folderIds: string[]) { const parts = [ fileIds.length > 0 ? `files: ${fileIds.join(', ')}` : null, folderIds.length > 0 ? `folders: ${folderIds.join(', ')}` : null, ].filter(Boolean) - super(`Workspace file items not found (${parts.join('; ')})`) + super('not_found', `Workspace file items not found (${parts.join('; ')})`) + this.name = 'WorkspaceFileItemsNotFoundError' } }