From c96b6b027656d79ceadff1a6a79d6b25e4223847 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 19:34:03 -0700 Subject: [PATCH] fix(api): conceal cross-tenant resource denials on internal routes The v2 routes rewrite DelegatedWorkspaceAuthorizationError, NoWorkspaceAccessError, and WorkspaceApiKeyScopeAuthorizationError to a 404 so a caller with no reach into a workspace cannot confirm a resource exists. The internal routes reach the same application use cases and still answered 403, so the same probe worked from the other surface. Same-workspace role denials stay 403 on both. --- .../app/api/table/[tableId]/exports/route.ts | 10 +- .../api/table/[tableId]/groups/route.test.ts | 13 +- .../app/api/table/[tableId]/groups/route.ts | 23 +- .../exports/[exportId]/download/route.ts | 10 +- .../app/api/table/exports/[exportId]/route.ts | 12 +- .../imports/[importId]/complete/route.ts | 10 +- .../table/imports/[importId]/parts/route.ts | 10 +- .../app/api/table/imports/[importId]/route.ts | 12 +- apps/sim/app/api/table/imports/route.ts | 10 +- .../api/table/table-transfer-routes.test.ts | 41 ++- .../v2/workflows/[id]/deploy/route.test.ts | 2 + .../v2/workflows/[id]/export/route.test.ts | 2 + .../v2/workflows/[id]/rollback/route.test.ts | 2 + .../[id]/runs/[runId]/resume/route.test.ts | 2 + .../api/v2/workflows/folders/route.test.ts | 2 + .../app/api/v2/workflows/import/route.test.ts | 2 + .../[id]/deployments/[version]/route.test.ts | 70 ++++- .../[id]/deployments/[version]/route.ts | 7 +- apps/sim/app/api/workflows/[id]/route.test.ts | 68 ++++- apps/sim/app/api/workflows/[id]/route.ts | 12 +- .../[id]/files/[fileId]/content/route.test.ts | 19 ++ .../[id]/files/[fileId]/content/route.ts | 2 +- .../[id]/files/[fileId]/route.test.ts | 28 ++ .../workspaces/[id]/files/[fileId]/route.ts | 4 +- .../[id]/files/[fileId]/share/route.test.ts | 18 ++ .../[id]/files/[fileId]/share/route.ts | 4 +- apps/sim/lib/api/server/routes/index.ts | 6 +- .../routes/resource-concealment.test.ts | 267 ++++++++++++++++++ .../api/server/routes/resource-concealment.ts | 84 ++++++ .../routes/v2-resource-concealment.test.ts | 118 -------- .../server/routes/v2-resource-concealment.ts | 36 --- apps/sim/lib/knowledge/api/route-policies.ts | 44 ++- apps/sim/lib/table/api/index.ts | 1 + apps/sim/lib/table/api/route-policies.ts | 36 +++ apps/sim/lib/workflows/api/index.ts | 2 + apps/sim/lib/workflows/api/route-policies.ts | 52 +++- .../api/internal-error-policies.ts | 15 + 37 files changed, 778 insertions(+), 278 deletions(-) create mode 100644 apps/sim/lib/api/server/routes/resource-concealment.test.ts create mode 100644 apps/sim/lib/api/server/routes/resource-concealment.ts 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 diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts index 0fcb3b2538f..fdcb3c0e970 100644 --- a/apps/sim/app/api/table/[tableId]/exports/route.ts +++ b/apps/sim/app/api/table/[tableId]/exports/route.ts @@ -1,10 +1,6 @@ import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { createTableExportUseCase } from '@/lib/table/application/exports' import { tableOperations } from '@/lib/table/application/operations' import { toV2TableExport } from '@/lib/table/orchestration/export-resource' @@ -16,7 +12,7 @@ export const POST = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table export creation has no request-rate policy', }), - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, workspaceId: body.workspaceId, diff --git a/apps/sim/app/api/table/[tableId]/groups/route.test.ts b/apps/sim/app/api/table/[tableId]/groups/route.test.ts index 7a774f8434d..cad09be8b65 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest' interface CapturedDefinition { contract: { method: string; path: string } auth: unknown + errorPolicy: unknown operation: { id: string } useCase: unknown mapInput(input: { @@ -21,6 +22,7 @@ interface CapturedDefinition { const mocks = vi.hoisted(() => ({ auth: { kind: 'session-or-executor' }, + concealTableGroupAuthorization: { kind: 'conceal-table-group' }, definitions: [] as CapturedDefinition[], useCases: { create: { operation: { id: 'tables.groups.create' } }, @@ -34,15 +36,17 @@ vi.mock('@/lib/api/server/routes', () => ({ mocks.definitions.push(definition) return vi.fn() }, - extendInternalErrorPolicy: vi.fn(() => ({ kind: 'table' })), - internalErrorResponse: vi.fn(), - internalOrchestrationErrorPolicy: { kind: 'plain' }, internalRateLimits: { none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), }, })) -vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) +vi.mock('@/lib/table/api', () => ({ + internalTableErrorPolicies: { + concealTableGroupAuthorization: mocks.concealTableGroupAuthorization, + }, + internalTableSessionOrExecutorAuth: mocks.auth, +})) vi.mock('@/lib/table/application/groups', () => ({ createTableGroupUseCase: mocks.useCases.create, @@ -77,6 +81,7 @@ describe('/api/table/[tableId]/groups', () => { expect(route.auth).toBe(mocks.auth) expect(route.useCase).toBe(useCase) expect(route.operation.id).toBe(useCase.operation.id) + expect(route.errorPolicy).toBe(mocks.concealTableGroupAuthorization) } }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 66c2d00adde..b1f9a1c4749 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -3,30 +3,17 @@ import { deleteWorkflowGroupContract, updateWorkflowGroupContract, } from '@/lib/api/contracts/tables' -import { - defineInternalJsonRoute, - extendInternalErrorPolicy, - internalErrorResponse, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { createTableGroupUseCase, deleteTableGroupUseCase, updateTableGroupUseCase, } from '@/lib/table/application/groups' import { tableOperations } from '@/lib/table/application/operations' -import { TableLockedError } from '@/lib/table/mutation-locks' import type { TableDefinition } from '@/lib/table/types' import { normalizeColumn } from '@/app/api/table/utils' -const errorPolicy = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => - error instanceof TableLockedError - ? internalErrorResponse(423, { error: error.message, lock: error.lock }) - : null -) - const rateLimit = internalRateLimits.none({ reason: 'Existing authenticated table group mutations have no request-rate policy', }) @@ -47,7 +34,7 @@ export const POST = defineInternalJsonRoute({ useCase: createTableGroupUseCase, auth: internalTableSessionOrExecutorAuth, rateLimit, - errorPolicy, + errorPolicy: internalTableErrorPolicies.concealTableGroupAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body, @@ -62,7 +49,7 @@ export const PATCH = defineInternalJsonRoute({ useCase: updateTableGroupUseCase, auth: internalTableSessionOrExecutorAuth, rateLimit, - errorPolicy, + errorPolicy: internalTableErrorPolicies.concealTableGroupAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table }) => presentTable(table), }) @@ -73,7 +60,7 @@ export const DELETE = defineInternalJsonRoute({ useCase: deleteTableGroupUseCase, auth: internalTableSessionOrExecutorAuth, rateLimit, - errorPolicy, + errorPolicy: internalTableErrorPolicies.concealTableGroupAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table }) => presentTable(table), }) diff --git a/apps/sim/app/api/table/exports/[exportId]/download/route.ts b/apps/sim/app/api/table/exports/[exportId]/download/route.ts index 9731e8a4a0d..dc9789fe2dd 100644 --- a/apps/sim/app/api/table/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts @@ -1,10 +1,6 @@ import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { downloadTableExportUseCase } from '@/lib/table/application/exports' import { tableOperations } from '@/lib/table/application/operations' @@ -15,7 +11,7 @@ export const GET = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table export download signing has no request-rate policy', }), - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts index bc0be516ddd..bb5a067f3e7 100644 --- a/apps/sim/app/api/table/exports/[exportId]/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/route.ts @@ -2,12 +2,8 @@ import { cancelTableExportResourceContract, getTableExportResourceContract, } from '@/lib/api/contracts/table-transfers' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports' import { tableOperations } from '@/lib/table/application/operations' import { toV2TableExport } from '@/lib/table/orchestration/export-resource' @@ -21,7 +17,7 @@ export const GET = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, operation: tableOperations.readExport, rateLimit, - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, @@ -35,7 +31,7 @@ export const DELETE = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, operation: tableOperations.cancelExport, rateLimit, - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealExportAuthorization, mapInput: ({ params, query }) => ({ exportId: params.exportId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts index 06336349d4f..6f1090eb5e1 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -1,10 +1,6 @@ import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { completeTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' import { toV2TableImport } from '@/lib/table/orchestration/import-resource' @@ -16,7 +12,7 @@ export const POST = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table import completion has no request-rate policy', }), - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts index 3317e2c602f..8e0971d1bbe 100644 --- a/apps/sim/app/api/table/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -1,10 +1,6 @@ import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { createTableImportPartsUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' @@ -15,7 +11,7 @@ export const POST = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table import part signing has no request-rate policy', }), - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query, headers, body }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts index 7a7ffd6584f..71aab2fc3e0 100644 --- a/apps/sim/app/api/table/imports/[importId]/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -2,12 +2,8 @@ import { cancelTableImportResourceContract, getTableImportResourceContract, } from '@/lib/api/contracts/table-transfers' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' import { toV2TableImport } from '@/lib/table/orchestration/import-resource' @@ -21,7 +17,7 @@ export const GET = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, operation: tableOperations.readImport, rateLimit, - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query }) => ({ importId: params.importId, workspaceId: query.workspaceId, @@ -35,7 +31,7 @@ export const DELETE = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, operation: tableOperations.cancelImport, rateLimit, - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealImportAuthorization, mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts index 9bb37c360f6..a774bdf6c21 100644 --- a/apps/sim/app/api/table/imports/route.ts +++ b/apps/sim/app/api/table/imports/route.ts @@ -1,10 +1,6 @@ import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, -} from '@/lib/api/server/routes' -import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { createTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' import { toV2CreateTableImport } from '@/lib/table/orchestration/import-resource' @@ -16,7 +12,7 @@ export const POST = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table import creation has no request-rate policy', }), - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalTableErrorPolicies.concealTableAuthorization, mapInput: ({ body }) => ({ body }), useCase: createTableImportUseCase, present: ({ import: created }) => ({ data: toV2CreateTableImport(created) }), diff --git a/apps/sim/app/api/table/table-transfer-routes.test.ts b/apps/sim/app/api/table/table-transfer-routes.test.ts index ca8c8ca6b6c..beaa86a8875 100644 --- a/apps/sim/app/api/table/table-transfer-routes.test.ts +++ b/apps/sim/app/api/table/table-transfer-routes.test.ts @@ -10,6 +10,7 @@ interface CapturedDefinition { response: { status?: number } } auth: unknown + errorPolicy: unknown operation: { id: string } useCase: unknown } @@ -17,6 +18,11 @@ interface CapturedDefinition { const mocks = vi.hoisted(() => ({ auth: { kind: 'session-or-executor' }, definitions: [] as CapturedDefinition[], + errorPolicies: { + concealTableAuthorization: { kind: 'conceal-table' }, + concealImportAuthorization: { kind: 'conceal-import' }, + concealExportAuthorization: { kind: 'conceal-export' }, + }, useCases: { cancelExport: { operation: { id: 'tables.exports.cancel' } }, cancelImport: { operation: { id: 'tables.imports.cancel' } }, @@ -41,7 +47,10 @@ vi.mock('@/lib/api/server/routes', () => ({ }, })) -vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) +vi.mock('@/lib/table/api', () => ({ + internalTableErrorPolicies: mocks.errorPolicies, + internalTableSessionOrExecutorAuth: mocks.auth, +})) vi.mock('@/lib/table/application/imports', () => ({ cancelTableImportUseCase: mocks.useCases.cancelImport, @@ -106,6 +115,36 @@ describe('internal table transfer routes', () => { } }) + it('conceals cross-tenant authorization on every table transfer control leg', () => { + const expected = [ + ['POST', '/api/table/imports', mocks.errorPolicies.concealTableAuthorization], + ['GET', '/api/table/imports/[importId]', mocks.errorPolicies.concealImportAuthorization], + ['DELETE', '/api/table/imports/[importId]', mocks.errorPolicies.concealImportAuthorization], + [ + 'POST', + '/api/table/imports/[importId]/parts', + mocks.errorPolicies.concealImportAuthorization, + ], + [ + 'POST', + '/api/table/imports/[importId]/complete', + mocks.errorPolicies.concealImportAuthorization, + ], + ['POST', '/api/table/[tableId]/exports', mocks.errorPolicies.concealTableAuthorization], + ['GET', '/api/table/exports/[exportId]', mocks.errorPolicies.concealExportAuthorization], + ['DELETE', '/api/table/exports/[exportId]', mocks.errorPolicies.concealExportAuthorization], + [ + 'GET', + '/api/table/exports/[exportId]/download', + mocks.errorPolicies.concealExportAuthorization, + ], + ] as const + + for (const [method, path, errorPolicy] of expected) { + expect(definition(method, path).errorPolicy).toBe(errorPolicy) + } + }) + it('preserves the create response statuses', () => { expect(definition('POST', '/api/table/imports').contract.response.status).toBe(201) expect(definition('POST', '/api/table/[tableId]/exports').contract.response.status).toBe(201) 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 e298f0d09aa..b41845f21a0 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 @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })), + internalOrchestrationErrorPolicy: { kind: 'internal-plain' }, createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, 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 205517eda7e..96b5468d343 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 @@ -6,6 +6,8 @@ import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })), + internalOrchestrationErrorPolicy: { kind: 'internal-plain' }, createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, 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 690d53fa3b9..b1424da0ea6 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 @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })), + internalOrchestrationErrorPolicy: { kind: 'internal-plain' }, createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, 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 3d1165aad5f..2d726aa208d 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 @@ -34,6 +34,8 @@ vi.mock('@/lib/api/server/routes', () => { } return { admitV2Request: mocks.admit, + createInternalResourceConcealmentPolicy: vi.fn(() => ({ project: () => null })), + internalOrchestrationErrorPolicy: { project: () => null }, createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), createV2ResourceConcealmentPolicy: vi.fn( ({ render }: { render?: (error: unknown) => Response | null }) => ({ 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 eee8e019850..577e9f0278c 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.test.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -6,6 +6,8 @@ import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })), + internalOrchestrationErrorPolicy: { kind: 'internal-plain' }, createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, 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 bfd43dad013..caab09d2d61 100644 --- a/apps/sim/app/api/v2/workflows/import/route.test.ts +++ b/apps/sim/app/api/v2/workflows/import/route.test.ts @@ -6,6 +6,8 @@ import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })), + internalOrchestrationErrorPolicy: { kind: 'internal-plain' }, createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts index 7dece65c9bc..560f4d66d3b 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts @@ -17,18 +17,25 @@ vi.mock('@/lib/api/server', () => ({ parseRequest: mocks.parseRequest, })) -vi.mock('@/lib/api/server/routes', () => ({ - defineInternalJsonRoute: vi.fn(() => vi.fn()), - InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, - internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, - internalSessionAuth: { authenticate: mocks.session }, -})) +vi.mock('@/lib/api/server/routes', async () => { + const { concealCrossTenantResourceError } = await import( + '@/lib/api/server/routes/resource-concealment' + ) + return { + concealCrossTenantResourceError, + defineInternalJsonRoute: vi.fn(() => vi.fn()), + InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, + internalSessionAuth: { authenticate: mocks.session }, + } +}) vi.mock('@/lib/workflows/api', () => ({ createInternalWorkflowErrorPolicy: vi.fn(() => ({ project: vi.fn(), unhandled: vi.fn(), })), + WORKFLOW_NOT_FOUND_MESSAGE: 'Workflow not found', })) vi.mock('@/lib/core/utils/with-route-handler', () => ({ @@ -44,6 +51,12 @@ vi.mock('@/lib/workflows/application/read-workflow-version', () => ({ readWorkflowVersion: { execute: mocks.read }, })) +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' import { PATCH } from '@/app/api/workflows/[id]/deployments/[version]/route' describe('workflow deployment version PATCH', () => { @@ -122,4 +135,49 @@ describe('workflow deployment version PATCH', () => { expect(mocks.update).toHaveBeenCalledOnce() expect(mocks.activate).not.toHaveBeenCalled() }) + + it.each([ + new NoWorkspaceAccessError(), + new WorkspaceApiKeyScopeAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + ])('conceals a cross-tenant activation denial as an absent workflow: %s', async (error) => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1', version: 2 }, body: { isActive: true } }, + }) + mocks.activate.mockRejectedValueOnce(error) + + const response = await PATCH( + createMockRequest( + 'PATCH', + undefined, + {}, + 'http://localhost/api/workflows/workflow-1/deployments/2' + ), + { params: Promise.resolve({ id: 'workflow-1', version: '2' }) } + ) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: 'Workflow not found' }) + }) + + it('keeps a same-workspace role denial on activation forbidden', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1', version: 2 }, body: { isActive: true } }, + }) + mocks.activate.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + + const response = await PATCH( + createMockRequest( + 'PATCH', + undefined, + {}, + 'http://localhost/api/workflows/workflow-1/deployments/2' + ), + { params: Promise.resolve({ id: 'workflow-1', version: '2' }) } + ) + + expect(response.status).toBe(403) + }) }) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 5221df281f0..627601e99b6 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -6,6 +6,7 @@ import { } from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { + concealCrossTenantResourceError, defineInternalJsonRoute, InternalUnauthenticatedError, internalRateLimits, @@ -14,7 +15,7 @@ import { import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' +import { createInternalWorkflowErrorPolicy, WORKFLOW_NOT_FOUND_MESSAGE } from '@/lib/workflows/api' import { activateWorkflowVersion, updateWorkflowVersion, @@ -118,7 +119,9 @@ export const PATCH = withRouteHandler( if (error instanceof InternalUnauthenticatedError) { return createErrorResponse(error.message, 401) } - const orchestrationError = asOrchestrationError(error) + const orchestrationError = asOrchestrationError( + concealCrossTenantResourceError(error, WORKFLOW_NOT_FOUND_MESSAGE) + ) if (orchestrationError) { return createErrorResponse( orchestrationError.message, diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index c219c102d66..6326e1540f4 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -17,18 +17,28 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/server', () => ({ parseRequest: mocks.parseRequest })) -vi.mock('@/lib/api/server/routes', () => ({ - defineInternalJsonRoute: mocks.defineRoute, - InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, - internalOrchestrationErrorPolicy: { kind: 'plain-orchestration' }, - internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, -})) +vi.mock('@/lib/api/server/routes', async () => { + const { concealCrossTenantResourceError } = await import( + '@/lib/api/server/routes/resource-concealment' + ) + return { + concealCrossTenantResourceError, + defineInternalJsonRoute: mocks.defineRoute, + InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, + internalOrchestrationErrorPolicy: { kind: 'plain-orchestration' }, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, + } +}) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) vi.mock('@/lib/workflows/api', () => ({ + internalWorkflowErrorPolicies: { + concealWorkflowAuthorization: { kind: 'conceal-workflow-authorization' }, + }, internalWorkflowReadAuth: { authenticate: mocks.auth }, internalWorkflowSessionOrExecutorAuth: { authenticate: mocks.auth }, + WORKFLOW_NOT_FOUND_MESSAGE: 'Workflow not found', })) vi.mock('@/lib/workflows/application/read-workflow-definition', () => ({ @@ -56,6 +66,12 @@ vi.mock('@/lib/workflows/application/update-workflow', () => ({ }, })) +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' import { DELETE, GET, PUT } from '@/app/api/workflows/[id]/route' const sessionPrincipal = { @@ -97,6 +113,12 @@ describe('/api/workflows/[id] application adapters', () => { expect(Reflect.get(DELETE, 'mapInput')({ params: { id: 'workflow-1' } })).toEqual({ workflowId: 'workflow-1', }) + + for (const handler of [GET, DELETE]) { + expect(Reflect.get(handler, 'errorPolicy')).toMatchObject({ + kind: 'conceal-workflow-authorization', + }) + } }) it('keeps human delete analytics surface-specific and no-op aware', async () => { @@ -155,6 +177,40 @@ describe('/api/workflows/[id] application adapters', () => { ) }) + it.each([ + new NoWorkspaceAccessError(), + new WorkspaceApiKeyScopeAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + ])('conceals a cross-tenant update denial as an absent workflow: %s', async (error) => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } }, + }) + mocks.updateWorkflow.mockRejectedValueOnce(error) + + const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Workflow not found' }) + }) + + it('keeps a same-workspace role denial on update forbidden', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } }, + }) + mocks.updateWorkflow.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + + const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Insufficient workspace permissions' }) + }) + it('projects unknown update failures safely', async () => { mocks.parseRequest.mockResolvedValue({ success: true, diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index ff1bc691125..6c0c4982a55 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -10,17 +10,19 @@ import { } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { + concealCrossTenantResourceError, defineInternalJsonRoute, InternalUnauthenticatedError, - internalOrchestrationErrorPolicy, internalRateLimits, } from '@/lib/api/server/routes' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { + internalWorkflowErrorPolicies, internalWorkflowReadAuth, internalWorkflowSessionOrExecutorAuth, + WORKFLOW_NOT_FOUND_MESSAGE, } from '@/lib/workflows/api' import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' @@ -37,7 +39,7 @@ export const GET = defineInternalJsonRoute({ auth: internalWorkflowReadAuth, operation: readWorkflowDefinition.operation, rateLimit: workflowInternalRateLimit, - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id, state: 'draft' as const }), useCase: readWorkflowDefinition, present: ({ workflow: workflowData, state }) => { @@ -82,7 +84,7 @@ export const DELETE = defineInternalJsonRoute({ auth: internalWorkflowSessionOrExecutorAuth, operation: deleteWorkflow.operation, rateLimit: workflowInternalRateLimit, - errorPolicy: internalOrchestrationErrorPolicy, + errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: deleteWorkflow, present: () => ({ success: true as const }), @@ -147,7 +149,9 @@ export const PUT = withRouteHandler( if (error instanceof InternalUnauthenticatedError) { return NextResponse.json({ error: error.message }, { status: 401 }) } - const orchestrationError = asOrchestrationError(error) + const orchestrationError = asOrchestrationError( + concealCrossTenantResourceError(error, WORKFLOW_NOT_FOUND_MESSAGE) + ) if (orchestrationError) { return NextResponse.json( { error: orchestrationError.message }, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index c1fbd5c74f5..698f524a4ce 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -20,6 +20,11 @@ vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => })) import { StorageLimitExceededError } from '@/lib/billing/storage' +import { + DelegatedWorkspaceAuthorizationError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route' @@ -88,6 +93,20 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(mocks.updateContent).not.toHaveBeenCalled() }) + it.each([ + new NoWorkspaceAccessError(), + new WorkspaceApiKeyScopeAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + ])('conceals a cross-tenant admission denial as an absent file: %s', async (error) => { + mocks.admit.mockRejectedValue(error) + + const response = await PUT(createRequest('{not-json'), routeContext) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toMatchObject({ error: 'File not found' }) + expect(mocks.updateContent).not.toHaveBeenCalled() + }) + it('rejects malformed base64 after admission', async () => { const response = await PUT( createRequest({ content: 'not-base64!', encoding: 'base64' }), diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 933dadbcb3a..fdf8fff3f30 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -22,7 +22,7 @@ export const PUT = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal content-update behavior', }), - errorPolicy: internalFileErrorPolicies.content, + errorPolicy: internalFileErrorPolicies.concealContentAuthorization, parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, beforeParse: async ({ principal, params }) => { if (typeof params.fileId === 'string') { diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts index fd31638e313..a0b4d86aba6 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts @@ -31,6 +31,12 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/route' @@ -131,6 +137,28 @@ describe('PATCH /api/workspaces/[id]/files/[fileId]', () => { expect(mocks.captureServerEvent).not.toHaveBeenCalled() }) + it.each([ + new NoWorkspaceAccessError(), + new WorkspaceApiKeyScopeAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + ])('conceals a cross-tenant denial as an absent file: %s', async (error) => { + mocks.rename.mockRejectedValue(error) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'File not found' }) + }) + + it('keeps a same-workspace role denial forbidden', async () => { + mocks.rename.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Insufficient workspace permissions' }) + }) + it('hides unexpected failures behind the internal 500 envelope', async () => { mocks.rename.mockRejectedValue(new Error('update workspace_files failed')) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 2f5bb14e2d1..a93c34f07f3 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -28,7 +28,7 @@ export const PATCH = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.rename, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal rename behavior' }), - errorPolicy: internalFileErrorPolicies.default, + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id, @@ -48,7 +48,7 @@ export const DELETE = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.delete, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal delete behavior' }), - errorPolicy: internalFileErrorPolicies.default, + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), useCase: deleteWorkspaceFileOperation, onSuccess: internalFileAnalytics.deleted, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts index 3cb9bc235cf..f7c53af37b7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts @@ -23,6 +23,11 @@ vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ }, })) +import { + DelegatedWorkspaceAuthorizationError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route' @@ -99,6 +104,19 @@ describe('/api/workspaces/[id]/files/[fileId]/share', () => { expect(await response.json()).toEqual({ error: 'Access denied' }) }) + it.each([ + new NoWorkspaceAccessError(), + new WorkspaceApiKeyScopeAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + ])('conceals a cross-tenant denial as an absent file: %s', async (error) => { + mocks.getShare.mockRejectedValueOnce(error) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'File not found' }) + }) + it('renders resource absence as 404', async () => { mocks.getShare.mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found')) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index 10629599374..880fe64669a 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -18,7 +18,7 @@ export const GET = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.readShare, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-read behavior' }), - errorPolicy: internalFileErrorPolicies.default, + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), useCase: getWorkspaceFileShare, }) @@ -30,7 +30,7 @@ export const PUT = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-update behavior', }), - errorPolicy: internalFileErrorPolicies.default, + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id, diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index e93cf272631..9c72405f0b9 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -12,6 +12,11 @@ export { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes/internal-json-route' +export { + concealCrossTenantResourceError, + createInternalResourceConcealmentPolicy, + createV2ResourceConcealmentPolicy, +} from '@/lib/api/server/routes/resource-concealment' export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { defineV2BodyLifecycleRoute } from '@/lib/api/server/routes/v2-body-lifecycle-route' export { @@ -24,4 +29,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/resource-concealment.test.ts b/apps/sim/lib/api/server/routes/resource-concealment.test.ts new file mode 100644 index 00000000000..b9abb7e2db5 --- /dev/null +++ b/apps/sim/lib/api/server/routes/resource-concealment.test.ts @@ -0,0 +1,267 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + concealCrossTenantResourceError, + type InternalErrorPolicy, + type V2ErrorPolicy, +} from '@/lib/api/server/routes' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { + internalKnowledgeErrorPolicies, + v2KnowledgeErrorPolicies, +} from '@/lib/knowledge/api/route-policies' +import { internalTableErrorPolicies, v2TableErrorPolicies } from '@/lib/table/api/route-policies' +import { + createInternalWorkflowErrorPolicy, + internalWorkflowErrorPolicies, + v2WorkflowErrorPolicies, +} from '@/lib/workflows/api/route-policies' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-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' }, + }) + }) +}) + +/** + * Internal routes reach the same application use cases as their v2 twins, so a + * cross-tenant denial must read as absence on both surfaces. Anything narrower + * — a same-workspace role or key-policy denial — stays a 403 here exactly as it + * does on v2. + */ +const internalPolicies: Array<{ + route: string + policy: InternalErrorPolicy + notFoundMessage: string +}> = [ + { + route: 'PATCH/DELETE /api/workspaces/[id]/files/[fileId]', + policy: internalFileErrorPolicies.concealResourceAuthorization, + notFoundMessage: 'File not found', + }, + { + route: 'PUT /api/workspaces/[id]/files/[fileId]/content', + policy: internalFileErrorPolicies.concealContentAuthorization, + notFoundMessage: 'File not found', + }, + { + route: 'GET/DELETE /api/workflows/[id]', + policy: internalWorkflowErrorPolicies.concealWorkflowAuthorization, + notFoundMessage: 'Workflow not found', + }, + { + route: 'POST/DELETE /api/workflows/[id]/deploy', + policy: createInternalWorkflowErrorPolicy('Failed to deploy workflow'), + notFoundMessage: 'Workflow not found', + }, + { + route: 'GET /api/workflows/[id]/deployments', + policy: createInternalWorkflowErrorPolicy('Failed to list deployments'), + notFoundMessage: 'Workflow not found', + }, + { + route: 'GET /api/workflows/[id]/deployments/[version]', + policy: createInternalWorkflowErrorPolicy('Failed to fetch deployment version'), + notFoundMessage: 'Workflow not found', + }, + { + route: 'POST /api/table/imports, POST /api/table/[tableId]/exports', + policy: internalTableErrorPolicies.concealTableAuthorization, + notFoundMessage: 'Table not found', + }, + { + route: 'POST/PATCH/DELETE /api/table/[tableId]/groups', + policy: internalTableErrorPolicies.concealTableGroupAuthorization, + notFoundMessage: 'Table not found', + }, + { + route: 'GET/DELETE /api/table/imports/[importId]', + policy: internalTableErrorPolicies.concealImportAuthorization, + notFoundMessage: 'Table import not found', + }, + { + route: 'GET/DELETE /api/table/exports/[exportId]', + policy: internalTableErrorPolicies.concealExportAuthorization, + notFoundMessage: 'Table export not found', + }, + { + route: 'GET/PUT/DELETE /api/knowledge/[id]', + policy: internalKnowledgeErrorPolicies.read, + notFoundMessage: 'Knowledge base not found', + }, + { + route: 'GET /api/knowledge/[id]/documents/[documentId]', + policy: internalKnowledgeErrorPolicies.documents, + notFoundMessage: 'Knowledge base not found', + }, + { + route: 'POST /api/knowledge/[id]/documents', + policy: internalKnowledgeErrorPolicies.uploads, + notFoundMessage: 'Knowledge base not found', + }, + { + route: 'POST /api/knowledge/search', + policy: internalKnowledgeErrorPolicies.search, + notFoundMessage: 'Knowledge base not found', + }, +] + +describe.each(internalPolicies)('$route internal concealment', ({ policy, notFoundMessage }) => { + it.each(crossTenantAuthorizationErrors)('conceals cross-tenant authorization: %s', (error) => { + const response = policy.project(error) + expect(response?.status).toBe(404) + expect(response?.body).toMatchObject({ error: notFoundMessage }) + }) + + it('preserves insufficient workspace role as forbidden', () => { + const response = policy.project(new InsufficientWorkspacePermissionsError()) + expect(response?.status).toBe(403) + expect(response?.body).toMatchObject({ error: 'Insufficient workspace permissions' }) + }) + + it.each([ + new PersonalApiKeysDisabledError(), + new WorkspaceApiKeyAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), + ])('preserves same-workspace principal policy denial as forbidden: %s', (error) => { + expect(policy.project(error)?.status).toBe(403) + }) + + it('does not classify generic forbidden errors by message', () => { + const response = policy.project( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + expect(response?.status).toBe(403) + }) +}) + +describe('internal list surfaces', () => { + it.each(crossTenantAuthorizationErrors)( + 'leaves workspace-level knowledge listing forbidden: %s', + (error) => { + expect(internalKnowledgeErrorPolicies.list.project(error)?.status).toBe(403) + expect(internalKnowledgeErrorPolicies.create.project(error)?.status).toBe(403) + } + ) +}) + +describe('concealCrossTenantResourceError', () => { + it.each(crossTenantAuthorizationErrors)('reports absence for %s', (error) => { + const concealed = concealCrossTenantResourceError(error, 'Workflow not found') + expect(asOrchestrationError(concealed)).toMatchObject({ + code: 'not_found', + message: 'Workflow not found', + }) + }) + + it('passes every other failure through untouched', () => { + const error = new InsufficientWorkspacePermissionsError() + expect(concealCrossTenantResourceError(error, 'Workflow not found')).toBe(error) + }) +}) diff --git a/apps/sim/lib/api/server/routes/resource-concealment.ts b/apps/sim/lib/api/server/routes/resource-concealment.ts new file mode 100644 index 00000000000..9eef885ded2 --- /dev/null +++ b/apps/sim/lib/api/server/routes/resource-concealment.ts @@ -0,0 +1,84 @@ +import type { InternalErrorPolicy } from '@/lib/api/server/routes/internal-json-route' +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { + DelegatedWorkspaceAuthorizationError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +type V2ErrorRenderer = V2ErrorPolicy['render'] + +/** + * Authorization failures proving the caller has no reach into the resource's + * workspace at all, as opposed to a workspace member whose role is too low. + * Answering these with `403` confirms the resource exists to a caller who was + * never entitled to learn that. + */ +function isCrossTenantAuthorizationError(error: unknown): boolean { + return ( + error instanceof DelegatedWorkspaceAuthorizationError || + error instanceof NoWorkspaceAccessError || + error instanceof WorkspaceApiKeyScopeAuthorizationError + ) +} + +/** + * The failure a caller should see in place of `error`: an absent resource when + * the denial was cross-tenant, and `error` itself otherwise. + * + * Routes that classify their own failures instead of delegating to an error + * policy — the raw `withRouteHandler` exceptions — run their caught value + * through this before classifying it, so they conceal the same way the + * policy-driven routes beside them do. + */ +export function concealCrossTenantResourceError(error: unknown, notFoundMessage: string): unknown { + if (!isCrossTenantAuthorizationError(error)) return error + return new OrchestrationError('not_found', notFoundMessage) +} + +/** + * 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) + }, + } +} + +/** + * The internal-surface counterpart of {@link createV2ResourceConcealmentPolicy}. + * + * The same application use case is reachable from both `/api/v2/...` and the + * internal `/api/...` routes, so a surface answering `403` where the other + * answers `404` hands back the resource-existence signal the v2 policy exists to + * withhold. The concealed failure is re-projected through `base` as a + * `not_found` orchestration error rather than built directly, so each domain's + * own error body — the legacy workflow `code` field, the shared `requestId` + * stamp — matches what its ordinary 404s already return. + */ +export function createInternalResourceConcealmentPolicy(options: { + base: InternalErrorPolicy + notFoundMessage: string +}): InternalErrorPolicy { + if (!options.notFoundMessage.trim()) { + throw new Error('A concealed internal resource requires a not-found message') + } + return { + project(error) { + return options.base.project(concealCrossTenantResourceError(error, options.notFoundMessage)) + }, + unhandled: options.base.unhandled, + } +} 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 c7ca4a35a4a..00000000000 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts +++ /dev/null @@ -1,118 +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, - 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 deleted file mode 100644 index f3a783cca3e..00000000000 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index 0d2d715fc0e..22628d413fe 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -1,4 +1,5 @@ import { + createInternalResourceConcealmentPolicy, createInternalSessionOrExecutorAuth, createV2ResourceConcealmentPolicy, type InternalErrorPolicy, @@ -52,21 +53,42 @@ export const internalKnowledgeSessionOrExecutorAuth = createInternalSessionOrExe audience: KNOWLEDGE_DELEGATION_AUDIENCE, }) +export const KNOWLEDGE_BASE_NOT_FOUND_MESSAGE = 'Knowledge base not found' + +/** + * Conceals a knowledge-base-scoped internal policy the way the v2 knowledge + * routes conceal theirs. The workspace-level `list` and `create` policies are + * deliberately left alone: neither names a knowledge base, so there is no + * resource whose existence a 403 could betray. + */ +function concealKnowledgeBase(base: InternalErrorPolicy): InternalErrorPolicy { + return createInternalResourceConcealmentPolicy({ + base, + notFoundMessage: KNOWLEDGE_BASE_NOT_FOUND_MESSAGE, + }) +} + export const internalKnowledgeErrorPolicies = { list: internalKnowledgeErrorPolicy('Failed to fetch knowledge bases'), - read: internalKnowledgeErrorPolicy('Failed to fetch knowledge base'), + read: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to fetch knowledge base')), create: internalKnowledgeErrorPolicy('Failed to create knowledge base'), - update: internalKnowledgeErrorPolicy('Failed to update knowledge base'), - delete: internalKnowledgeErrorPolicy('Failed to delete knowledge base'), - restore: internalKnowledgeErrorPolicy('Internal server error'), + update: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to update knowledge base')), + delete: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to delete knowledge base')), + restore: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')), default: internalKnowledgeErrorPolicy('Internal server error'), - documents: internalKnowledgeErrorPolicy('Failed to process knowledge document request'), - chunks: internalKnowledgeErrorPolicy('Failed to process knowledge chunk request'), - upsert: internalKnowledgeUploadErrorPolicy, - search: internalKnowledgeSearchErrorPolicy, - tags: internalKnowledgeErrorPolicy('Failed to process knowledge tag request'), - connectors: internalKnowledgeErrorPolicy('Internal server error'), - uploads: internalKnowledgeUploadErrorPolicy, + documents: concealKnowledgeBase( + internalKnowledgeErrorPolicy('Failed to process knowledge document request') + ), + chunks: concealKnowledgeBase( + internalKnowledgeErrorPolicy('Failed to process knowledge chunk request') + ), + upsert: concealKnowledgeBase(internalKnowledgeUploadErrorPolicy), + search: concealKnowledgeBase(internalKnowledgeSearchErrorPolicy), + tags: concealKnowledgeBase( + internalKnowledgeErrorPolicy('Failed to process knowledge tag request') + ), + connectors: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')), + uploads: concealKnowledgeBase(internalKnowledgeUploadErrorPolicy), } as const const v2KnowledgeUsageErrorPolicy = { diff --git a/apps/sim/lib/table/api/index.ts b/apps/sim/lib/table/api/index.ts index 892fe18d6f9..e04cc23089e 100644 --- a/apps/sim/lib/table/api/index.ts +++ b/apps/sim/lib/table/api/index.ts @@ -1,4 +1,5 @@ export { + internalTableErrorPolicies, internalTableSessionOrExecutorAuth, v2TableErrorPolicies, } from '@/lib/table/api/route-policies' diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index 41d1eda13ff..ab0303bb014 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -1,6 +1,10 @@ import { + createInternalResourceConcealmentPolicy, createInternalSessionOrExecutorAuth, createV2ResourceConcealmentPolicy, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, type V2ErrorPolicy, } from '@/lib/api/server/routes' import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' @@ -53,3 +57,35 @@ export const v2TableErrorPolicies = { render: renderTableError, }), } as const + +const internalTableGroupErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => + error instanceof TableLockedError + ? internalErrorResponse(423, { error: error.message, lock: error.lock }) + : null +) + +/** + * Internal-surface counterparts of {@link v2TableErrorPolicies}. The internal + * routes reach the same table use cases, so they conceal the same cross-tenant + * authorization failures behind the same not-found wording. + */ +export const internalTableErrorPolicies = { + concealTableAuthorization: createInternalResourceConcealmentPolicy({ + base: internalOrchestrationErrorPolicy, + notFoundMessage: 'Table not found', + }), + concealTableGroupAuthorization: createInternalResourceConcealmentPolicy({ + base: internalTableGroupErrorPolicy, + notFoundMessage: 'Table not found', + }), + concealImportAuthorization: createInternalResourceConcealmentPolicy({ + base: internalOrchestrationErrorPolicy, + notFoundMessage: 'Table import not found', + }), + concealExportAuthorization: createInternalResourceConcealmentPolicy({ + base: internalOrchestrationErrorPolicy, + notFoundMessage: 'Table export not found', + }), +} as const diff --git a/apps/sim/lib/workflows/api/index.ts b/apps/sim/lib/workflows/api/index.ts index 2d0371da36a..26e4ce576dd 100644 --- a/apps/sim/lib/workflows/api/index.ts +++ b/apps/sim/lib/workflows/api/index.ts @@ -1,6 +1,8 @@ export { createInternalWorkflowErrorPolicy, + internalWorkflowErrorPolicies, internalWorkflowReadAuth, internalWorkflowSessionOrExecutorAuth, v2WorkflowErrorPolicies, + WORKFLOW_NOT_FOUND_MESSAGE, } from '@/lib/workflows/api/route-policies' diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index bb5e1ec6930..102b6e69b17 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -1,11 +1,13 @@ import type { Principal } from '@sim/auth/principal' import { + createInternalResourceConcealmentPolicy, createInternalSessionOrExecutorAuth, createV2ResourceConcealmentPolicy, type InternalAuthPolicy, type InternalErrorPolicy, InternalUnauthenticatedError, internalErrorResponse, + internalOrchestrationErrorPolicy, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' @@ -63,22 +65,42 @@ function legacyWorkflowErrorCode(message: string): string { return message.toUpperCase().replace(/\s+/g, '_') } +export const WORKFLOW_NOT_FOUND_MESSAGE = 'Workflow not found' + +/** + * Every route built on this policy is scoped to a single workflow, so all of + * them conceal cross-tenant authorization the way their v2 counterparts do. + */ export function createInternalWorkflowErrorPolicy(fallback: string): InternalErrorPolicy { if (!fallback.trim()) throw new Error('Internal workflow error fallback is required') - return { - project(error) { - const classified = asOrchestrationError(error) - if (!classified) return null - return internalErrorResponse(statusForOrchestrationError(classified.code), { - error: classified.message, - code: legacyWorkflowErrorCode(classified.message), - }) - }, - unhandled() { - return internalErrorResponse(500, { - error: fallback, - code: legacyWorkflowErrorCode(fallback), - }) + return createInternalResourceConcealmentPolicy({ + notFoundMessage: WORKFLOW_NOT_FOUND_MESSAGE, + base: { + project(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + code: legacyWorkflowErrorCode(classified.message), + }) + }, + unhandled() { + return internalErrorResponse(500, { + error: fallback, + code: legacyWorkflowErrorCode(fallback), + }) + }, }, - } + }) } + +/** + * Internal-surface counterparts of {@link v2WorkflowErrorPolicies} for the + * workflow routes that project plain orchestration errors. + */ +export const internalWorkflowErrorPolicies = { + concealWorkflowAuthorization: createInternalResourceConcealmentPolicy({ + base: internalOrchestrationErrorPolicy, + notFoundMessage: WORKFLOW_NOT_FOUND_MESSAGE, + }), +} as const diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.ts index a9fd4bd52a0..a54b65bc492 100644 --- a/apps/sim/lib/workspace-files/api/internal-error-policies.ts +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { + createInternalResourceConcealmentPolicy, extendInternalErrorPolicy, type InternalErrorPolicy, internalErrorResponse, @@ -78,9 +79,23 @@ const inline: InternalErrorPolicy = { }, } +const FILE_NOT_FOUND_MESSAGE = 'File not found' + export const internalFileErrorPolicies = { default: internalOrchestrationErrorPolicy, content, + /** + * Single-file internal routes reach the same use cases as the concealing v2 + * file routes, so they withhold the same cross-tenant existence signal. + */ + concealResourceAuthorization: createInternalResourceConcealmentPolicy({ + base: internalOrchestrationErrorPolicy, + notFoundMessage: FILE_NOT_FOUND_MESSAGE, + }), + concealContentAuthorization: createInternalResourceConcealmentPolicy({ + base: content, + notFoundMessage: FILE_NOT_FOUND_MESSAGE, + }), style, compiledCheck, downloadUrl,