fix(files): preserve principals when serving generated documents (#6654)

* fix(files): preserve principals when serving documents

* fix(files): address principal serve review findings
This commit is contained in:
Theodore Li
2026-08-13 00:34:07 -04:00
committed by GitHub
parent ec70c4d85b
commit e4019fa39e
26 changed files with 926 additions and 119 deletions
@@ -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,10 +221,61 @@ 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 () => {
mockVerifyFileAccess.mockResolvedValue(false)
mockFindLocalFile.mockReturnValue(null)
+86 -10
View File
@@ -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<NextResponse> {
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,
+86
View File
@@ -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()
})
})
+31 -2
View File
@@ -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<AuthR
authenticated: true,
userId: ANONYMOUS_USER_ID,
keyType: 'personal',
principal: {
kind: 'personal_api_key',
userId: ANONYMOUS_USER_ID,
keyId: 'auth-disabled',
},
}
}
@@ -43,13 +50,35 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
}
}
await updateApiKeyLastUsed(result.keyId!)
if (!result.keyId || !result.userId || !result.keyType) {
throw new Error('Authenticated v1 API key is missing its canonical identity')
}
let principal: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal
if (result.keyType === 'workspace') {
if (!result.workspaceId) {
throw new Error('Authenticated workspace API key is missing its workspace scope')
}
principal = {
kind: 'workspace_api_key',
workspaceId: result.workspaceId,
keyId: result.keyId,
}
} else {
principal = {
kind: 'personal_api_key',
userId: result.userId,
keyId: result.keyId,
}
}
await updateApiKeyLastUsed(result.keyId)
return {
authenticated: true,
userId: result.userId!,
userId: result.userId,
workspaceId: result.workspaceId,
keyType: result.keyType,
principal,
}
} catch (error) {
logger.error('API key authentication error', { error })
@@ -8,24 +8,27 @@ const {
mockCheckRateLimit,
mockValidateWorkspaceAccess,
mockGetWorkspaceFile,
mockFetchServableWorkspaceFileBuffer,
mockDownloadWorkspaceFileStream,
} = vi.hoisted(() => ({
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<Uint8Array>({
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(),
})
})
})
+37 -61
View File
@@ -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 })
}
// 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',
},
const principal = requireRateLimitPrincipal(rateLimit)
const { file, stream, contentLength, contentType } = await downloadWorkspaceFileStream.execute({
principal,
input: { fileId, assertedWorkspaceId: workspaceId },
request,
})
captureServerEvent(
userId,
'file_downloaded',
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
{ groups: { workspace: workspaceId } }
)
if (principal.kind === 'personal_api_key') {
captureServerEvent(
principal.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),
},
}
)
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),
},
})
} 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 })
+12
View File
@@ -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)
+15
View File
@@ -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 })
@@ -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,
}))
@@ -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
@@ -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)
)
})
})
@@ -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<CompiledDocResult>
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, {
@@ -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'
)
})
})
@@ -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<PublishedArtifactPointer | null> {
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<void> {
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<Buffer | null> {
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,
@@ -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')
@@ -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 })
)
})
})
@@ -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<ServableFile> {
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,
})
@@ -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'
@@ -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({
@@ -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 () => {
@@ -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<ReturnType<typeof resolveDownloadContext>>
}): Promise<DownloadWorkspaceFileItemsResult> {
principal,
}: AuthorizedWorkspaceUseCaseContext<
typeof fileOperations.download,
DownloadWorkspaceFileItemsInput,
Awaited<ReturnType<typeof resolveDownloadContext>>
>): Promise<DownloadWorkspaceFileItemsResult> {
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) {
@@ -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()
})
@@ -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<Uint8Array>({
@@ -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,
}
)
}
@@ -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()
})
})
@@ -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<ReadWorkspaceFileContentByKeyResult> {
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,
})