diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 495f4ad4913..799fb19b97c 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -20,6 +20,10 @@ const { mockIsUsingCloudStorage, mockDownloadCopilotFile, mockInferContextFromKey, + mockParseWorkspaceFileKey, + mockAuthenticateWorkspaceFile, + mockReadWorkspaceFileContentByKey, + mockResolveServableDocBytes, mockGetContentType, mockFindLocalFile, mockCreateFileResponse, @@ -40,6 +44,10 @@ const { mockIsUsingCloudStorage: vi.fn(), mockDownloadCopilotFile: vi.fn(), mockInferContextFromKey: vi.fn(), + mockParseWorkspaceFileKey: vi.fn(), + mockAuthenticateWorkspaceFile: vi.fn(), + mockReadWorkspaceFileContentByKey: vi.fn(), + mockResolveServableDocBytes: vi.fn(), mockGetContentType: vi.fn(), mockFindLocalFile: vi.fn(), mockCreateFileResponse: vi.fn(), @@ -82,7 +90,19 @@ vi.mock('@/lib/execution/sandbox/run-task', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - parseWorkspaceFileKey: vi.fn().mockReturnValue(undefined), + parseWorkspaceFileKey: mockParseWorkspaceFileKey, +})) + +vi.mock('@/lib/workspace-files/api', () => ({ + internalWorkspaceFileServeAuth: { authenticate: mockAuthenticateWorkspaceFile }, +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({ + readWorkspaceFileContentByKey: { execute: mockReadWorkspaceFileContentByKey }, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ + resolveServableDocBytes: mockResolveServableDocBytes, })) vi.mock('@/app/api/files/utils', () => ({ @@ -109,7 +129,27 @@ describe('File Serve API Route', () => { mockReadFile.mockResolvedValue(Buffer.from('test content')) mockIsUsingCloudStorage.mockReturnValue(false) storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) - mockInferContextFromKey.mockReturnValue('workspace') + mockInferContextFromKey.mockReturnValue('mothership') + mockParseWorkspaceFileKey.mockReturnValue(undefined) + mockAuthenticateWorkspaceFile.mockResolvedValue({ + kind: 'session', + userId: 'test-user-id', + sessionId: 'session-1', + }) + mockReadWorkspaceFileContentByKey.mockResolvedValue({ + file: { + id: 'file-1', + workspaceId: 'test-workspace-id', + name: 'report.pdf', + }, + content: Buffer.from('generated source'), + }) + mockResolveServableDocBytes.mockImplementation( + async ({ rawBuffer, fileName }: { rawBuffer: Buffer; fileName: string }) => ({ + buffer: rawBuffer, + contentType: mockGetContentType(fileName), + }) + ) mockGetContentType.mockReturnValue('text/plain') mockFindLocalFile.mockReturnValue('/test/uploads/test-file.txt') mockCreateFileResponse.mockImplementation( @@ -181,8 +221,59 @@ describe('File Serve API Route', () => { expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith({ key: 'workspace/test-workspace-id/1234567890-image.png', - context: 'workspace', + context: 'mothership', + }) + }) + + it('serves a workspace document through the authorized use case and preserves the Principal', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'test-user-id', + workspaceId: 'test-workspace-id', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2026-08-01T01:00:00Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + }, + } + mockInferContextFromKey.mockReturnValue('workspace') + mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') + mockAuthenticateWorkspaceFile.mockResolvedValue(principal) + mockResolveServableDocBytes.mockResolvedValue({ + buffer: Buffer.from('%PDF-compiled'), + contentType: 'application/pdf', }) + + const req = new NextRequest( + 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf' + ) + const response = await GET(req, { + params: Promise.resolve({ + path: ['workspace', 'test-workspace-id', 'report.pdf'], + }), + }) + + expect(response.status).toBe(200) + expect(mockReadWorkspaceFileContentByKey).toHaveBeenCalledWith({ + principal, + input: { + key: 'workspace/test-workspace-id/report.pdf', + assertedWorkspaceId: 'test-workspace-id', + }, + request: req, + }) + expect(mockResolveServableDocBytes).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'test-workspace-id', + filePrincipal: principal, + }) + ) + expect(hybridAuthMockFns.mockCheckSessionOrInternalAuth).not.toHaveBeenCalled() + expect(mockVerifyFileAccess).not.toHaveBeenCalled() }) it('should return 404 when file not found', async () => { diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 7ba54de8e06..0899cdd0dfa 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,11 +1,17 @@ import { readFile } from 'fs/promises' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer' +import { + concealCrossTenantResourceError, + InternalUnauthenticatedError, +} from '@/lib/api/server/routes' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { resolveServableDocBytes } from '@/lib/copilot/tools/server/files/doc-compile' import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' @@ -13,6 +19,8 @@ import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspac import { downloadFile } from '@/lib/uploads/core/storage-service' import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api' +import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key' import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, @@ -66,9 +74,11 @@ async function resolveServableBytes(params: { workspaceId: string | undefined options: ServeOptions ownerKey: string | undefined + filePrincipal?: Principal signal: AbortSignal | undefined }): Promise<{ buffer: Buffer; contentType: string }> { - const { buffer, filename, storageKey, workspaceId, options, ownerKey, signal } = params + const { buffer, filename, storageKey, workspaceId, options, ownerKey, filePrincipal, signal } = + params if (options.raw) return { buffer, contentType: getContentType(filename) } if (options.preview) { @@ -82,6 +92,7 @@ async function resolveServableBytes(params: { rawBuffer: buffer, fileName: filename, workspaceId, + filePrincipal, ownerKey, signal, }) @@ -154,6 +165,23 @@ export const GET = withRouteHandler( return await handleLocalFilePublic(fullPath) } + const storageContext = inferContextFromKey(cloudKey) + const workspacePrincipal = + storageContext === 'workspace' + ? await internalWorkspaceFileServeAuth.authenticate(request, { path }) + : undefined + const legacyAuthResult = workspacePrincipal + ? undefined + : await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + + if (legacyAuthResult && (!legacyAuthResult.success || !legacyAuthResult.userId)) { + logger.warn('Unauthorized file access attempt', { + path, + error: legacyAuthResult.error || 'Missing userId', + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const query = fileServeQuerySchema.parse({ raw: request.nextUrl.searchParams.get('raw'), preview: request.nextUrl.searchParams.get('preview'), @@ -165,17 +193,12 @@ export const GET = withRouteHandler( versioned: query.v != null, } - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - - if (!authResult.success || !authResult.userId) { - logger.warn('Unauthorized file access attempt', { - path, - error: authResult.error || 'Missing userId', - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (workspacePrincipal) { + return await handleWorkspaceFile(cloudKey, workspacePrincipal, options, request) } - const userId = authResult.userId + const userId = legacyAuthResult?.userId + if (!userId) throw new Error('Authenticated file serve request is missing a user ID') if (isUsingCloudStorage()) { return await handleCloudProxy(cloudKey, userId, options, request.signal) @@ -183,6 +206,11 @@ export const GET = withRouteHandler( return await handleLocalFile(cloudKey, userId, options, request.signal) } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + logger.warn('Unauthorized file access attempt', { error: error.message }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + // An in-progress/incomplete doc source fails to compile — this is expected // mid-generation, not a server fault. Return 409 (not 500) so it isn't an // alarming error; the client re-fetches once the doc finishes (the serve @@ -194,6 +222,15 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 }) } + const orchestrationError = asOrchestrationError( + concealCrossTenantResourceError(error, 'File not found') + ) + if (orchestrationError?.code === 'not_found') { + const notFound = new FileNotFoundError('File not found') + logServeFailure('Error serving file:', notFound) + return createErrorResponse(notFound) + } + logServeFailure('Error serving file:', error) if (error instanceof FileNotFoundError) { @@ -205,6 +242,45 @@ export const GET = withRouteHandler( } ) +async function handleWorkspaceFile( + key: string, + principal: Principal, + options: ServeOptions, + request: NextRequest +): Promise { + const workspaceId = getWorkspaceIdForCompile(key) + if (!workspaceId) throw new FileNotFoundError(`File not found: ${key}`) + + const { file, content } = await readWorkspaceFileContentByKey.execute({ + principal, + input: { key, assertedWorkspaceId: workspaceId }, + request, + }) + const ownerKey = `user:${requirePrincipalSubjectUserId(principal)}` + const resolved = await resolveServableBytes({ + buffer: content, + filename: file.name, + storageKey: key, + workspaceId, + options, + ownerKey, + filePrincipal: principal, + signal: request.signal, + }) + + logger.info('Workspace file served', { + fileId: file.id, + workspaceId, + size: resolved.buffer.length, + }) + return createFileResponse({ + buffer: resolved.buffer, + contentType: resolved.contentType, + filename: file.name, + cacheControl: resolveServeCacheControl(options.versioned, 'workspace'), + }) +} + async function handleLocalFile( filename: string, userId: string, diff --git a/apps/sim/app/api/v1/auth.test.ts b/apps/sim/app/api/v1/auth.test.ts new file mode 100644 index 00000000000..90f0f2f1d76 --- /dev/null +++ b/apps/sim/app/api/v1/auth.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticateApiKey: vi.fn(), + updateLastUsed: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isAuthDisabled: false })) +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: mocks.authenticateApiKey, + updateApiKeyLastUsed: mocks.updateLastUsed, +})) + +import { authenticateV1Request } from '@/app/api/v1/auth' + +describe('v1 API key authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs a personal API-key Principal from canonical key identity', async () => { + mocks.authenticateApiKey.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + keyType: 'personal', + }) + + await expect( + authenticateV1Request( + new NextRequest('http://localhost/api/v1/files', { + headers: { 'x-api-key': 'secret' }, + }) + ) + ).resolves.toMatchObject({ + authenticated: true, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }) + }) + + it('constructs a workspace API-key Principal without borrowing the creator identity', async () => { + mocks.authenticateApiKey.mockResolvedValue({ + success: true, + userId: 'creator-1', + keyId: 'key-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + + const result = await authenticateV1Request( + new NextRequest('http://localhost/api/v1/files', { + headers: { 'x-api-key': 'secret' }, + }) + ) + + expect(result.principal).toEqual({ + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }) + expect(result.principal).not.toHaveProperty('userId') + }) + + it('fails closed when authenticated key identity is incomplete', async () => { + mocks.authenticateApiKey.mockResolvedValue({ + success: true, + userId: 'creator-1', + keyId: 'key-1', + keyType: 'workspace', + }) + + await expect( + authenticateV1Request( + new NextRequest('http://localhost/api/v1/files', { + headers: { 'x-api-key': 'secret' }, + }) + ) + ).resolves.toEqual({ authenticated: false, error: 'Authentication failed' }) + expect(mocks.updateLastUsed).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v1/auth.ts b/apps/sim/app/api/v1/auth.ts index 0f391889005..78c68e1f9dd 100644 --- a/apps/sim/app/api/v1/auth.ts +++ b/apps/sim/app/api/v1/auth.ts @@ -1,3 +1,4 @@ +import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' @@ -11,6 +12,7 @@ export interface AuthResult { userId?: string workspaceId?: string keyType?: 'personal' | 'workspace' + principal?: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal error?: string } @@ -20,6 +22,11 @@ export async function authenticateV1Request(request: NextRequest): Promise ({ mockCheckRateLimit: vi.fn(), mockValidateWorkspaceAccess: vi.fn(), mockGetWorkspaceFile: vi.fn(), - mockFetchServableWorkspaceFileBuffer: vi.fn(), + mockDownloadWorkspaceFileStream: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, createRateLimitResponse: () => new Response('rate limited', { status: 429 }), + requireRateLimitPrincipal: (rateLimit: { principal: unknown }) => rateLimit.principal, validateWorkspaceAccess: mockValidateWorkspaceAccess, v1ValidationErrorResponse: (e: { issues: unknown[] }) => NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ getWorkspaceFile: mockGetWorkspaceFile, - fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, +})) +vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ + downloadWorkspaceFileStream: { execute: mockDownloadWorkspaceFileStream }, })) vi.mock('@/lib/workspace-files/orchestration', () => ({ performDeleteWorkspaceFileItems: vi.fn(), @@ -37,7 +40,7 @@ vi.mock('@sim/audit', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v1/files/[fileId]/route' const WORKSPACE_ID = 'ws-1' @@ -45,6 +48,11 @@ const FILE_ID = 'file-1' const context = { params: Promise.resolve({ fileId: FILE_ID }) } const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +const PRINCIPAL = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} function request() { return createMockRequest( @@ -71,16 +79,31 @@ function generatedDocument(name = 'report.docx') { } } +function renderedDownload(buffer: Buffer) { + return { + file: generatedDocument(), + stream: new ReadableStream({ + start(controller) { + controller.enqueue(buffer) + controller.close() + }, + }), + contentLength: buffer.length, + contentType: DOCX_MIME, + } +} + describe('v1 file download', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'user-1', + principal: PRINCIPAL, + }) mockValidateWorkspaceAccess.mockResolvedValue(null) mockGetWorkspaceFile.mockResolvedValue(generatedDocument()) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKrendered'), - contentType: DOCX_MIME, - }) + mockDownloadWorkspaceFileStream.mockResolvedValue(renderedDownload(Buffer.from('PKrendered'))) }) it('serves the rendered bytes and the rendered content type', async () => { @@ -104,10 +127,7 @@ describe('v1 file download', () => { it('reports Content-Length from the rendered bytes, not the declared source size', async () => { const rendered = Buffer.alloc(50_000) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: rendered, - contentType: DOCX_MIME, - }) + mockDownloadWorkspaceFileStream.mockResolvedValue(renderedDownload(rendered)) const response = await GET(request(), context) @@ -115,8 +135,8 @@ describe('v1 file download', () => { }) it('returns a retryable 409 while the artifact is still compiling', async () => { - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new DocCompileUserError('Document is still being generated') + mockDownloadWorkspaceFileStream.mockRejectedValue( + new OrchestrationError('conflict', 'Document is still being generated') ) const response = await GET(request(), context) @@ -127,11 +147,17 @@ describe('v1 file download', () => { }) it('404s a file that does not exist', async () => { - mockGetWorkspaceFile.mockResolvedValue(null) + mockDownloadWorkspaceFileStream.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) const response = await GET(request(), context) expect(response.status).toBe(404) - expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled() + expect(mockDownloadWorkspaceFileStream).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), + }) }) }) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 1e9b084e680..eb767ac5f3c 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -1,20 +1,18 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files' import { parseRequest } from '@/lib/api/server' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { - fetchServableWorkspaceFileBuffer, - getWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' -import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/doc-not-ready' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { checkRateLimit, createRateLimitResponse, + requireRateLimitPrincipal, v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -38,7 +36,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1DownloadFileContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -47,64 +44,43 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo const { fileId } = parsed.data.params const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) - if (accessError) return accessError - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) + const principal = requireRateLimitPrincipal(rateLimit) + const { file, stream, contentLength, contentType } = await downloadWorkspaceFileStream.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + request, + }) + if (principal.kind === 'personal_api_key') { + captureServerEvent( + principal.userId, + 'file_downloaded', + { workspace_id: workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: workspaceId } } + ) } - // Generated docs store their generation source; serve the rendered artifact. - // Its content type is the rendered one, not the source MIME on the record. - const { buffer, contentType } = await fetchServableWorkspaceFileBuffer(fileRecord) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: fileRecord.id, - resourceName: fileRecord.name, - description: `Downloaded file "${fileRecord.name}" via API`, - metadata: { - fileId: fileRecord.id, - fileName: fileRecord.name, - bytes: buffer.length, - source: 'api_v1', + return new Response(stream, { + status: 200, + headers: { + 'Content-Type': contentType || file.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, + 'Content-Length': String(contentLength), + 'X-File-Id': file.id, + 'X-File-Name': encodeURIComponent(file.name), + 'X-Uploaded-At': + file.uploadedAt instanceof Date ? file.uploadedAt.toISOString() : String(file.uploadedAt), }, - request, }) - captureServerEvent( - userId, - 'file_downloaded', - { workspace_id: workspaceId, is_bulk: false, file_count: 1 }, - { groups: { workspace: workspaceId } } - ) - - // View, not copy — a second full copy would double peak memory for a large file. - return new Response( - new Uint8Array(buffer.buffer as ArrayBuffer, buffer.byteOffset, buffer.byteLength), - { - status: 200, - headers: { - 'Content-Type': contentType || fileRecord.type || 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, - 'Content-Length': String(buffer.length), - 'X-File-Id': fileRecord.id, - 'X-File-Name': encodeURIComponent(fileRecord.name), - 'X-Uploaded-At': - fileRecord.uploadedAt instanceof Date - ? fileRecord.uploadedAt.toISOString() - : String(fileRecord.uploadedAt), - }, - } - ) } catch (error) { - // A generated doc whose artifact is still compiling is retryable, not a fault: - // without this the caller sees a 500 and has no reason to try again. - if (isDocNotReadyError(error)) { - return NextResponse.json({ error: docNotReadyMessage() }, { status: 409 }) + const orchestrationError = asOrchestrationError(error) + if (orchestrationError && orchestrationError.code !== 'internal') { + return NextResponse.json( + { + error: + orchestrationError.code === 'not_found' ? 'File not found' : orchestrationError.message, + }, + { status: statusForOrchestrationError(orchestrationError.code) } + ) } logger.error(`[${requestId}] Error downloading file:`, error) return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 94c0790b274..2a8e598a7b6 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -61,6 +61,7 @@ describe('checkRateLimit', () => { authenticated: true, userId: 'user-1', keyType: 'personal', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, }) mockGetSubscription.mockResolvedValue({ plan: 'team' }) mockGetRateLimit.mockReturnValue(TEAM_BUCKET) @@ -78,6 +79,16 @@ describe('checkRateLimit', () => { expect(result.limit).not.toBe(TEAM_BUCKET.refillRate) }) + it('preserves the authenticated API-key Principal for application operations', async () => { + const result = await checkRateLimit(request(), 'workflows') + + expect(result.principal).toEqual({ + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + }) + }) + it('never reports more remaining than the limit', async () => { const result = await checkRateLimit(request(), 'workflows') @@ -196,6 +207,7 @@ describe('rate-limit snapshot context', () => { authenticated: true, userId: 'user-1', keyType: 'personal', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, }) mockGetSubscription.mockResolvedValue({ plan: 'team' }) mockGetRateLimit.mockReturnValue(TEAM_BUCKET) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 6a9db50ec44..3f8d4878119 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -1,3 +1,4 @@ +import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { type NextRequest, NextResponse } from 'next/server' @@ -63,6 +64,7 @@ export interface RateLimitResult { userId?: string workspaceId?: string keyType?: 'personal' | 'workspace' + principal?: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal error?: string } @@ -82,6 +84,18 @@ export function requireRateLimitUserId(rateLimit: RateLimitResult): string { return rateLimit.userId } +export function requireRateLimitPrincipal( + rateLimit: RateLimitResult +): PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal { + if (!rateLimit.allowed) { + throw new Error('Cannot authorize a denied public API request') + } + if (!rateLimit.principal) { + throw new Error('Allowed public API request is missing its Principal') + } + return rateLimit.principal +} + export async function checkRateLimit( request: NextRequest, endpoint: ApiEndpoint = 'logs' @@ -144,6 +158,7 @@ export async function checkRateLimit( userId, workspaceId: auth.workspaceId, keyType: auth.keyType, + principal: auth.principal, } } catch (error) { logger.error('Rate limit check error', { error }) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 0717900344a..7501d3355ba 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -84,12 +84,14 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ })) vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, findWorkspaceFileRecord: mockFindWorkspaceFileRecord, getSandboxWorkspaceFilePath: mockGetSandboxWorkspaceFilePath, listWorkspaceFiles: mockListWorkspaceFiles, })) +vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({ + fetchAuthorizedServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ listWorkspaceFileFolders: mockListWorkspaceFileFolders, })) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 01773a65f00..a10c8365d27 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -30,7 +30,6 @@ import { queryRows } from '@/lib/table/rows/service' import { getTableById, listTables } from '@/lib/table/service' import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache' import { - fetchServableWorkspaceFileBuffer, findWorkspaceFileRecord, getSandboxWorkspaceFilePath, type WorkspaceFileRecord, @@ -42,6 +41,7 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' @@ -202,7 +202,7 @@ async function pushWorkspaceFileMount( } const { buffer, contentType } = rendersFromSource - ? await fetchServableWorkspaceFileBuffer(record, { + ? await fetchAuthorizedServableWorkspaceFileBuffer(record, principal, { maxBytes: Math.min(MAX_FILE_SIZE, remainingBudget), }).catch((error) => { if (!isPayloadSizeLimitError(error)) throw error diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts index 7e79ab14de6..734761a5c02 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts @@ -8,6 +8,7 @@ const { executeInSandboxMock, executeShellInSandboxMock, loadCompiledDocMock, + publishCompiledDocArtifactMock, readWorkspaceFileContentMock, readWorkspaceFileMetadataMock, storeCompiledDocMock, @@ -15,6 +16,7 @@ const { executeInSandboxMock: vi.fn(), executeShellInSandboxMock: vi.fn(), loadCompiledDocMock: vi.fn(), + publishCompiledDocArtifactMock: vi.fn(), readWorkspaceFileContentMock: vi.fn(), readWorkspaceFileMetadataMock: vi.fn(), storeCompiledDocMock: vi.fn(), @@ -35,6 +37,8 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => })) vi.mock('./doc-compiled-store', () => ({ loadCompiledDoc: loadCompiledDocMock, + loadPublishedCompiledDoc: vi.fn(), + publishCompiledDocArtifact: publishCompiledDocArtifactMock, storeCompiledDoc: storeCompiledDocMock, })) @@ -245,5 +249,11 @@ describe('collectReferencedFileIds', () => { ) expect(readWorkspaceFileContentMock).not.toHaveBeenCalled() expect(executeInSandboxMock).not.toHaveBeenCalled() + expect(publishCompiledDocArtifactMock).toHaveBeenCalledWith( + 'workspace-1', + `image = await getFileBase64('${ID}')`, + 'pdf', + expect.stringContaining(ID) + ) }) }) diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index cb5c3f25a64..b6dafb6826b 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -2,6 +2,12 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { + loadCompiledDoc, + loadPublishedCompiledDoc, + publishCompiledDocArtifact, + storeCompiledDoc, +} from '@/lib/copilot/tools/server/files/doc-compiled-store' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { CodeLanguage } from '@/lib/execution/languages' import { @@ -15,7 +21,6 @@ import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { getContentType } from '@/app/api/files/utils' import type { SandboxTaskId } from '@/sandbox-tasks/registry' -import { loadCompiledDoc, storeCompiledDoc } from './doc-compiled-store' const logger = createLogger('CopilotDocCompile') @@ -567,6 +572,14 @@ export async function compileDoc(args: CompileArgs): Promise referencedImages.artifactIdentity ) if (existing) { + if (referencedImages.artifactIdentity) { + await publishCompiledDocArtifact( + workspaceId, + source, + fmt.ext, + referencedImages.artifactIdentity + ) + } const contributingFiles = referencedImageIdentities(referencedImages) return { buffer: existing, @@ -588,6 +601,7 @@ export async function loadCompiledDocByExt( ext: string, options: { allowLegacyReferencedArtifact?: boolean + allowPublishedReferencedArtifact?: boolean filePrincipal?: Principal } = {} ): Promise<{ buffer: Buffer; contentType: string } | null> { @@ -599,6 +613,10 @@ export async function loadCompiledDocByExt( const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext) return buffer ? { buffer, contentType: fmt.contentType } : null } + if (options.allowPublishedReferencedArtifact) { + const publishedBuffer = await loadPublishedCompiledDoc(workspaceId, source, fmt.ext) + if (publishedBuffer) return { buffer: publishedBuffer, contentType: fmt.contentType } + } if (!options.allowLegacyReferencedArtifact) return null const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext) return legacyBuffer ? { buffer: legacyBuffer, contentType: fmt.contentType } : null @@ -655,7 +673,7 @@ export async function resolveServableDoc( workspaceId, storedBytes.toString('utf-8'), fmt.ext, - { allowLegacyReferencedArtifact: true } + { allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true } ) return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' } } catch (error) { @@ -743,7 +761,16 @@ export async function resolveServableDocBytes(args: { return compileDocInLegacySandbox({ source, fileName, workspaceId, ownerKey, signal }, fmt) } const referencedFileIds = collectReferencedFileIds(source) - if (referencedFileIds.size > 0 && filePrincipal) { + if (referencedFileIds.size > 0) { + if (!filePrincipal) { + const published = await loadCompiledDocByExt(workspaceId, source, extNoDot, { + allowPublishedReferencedArtifact: true, + }) + if (published) return published + throw new Error( + 'Referenced document resolution requires an authorized workspace file principal' + ) + } return compileDoc({ source, fileName, workspaceId, filePrincipal, ownerKey, signal }) } const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot, { diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts new file mode 100644 index 00000000000..3c9a6e6d3ab --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDownloadFile, mockHeadObject, mockUploadFile } = vi.hoisted(() => ({ + mockDownloadFile: vi.fn(), + mockHeadObject: vi.fn(), + mockUploadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mockDownloadFile, + headObject: mockHeadObject, + uploadFile: mockUploadFile, +})) + +import { + loadPublishedCompiledDoc, + storeCompiledDoc, +} from '@/lib/copilot/tools/server/files/doc-compiled-store' + +describe('compiled document publication', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHeadObject.mockResolvedValue(null) + }) + + it('publishes a source-keyed pointer after storing a dependency-bound artifact', async () => { + await storeCompiledDoc( + 'workspace-1', + 'source', + 'pdf', + 'application/pdf', + Buffer.from('%PDF-artifact'), + 'dependency-identity' + ) + + expect(mockUploadFile).toHaveBeenCalledTimes(2) + expect(mockUploadFile).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + file: Buffer.from( + JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }) + ), + contentType: 'application/json', + context: 'copilot', + preserveKey: true, + }) + ) + }) + + it('loads only the exact dependency-bound artifact named by the published pointer', async () => { + mockHeadObject.mockResolvedValue({ size: 1 }) + mockDownloadFile + .mockResolvedValueOnce( + Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' })) + ) + .mockResolvedValueOnce(Buffer.from('%PDF-artifact')) + + await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).resolves.toEqual( + Buffer.from('%PDF-artifact') + ) + expect(mockDownloadFile).toHaveBeenCalledTimes(2) + expect(mockDownloadFile.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ context: 'copilot' }) + ) + }) + + it('fails fast on a malformed published pointer', async () => { + mockHeadObject.mockResolvedValue({ size: 1 }) + mockDownloadFile.mockResolvedValueOnce(Buffer.from('{not-json')) + + await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow( + 'Published compiled document pointer is malformed' + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts index b25921868a5..dc57f549f0b 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service' const logger = createLogger('CopilotDocCompiledStore') @@ -30,6 +30,41 @@ function compiledArtifactKey( return `copilot-doc-compiled/${workspaceId}/${hash}.${ext}` } +function publishedArtifactPointerKey(workspaceId: string, source: string, ext: string): string { + const sourceHash = createHash('sha256').update(source, 'utf-8').digest('hex') + return `copilot-doc-compiled/${workspaceId}/${sourceHash}.${ext}.published.json` +} + +interface PublishedArtifactPointer { + version: 1 + referencedInputIdentity: string +} + +async function loadPublishedArtifactPointer(key: string): Promise { + const stored = await headObject(key, 'copilot') + if (!stored) return null + const encoded = await downloadFile({ key, context: 'copilot' }) + + let decoded: unknown + try { + decoded = JSON.parse(encoded.toString('utf-8')) + } catch { + throw new Error(`Published compiled document pointer is malformed: ${key}`) + } + if ( + typeof decoded !== 'object' || + decoded === null || + !('version' in decoded) || + decoded.version !== 1 || + !('referencedInputIdentity' in decoded) || + typeof decoded.referencedInputIdentity !== 'string' || + !decoded.referencedInputIdentity + ) { + throw new Error(`Published compiled document pointer is invalid: ${key}`) + } + return { version: 1, referencedInputIdentity: decoded.referencedInputIdentity } +} + /** Loads the compiled binary for the current source, or null if not yet built. */ export async function loadCompiledDoc( workspaceId: string, @@ -45,6 +80,56 @@ export async function loadCompiledDoc( } } +/** + * Publishes the exact dependency-bound artifact that was produced under an authorized Principal. + * Public shares consume this pointer without gaining authority to read the referenced workspace + * files or compile arbitrary source themselves. + */ +export async function publishCompiledDocArtifact( + workspaceId: string, + source: string, + ext: string, + referencedInputIdentity: string +): Promise { + if (!referencedInputIdentity) { + throw new Error('Published compiled document identity must not be empty') + } + const key = publishedArtifactPointerKey(workspaceId, source, ext) + const existing = await loadPublishedArtifactPointer(key) + if (existing?.referencedInputIdentity === referencedInputIdentity) return + const pointer: PublishedArtifactPointer = { version: 1, referencedInputIdentity } + try { + await uploadFile({ + file: Buffer.from(JSON.stringify(pointer), 'utf-8'), + fileName: `doc.${ext}.published.json`, + contentType: 'application/json', + context: 'copilot', + customKey: key, + preserveKey: true, + }) + } catch (error) { + logger.error('Failed to publish compiled doc artifact', { + key, + error: getErrorMessage(error), + }) + throw toError(error) + } +} + +/** Loads an artifact previously published by an authorized compile, or null before cutover. */ +export async function loadPublishedCompiledDoc( + workspaceId: string, + source: string, + ext: string +): Promise { + const key = publishedArtifactPointerKey(workspaceId, source, ext) + const pointer = await loadPublishedArtifactPointer(key) + if (!pointer) return null + const artifact = await loadCompiledDoc(workspaceId, source, ext, pointer.referencedInputIdentity) + if (!artifact) throw new Error(`Published compiled document artifact is missing: ${key}`) + return artifact +} + /** * Stores the compiled binary as the source's associated S3 artifact. * @@ -71,6 +156,9 @@ export async function storeCompiledDoc( customKey: key, preserveKey: true, }) + if (referencedInputIdentity) { + await publishCompiledDocArtifact(workspaceId, source, ext, referencedInputIdentity) + } } catch (err) { logger.error('Failed to store compiled doc artifact', { key, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index d7b4081a700..336ebe43491 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -7,6 +7,8 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteInSandbox, mockLoadCompiledDoc, + mockLoadPublishedCompiledDoc, + mockPublishCompiledDocArtifact, mockReadWorkspaceFileContent, mockReadWorkspaceFileMetadata, mockRunSandboxTask, @@ -14,6 +16,8 @@ const { } = vi.hoisted(() => ({ mockExecuteInSandbox: vi.fn(), mockLoadCompiledDoc: vi.fn(), + mockLoadPublishedCompiledDoc: vi.fn(), + mockPublishCompiledDocArtifact: vi.fn(), mockReadWorkspaceFileContent: vi.fn(), mockReadWorkspaceFileMetadata: vi.fn(), mockRunSandboxTask: vi.fn(), @@ -38,6 +42,8 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => })) vi.mock('./doc-compiled-store', () => ({ loadCompiledDoc: mockLoadCompiledDoc, + loadPublishedCompiledDoc: mockLoadPublishedCompiledDoc, + publishCompiledDocArtifact: mockPublishCompiledDocArtifact, storeCompiledDoc: mockStoreCompiledDoc, })) vi.mock('@/app/api/files/utils', () => ({ @@ -72,6 +78,7 @@ describe('resolveServableDocBytes', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isDocSandboxEnabled: true }) + mockLoadPublishedCompiledDoc.mockResolvedValue(null) }) it('swaps generated-doc source for the compiled artifact + binary content type', async () => { @@ -201,6 +208,65 @@ describe('resolveServableDocBytes', () => { expect(mockStoreCompiledDoc).not.toHaveBeenCalled() }) + it('serves the dependency-bound artifact published by an authorized compile', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + const publishedArtifact = Buffer.from('%PDF-published') + mockLoadPublishedCompiledDoc.mockResolvedValue(publishedArtifact) + + await expect(resolveServableDoc(WORKSPACE_ID, source, 'report.pdf')).resolves.toEqual({ + kind: 'artifact', + buffer: publishedArtifact, + contentType: 'application/pdf', + }) + expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith( + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf' + ) + expect(mockLoadCompiledDoc).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() + }) + + it('fails fast when a private referenced document loses its Principal', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + + await expect( + resolveServableDocBytes({ + rawBuffer: source, + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + ).rejects.toThrow( + 'Referenced document resolution requires an authorized workspace file principal' + ) + expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith( + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf' + ) + expect(mockLoadCompiledDoc).not.toHaveBeenCalled() + }) + + it('lets a principal-less private adapter use output published by an authorized compile', async () => { + const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') + const publishedArtifact = Buffer.from('%PDF-published') + mockLoadPublishedCompiledDoc.mockResolvedValue(publishedArtifact) + + await expect( + resolveServableDocBytes({ + rawBuffer: source, + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + ).resolves.toEqual({ + buffer: publishedArtifact, + contentType: 'application/pdf', + }) + expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() + expect(mockLoadCompiledDoc).not.toHaveBeenCalled() + }) + it('does not apply model-only provenance policy while serving a public legacy artifact', async () => { const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8') const legacyArtifact = Buffer.from('%PDF-legacy') diff --git a/apps/sim/lib/uploads/utils/file-utils.server.test.ts b/apps/sim/lib/uploads/utils/file-utils.server.test.ts index 19f8a4dea71..6cae5e88cfa 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.test.ts @@ -81,10 +81,13 @@ describe('downloadFileFromStorage context derivation', () => { context: 'execution', } - await downloadServableFileFromStorage(userFile, 'req-1', createLogger('test')) + const filePrincipal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + await downloadServableFileFromStorage(userFile, 'req-1', createLogger('test'), { + filePrincipal, + }) expect(mockResolveServableDocBytes).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId }) + expect.objectContaining({ workspaceId, filePrincipal }) ) }) }) diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index ca8fa11bcb1..34d91991a45 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger, type Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' @@ -403,7 +404,12 @@ export async function downloadServableFileFromStorage( userFile: UserFile, requestId: string, logger: Logger, - options: { maxBytes?: number; signal?: AbortSignal; ownerKey?: string } = {} + options: { + maxBytes?: number + signal?: AbortSignal + ownerKey?: string + filePrincipal?: Principal + } = {} ): Promise { const buffer = await downloadFileFromStorage(userFile, requestId, logger, { maxBytes: options.maxBytes, @@ -430,6 +436,7 @@ export async function downloadServableFileFromStorage( rawBuffer: buffer, fileName: userFile.name, workspaceId, + filePrincipal: options.filePrincipal, ownerKey: options.ownerKey, signal: options.signal, }) diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts index dd0b8f04359..f4c93a1ad9a 100644 --- a/apps/sim/lib/workspace-files/api/index.ts +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -3,5 +3,6 @@ export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-er export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters' export { internalSessionOrExecutorAuth, + internalWorkspaceFileServeAuth, v2FileErrorPolicies, } from '@/lib/workspace-files/api/route-policies' diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index 49de1bbbdba..4698ba8afc4 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -14,6 +14,15 @@ export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth }, }) +/** + * Generated-document serving authorizes the root file and every referenced input. Its executor + * Principal is therefore intentionally workspace-scoped; a file-scoped Principal could authorize + * the root or one dependency, but never the full dependency graph. + */ +export const internalWorkspaceFileServeAuth = createInternalSessionOrExecutorAuth({ + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, +}) + export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, concealResourceAuthorization: createV2ResourceConcealmentPolicy({ diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index fd017ba1b02..57f270b34f7 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -30,11 +30,13 @@ const { vi.mock('@/lib/uploads/contexts/workspace', () => ({ buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; path?: string; name: string }>) => new Map(folders.map((folder) => [folder.id, folder.path ?? folder.name])), - fetchServableWorkspaceFileBuffer: mockFetchServable, listWorkspaceFileFolders: mockListFolders, listWorkspaceFiles: mockListFiles, loadWorkspaceFileOperationContext: mockLoadContext, })) +vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({ + fetchAuthorizedServableWorkspaceFileBuffer: mockFetchServable, +})) vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (actual: string | null, required: string) => actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), @@ -161,9 +163,11 @@ describe('downloadWorkspaceFileItems', () => { expect(result.filesToZip[0].id).toBe('f1') expect(result.renderedDocuments.get('f1')).toEqual(Buffer.from('rendered')) - expect(mockFetchServable).toHaveBeenCalledWith(expect.objectContaining({ id: 'f1' }), { - maxBytes: 50 * 1024 * 1024, - }) + expect(mockFetchServable).toHaveBeenCalledWith( + expect.objectContaining({ id: 'f1' }), + principal, + { maxBytes: 50 * 1024 * 1024 } + ) }) it('returns typed validation and conflict failures without recording audit', async () => { diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index afbe5e026ef..7291fc7ec5a 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -1,9 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { buildWorkspaceFileFolderPathMap, - fetchServableWorkspaceFileBuffer, listWorkspaceFileFolders, listWorkspaceFiles, loadWorkspaceFileOperationContext, @@ -16,6 +16,7 @@ import { needsRenderedArtifact, } from '@/lib/uploads/utils/file-utils' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { fileOperations } from '@/lib/workspace-files/application/operations' export const MAX_ZIP_DOWNLOAD_FILES = 100 @@ -61,10 +62,12 @@ function validationError(message: string): never { async function executeDownloadWorkspaceFileItems({ input, context, -}: { - input: DownloadWorkspaceFileItemsInput - context: Awaited> -}): Promise { + principal, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileItemsInput, + Awaited> +>): Promise { const fileIds = [...new Set(input.fileIds)] const folderIds = [...new Set(input.folderIds)] if (fileIds.length > MAX_REQUESTED_FILE_IDS) { @@ -118,7 +121,9 @@ async function executeDownloadWorkspaceFileItems({ const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) try { - const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance }) + const { buffer } = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, { + maxBytes: allowance, + }) renderedBytes += buffer.length renderedDocuments.set(file.id, buffer) } catch (error) { diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts index 0b563b59afe..a8e950e2df1 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts @@ -27,11 +27,14 @@ vi.mock('@sim/platform-authz/workspace', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchServableWorkspaceFileBuffer: mocks.fetchServable, getWorkspaceFile: mocks.getFile, loadActiveWorkspaceFileContext: mocks.loadContext, })) +vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({ + fetchAuthorizedServableWorkspaceFileBuffer: mocks.fetchServable, +})) + vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFileStream: mocks.downloadStream, })) @@ -194,6 +197,7 @@ describe('workspace file downloads', () => { expect(mocks.fetchServable).toHaveBeenCalledWith( generatedDoc, + SESSION, expect.objectContaining({ maxBytes: MAX_RENDERED_DOCUMENT_BYTES }) ) }) @@ -202,7 +206,7 @@ describe('workspace file downloads', () => { it('routes a record with no content type by its extension', async () => { await downloadStream('file-1') - expect(mocks.fetchServable).toHaveBeenCalledWith(file, expect.anything()) + expect(mocks.fetchServable).toHaveBeenCalledWith(file, SESSION, expect.anything()) expect(mocks.downloadStream).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts index 01a2fa507af..0d599f8bd23 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -5,7 +5,6 @@ import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { type ActiveWorkspaceFileContext, - fetchServableWorkspaceFileBuffer, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFileStream } from '@/lib/uploads/core/storage-service' @@ -16,6 +15,7 @@ import { needsRenderedArtifact, } from '@/lib/uploads/utils/file-utils' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' @@ -93,9 +93,16 @@ export const downloadWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ * artifact that never appears. It stays a `conflict` only because the v2 * envelope has no 422; the message is what distinguishes the two. */ -async function resolveRenderedArtifact(file: DownloadWorkspaceFileResult['file']) { +async function resolveRenderedArtifact( + file: DownloadWorkspaceFileResult['file'], + filePrincipal: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileInput, + ActiveWorkspaceFileContext + >['principal'] +) { try { - return await fetchServableWorkspaceFileBuffer(file, { + return await fetchAuthorizedServableWorkspaceFileBuffer(file, filePrincipal, { maxBytes: MAX_RENDERED_DOCUMENT_BYTES, }) } catch (error) { @@ -118,6 +125,7 @@ async function resolveRenderedArtifact(file: DownloadWorkspaceFileResult['file'] async function executeDownloadWorkspaceFileStream({ context, + principal, }: AuthorizedWorkspaceUseCaseContext< typeof fileOperations.download, DownloadWorkspaceFileInput, @@ -138,7 +146,7 @@ async function executeDownloadWorkspaceFileStream({ * double peak memory. */ if (needsRenderedArtifact(file.type, file.name)) { - const { buffer, contentType } = await resolveRenderedArtifact(file) + const { buffer, contentType } = await resolveRenderedArtifact(file, principal) return { file, stream: new ReadableStream({ diff --git a/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts b/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts new file mode 100644 index 00000000000..f7456f44d73 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts @@ -0,0 +1,35 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { generateRequestId } from '@/lib/core/utils/request' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' + +const logger = createLogger('FetchServableWorkspaceFileBuffer') + +/** + * Resolves the rendered bytes of an already-authorized workspace file while preserving the + * Principal for any referenced-file reads performed by the document compiler. + */ +export async function fetchAuthorizedServableWorkspaceFileBuffer( + fileRecord: WorkspaceFileRecord, + filePrincipal: Principal, + options: { maxBytes?: number; signal?: AbortSignal; requestId?: string } = {} +): Promise<{ buffer: Buffer; contentType: string }> { + return downloadServableFileFromStorage( + { + id: fileRecord.id, + name: fileRecord.name, + url: fileRecord.url ?? fileRecord.path, + size: fileRecord.size, + type: fileRecord.type, + key: fileRecord.key, + context: fileRecord.storageContext ?? 'workspace', + }, + options.requestId ?? generateRequestId(), + logger, + { + ...options, + filePrincipal, + } + ) +} diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts new file mode 100644 index 00000000000..39943c138e4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchContent: vi.fn(), + getFile: vi.fn(), + getMetadata: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchContent, + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + getFileMetadataByKey: mocks.getMetadata, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key' + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', + key: 'workspace/workspace-1/report.pdf', + path: '/api/files/serve/workspace/workspace-1/report.pdf', + size: 6, + type: 'text/x-python-pdf', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-08-01T00:00:00Z'), + updatedAt: new Date('2026-08-01T00:00:00Z'), +} + +describe('readWorkspaceFileContentByKey', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getMetadata.mockResolvedValue({ + id: file.id, + workspaceId: file.workspaceId, + key: file.key, + context: 'workspace', + }) + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getFile.mockResolvedValue(file) + mocks.fetchContent.mockResolvedValue(Buffer.from('source')) + }) + + it('authorizes the canonical file before returning its stored bytes', async () => { + await expect( + readWorkspaceFileContentByKey.execute({ + principal, + input: { key: file.key, assertedWorkspaceId: file.workspaceId }, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.getFile).toHaveBeenCalledWith(file.workspaceId, file.id, { + throwOnError: true, + }) + expect(mocks.fetchContent).toHaveBeenCalledWith(file) + }) + + it('rejects a stale key instead of serving the file current at the same ID', async () => { + mocks.getFile.mockResolvedValue({ ...file, key: `${file.key}.new` }) + + await expect( + readWorkspaceFileContentByKey.execute({ + principal, + input: { key: file.key, assertedWorkspaceId: file.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.fetchContent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts new file mode 100644 index 00000000000..d5629ed275b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts @@ -0,0 +1,57 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + fetchWorkspaceFileBuffer, + getWorkspaceFile, + loadActiveWorkspaceFileContext, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ReadWorkspaceFileContentByKeyInput { + key: string + assertedWorkspaceId?: string +} + +export interface ReadWorkspaceFileContentByKeyResult { + file: WorkspaceFileRecord + content: Buffer +} + +async function executeReadWorkspaceFileContentByKey({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceFileContentByKeyInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file || file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + return { file, content: await fetchWorkspaceFileBuffer(file) } +} + +export const readWorkspaceFileContentByKey = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + async resolveContext({ input }) { + const metadata = await getFileMetadataByKey(input.key, 'workspace') + if ( + !metadata?.workspaceId || + (input.assertedWorkspaceId !== undefined && + input.assertedWorkspaceId !== metadata.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + const canonical = await loadActiveWorkspaceFileContext(metadata.id) + if (!canonical || canonical.workspaceId !== metadata.workspaceId) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical + }, + execute: executeReadWorkspaceFileContentByKey, +})