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/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]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 8a6434bd566..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,7 +113,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect(mocks.readMetadata).not.toHaveBeenCalled() }) - it('conceals an authorization failure as not found', async () => { + it('conceals cross-workspace authorization as not found', async () => { mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError()) const response = await callGet(`workspaceId=${WORKSPACE_ID}`) 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..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('conceals download authorization failures', async () => { + it('conceals cross-workspace download authorization', async () => { mocks.download.mockRejectedValue(new NoWorkspaceAccessError()) const response = await GET( diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index 45d60afd0c5..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, v2Error } 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,9 +51,7 @@ export async function authenticateUploadPrincipal(request: NextRequest): Promise return auth.principal } -/** Resource-ID upload controls conceal authorization failures as absence. */ +/** Conceals cross-tenant upload-session authorization while preserving same-workspace denials. */ 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 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 2a0faf317f5..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 @@ -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 a8b04e82093..88d2a5ccfd5 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -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 c2047280fc9..c840e2f8987 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -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.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..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'), }, @@ -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..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 = { @@ -95,9 +95,7 @@ describe('GET /api/v2/logs/[runId]', () => { }) it('conceals canonical workspace authorization as log not-found', async () => { - mocks.execute.mockRejectedValueOnce( - new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') - ) + 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' }), 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]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index b3ec07a61d1..a81f4039c3a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -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 64de962a819..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 @@ -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 cf87f47068f..a0b80ba1664 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -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/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..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,7 +10,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), - createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), + 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/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index 5cce4b1bad5..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('masks a workspace-key/workflow mismatch as 404', async () => { + it('conceals a workspace-key/workflow mismatch as not found', async () => { mockAuthenticateV2ApiKey.mockResolvedValue({ principal: { kind: 'workspace_api_key', @@ -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 6b7b120aaab..877f8421e7a 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,16 @@ 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') + 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, 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..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,7 +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((options) => options), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -20,7 +20,7 @@ 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 with tenant-boundary concealment', () => { expect(GET).toMatchObject({ operation: workflowOperations.export, useCase: exportWorkflow, 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..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,7 +9,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/server/routes', () => ({ createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), - createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), + 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/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 61fadb6fe00..d16cabfc429 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -134,7 +134,7 @@ describe('/api/v2/workflows/[id]', () => { }) }) - 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]/runs/[runId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts index a5d64d343bd..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,7 +35,11 @@ vi.mock('@/lib/api/server/routes', () => { return { admitV2Request: mocks.admit, createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })), - createV2ResourceConcealmentPolicy: vi.fn(() => ({ render: renderOrchestrationError })), + 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/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts index 2e728cb94db..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,7 +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((options) => options), + 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 c1d7239647c..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,7 +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((options) => options), + 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/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/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/api/server/routes/v2-resource-concealment.test.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts index 818352f754d..c7ca4a35a4a 100644 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts @@ -10,6 +10,7 @@ import { PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, WorkspaceApiKeyAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' @@ -59,16 +60,15 @@ const policies: Array<{ }, ] -const resourceAuthorizationErrors = [ +const crossTenantAuthorizationErrors = [ new NoWorkspaceAccessError(), - new WorkspaceApiKeyAuthorizationError(), + new WorkspaceApiKeyScopeAuthorizationError(), 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', + it.each(crossTenantAuthorizationErrors)( + 'conceals cross-tenant authorization: %s', async (error) => { const response = policy.render(error) expect(response?.status).toBe(404) @@ -97,6 +97,15 @@ describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessa }) }) + 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') diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts index 82d56f4e4b3..f3a783cca3e 100644 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts @@ -2,23 +2,24 @@ import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' import { DelegatedWorkspaceAuthorizationError, NoWorkspaceAccessError, - PrincipalKindAuthorizationError, - WorkspaceApiKeyAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' type V2ErrorRenderer = V2ErrorPolicy['render'] -function isResourceAuthorizationError(error: unknown): boolean { +function isCrossTenantAuthorizationError(error: unknown): boolean { return ( error instanceof DelegatedWorkspaceAuthorizationError || error instanceof NoWorkspaceAccessError || - error instanceof PrincipalKindAuthorizationError || - error instanceof WorkspaceApiKeyAuthorizationError + error instanceof WorkspaceApiKeyScopeAuthorizationError ) } -/** Conceals only typed resource-authorization failures without hiding workspace policy denials. */ +/** + * Conceals cross-tenant authorization failures while preserving same-workspace + * policy and role denials as 403 responses. + */ export function createV2ResourceConcealmentPolicy(options: { notFoundMessage: string render?: V2ErrorRenderer @@ -26,7 +27,7 @@ export function createV2ResourceConcealmentPolicy(options: { const render = options.render ?? v2CaughtOrchestrationError return { render(error) { - if (isResourceAuthorizationError(error)) { + if (isCrossTenantAuthorizationError(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/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(), - new PrincipalKindAuthorizationError('workspace_api_key', 'knowledge.read'), - ])('conceals canonical resource authorization failures as absence', async (error) => { + ])('conceals cross-tenant knowledge authorization failures', async (error) => { const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(error) expect(response?.status).toBe(404) expect(await response?.json()).toEqual({ @@ -27,6 +27,15 @@ describe('v2 knowledge error policies', () => { }) }) + it.each([ + new WorkspaceApiKeyAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'knowledge.read'), + ])('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.concealKnowledgeBaseAuthorization.render( new PersonalApiKeysDisabledError() @@ -49,4 +58,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.concealKnowledgeBaseAuthorization.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..6b20b06a984 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -78,21 +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 0c1ca12ed39..aa1e8da8cf1 100644 --- a/apps/sim/lib/logs/api/route-policies.ts +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -1,14 +1,11 @@ -import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' +import { + createV2ResourceConcealmentPolicy, + 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, + concealDetailAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Log not found', + }), } 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/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 } 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' } } diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts index 02174e11a38..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,10 +31,9 @@ import { describe('v2 workflow error policies', () => { it.each([ new NoWorkspaceAccessError(), - new WorkspaceApiKeyAuthorizationError(), + new WorkspaceApiKeyScopeAuthorizationError(), new DelegatedWorkspaceAuthorizationError(), - new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'), - ])('conceals workflow authorization failures as absence', async (error) => { + ])('conceals cross-tenant workflow authorization failures', async (error) => { const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) expect(response?.status).toBe(404) expect(await response?.json()).toEqual({ @@ -41,6 +41,15 @@ describe('v2 workflow error policies', () => { }) }) + it.each([ + new WorkspaceApiKeyAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'), + ])('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('preserves the personal-api-key workspace policy failure as forbidden', async () => { const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( new PersonalApiKeysDisabledError() @@ -58,6 +67,7 @@ describe('v2 workflow error policies', () => { 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' }, }) @@ -72,6 +82,16 @@ describe('v2 workflow error policies', () => { error: { code: 'FORBIDDEN', message: 'Workflow transition is forbidden' }, }) }) + + it('preserves genuine not-found failures', async () => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( + new OrchestrationError('not_found', 'Workflow not found') + ) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) + }) }) describe('internal workflow read auth', () => { 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/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..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,6 +67,48 @@ describe('queryPublicWorkspaceMembers', () => { ]) expect(page?.nextEmail).toBeNull() expect(dbChainMockFns.limit).toHaveBeenCalledWith(11) + 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 c4846107819..32304adc3a5 100644 --- a/apps/sim/lib/workspaces/public-queries.ts +++ b/apps/sim/lib/workspaces/public-queries.ts @@ -117,6 +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 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({ @@ -126,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, eq(member.userId, user.id)) .where( and( eq(permissions.entityType, 'workspace'), @@ -175,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, }) }