Skip to content

Commit 6c64353

Browse files
fix(files): address principal serve review findings
1 parent 62b012d commit 6c64353

14 files changed

Lines changed: 281 additions & 94 deletions

File tree

apps/sim/app/api/files/serve/[...path]/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
9494
}))
9595

9696
vi.mock('@/lib/workspace-files/api', () => ({
97-
internalSessionOrExecutorAuth: { authenticate: mockAuthenticateWorkspaceFile },
97+
internalWorkspaceFileServeAuth: { authenticate: mockAuthenticateWorkspaceFile },
9898
}))
9999

100100
vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspac
1919
import { downloadFile } from '@/lib/uploads/core/storage-service'
2020
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
2121
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
22-
import { internalSessionOrExecutorAuth } from '@/lib/workspace-files/api'
22+
import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api'
2323
import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
2424
import { verifyFileAccess } from '@/app/api/files/authorization'
2525
import {
@@ -168,7 +168,7 @@ export const GET = withRouteHandler(
168168
const storageContext = inferContextFromKey(cloudKey)
169169
const workspacePrincipal =
170170
storageContext === 'workspace'
171-
? await internalSessionOrExecutorAuth.authenticate(request, { path })
171+
? await internalWorkspaceFileServeAuth.authenticate(request, { path })
172172
: undefined
173173
const legacyAuthResult = workspacePrincipal
174174
? undefined

apps/sim/app/api/v1/auth.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mocks = vi.hoisted(() => ({
9+
authenticateApiKey: vi.fn(),
10+
updateLastUsed: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/core/config/env-flags', () => ({ isAuthDisabled: false }))
14+
vi.mock('@/lib/api-key/service', () => ({
15+
authenticateApiKeyFromHeader: mocks.authenticateApiKey,
16+
updateApiKeyLastUsed: mocks.updateLastUsed,
17+
}))
18+
19+
import { authenticateV1Request } from '@/app/api/v1/auth'
20+
21+
describe('v1 API key authentication', () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks()
24+
})
25+
26+
it('constructs a personal API-key Principal from canonical key identity', async () => {
27+
mocks.authenticateApiKey.mockResolvedValue({
28+
success: true,
29+
userId: 'user-1',
30+
keyId: 'key-1',
31+
keyType: 'personal',
32+
})
33+
34+
await expect(
35+
authenticateV1Request(
36+
new NextRequest('http://localhost/api/v1/files', {
37+
headers: { 'x-api-key': 'secret' },
38+
})
39+
)
40+
).resolves.toMatchObject({
41+
authenticated: true,
42+
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
43+
})
44+
})
45+
46+
it('constructs a workspace API-key Principal without borrowing the creator identity', async () => {
47+
mocks.authenticateApiKey.mockResolvedValue({
48+
success: true,
49+
userId: 'creator-1',
50+
keyId: 'key-1',
51+
keyType: 'workspace',
52+
workspaceId: 'workspace-1',
53+
})
54+
55+
const result = await authenticateV1Request(
56+
new NextRequest('http://localhost/api/v1/files', {
57+
headers: { 'x-api-key': 'secret' },
58+
})
59+
)
60+
61+
expect(result.principal).toEqual({
62+
kind: 'workspace_api_key',
63+
workspaceId: 'workspace-1',
64+
keyId: 'key-1',
65+
})
66+
expect(result.principal).not.toHaveProperty('userId')
67+
})
68+
69+
it('fails closed when authenticated key identity is incomplete', async () => {
70+
mocks.authenticateApiKey.mockResolvedValue({
71+
success: true,
72+
userId: 'creator-1',
73+
keyId: 'key-1',
74+
keyType: 'workspace',
75+
})
76+
77+
await expect(
78+
authenticateV1Request(
79+
new NextRequest('http://localhost/api/v1/files', {
80+
headers: { 'x-api-key': 'secret' },
81+
})
82+
)
83+
).resolves.toEqual({ authenticated: false, error: 'Authentication failed' })
84+
expect(mocks.updateLastUsed).not.toHaveBeenCalled()
85+
})
86+
})

apps/sim/app/api/v1/auth.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
12
import { createLogger } from '@sim/logger'
23
import type { NextRequest } from 'next/server'
34
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
@@ -11,6 +12,7 @@ export interface AuthResult {
1112
userId?: string
1213
workspaceId?: string
1314
keyType?: 'personal' | 'workspace'
15+
principal?: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal
1416
error?: string
1517
}
1618

@@ -20,6 +22,11 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
2022
authenticated: true,
2123
userId: ANONYMOUS_USER_ID,
2224
keyType: 'personal',
25+
principal: {
26+
kind: 'personal_api_key',
27+
userId: ANONYMOUS_USER_ID,
28+
keyId: 'auth-disabled',
29+
},
2330
}
2431
}
2532

@@ -43,13 +50,35 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
4350
}
4451
}
4552

46-
await updateApiKeyLastUsed(result.keyId!)
53+
if (!result.keyId || !result.userId || !result.keyType) {
54+
throw new Error('Authenticated v1 API key is missing its canonical identity')
55+
}
56+
let principal: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal
57+
if (result.keyType === 'workspace') {
58+
if (!result.workspaceId) {
59+
throw new Error('Authenticated workspace API key is missing its workspace scope')
60+
}
61+
principal = {
62+
kind: 'workspace_api_key',
63+
workspaceId: result.workspaceId,
64+
keyId: result.keyId,
65+
}
66+
} else {
67+
principal = {
68+
kind: 'personal_api_key',
69+
userId: result.userId,
70+
keyId: result.keyId,
71+
}
72+
}
73+
74+
await updateApiKeyLastUsed(result.keyId)
4775

4876
return {
4977
authenticated: true,
50-
userId: result.userId!,
78+
userId: result.userId,
5179
workspaceId: result.workspaceId,
5280
keyType: result.keyType,
81+
principal,
5382
}
5483
} catch (error) {
5584
logger.error('API key authentication error', { error })

apps/sim/app/api/v1/files/[fileId]/route.test.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,27 @@ const {
88
mockCheckRateLimit,
99
mockValidateWorkspaceAccess,
1010
mockGetWorkspaceFile,
11-
mockFetchServableWorkspaceFileBuffer,
11+
mockDownloadWorkspaceFileStream,
1212
} = vi.hoisted(() => ({
1313
mockCheckRateLimit: vi.fn(),
1414
mockValidateWorkspaceAccess: vi.fn(),
1515
mockGetWorkspaceFile: vi.fn(),
16-
mockFetchServableWorkspaceFileBuffer: vi.fn(),
16+
mockDownloadWorkspaceFileStream: vi.fn(),
1717
}))
1818

1919
vi.mock('@/app/api/v1/middleware', () => ({
2020
checkRateLimit: mockCheckRateLimit,
2121
createRateLimitResponse: () => new Response('rate limited', { status: 429 }),
22+
requireRateLimitPrincipal: (rateLimit: { principal: unknown }) => rateLimit.principal,
2223
validateWorkspaceAccess: mockValidateWorkspaceAccess,
2324
v1ValidationErrorResponse: (e: { issues: unknown[] }) =>
2425
NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }),
2526
}))
2627
vi.mock('@/lib/uploads/contexts/workspace', () => ({
2728
getWorkspaceFile: mockGetWorkspaceFile,
28-
fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
29+
}))
30+
vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({
31+
downloadWorkspaceFileStream: { execute: mockDownloadWorkspaceFileStream },
2932
}))
3033
vi.mock('@/lib/workspace-files/orchestration', () => ({
3134
performDeleteWorkspaceFileItems: vi.fn(),
@@ -37,14 +40,19 @@ vi.mock('@sim/audit', () => ({
3740
}))
3841
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
3942

40-
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
43+
import { OrchestrationError } from '@/lib/core/orchestration/types'
4144
import { GET } from '@/app/api/v1/files/[fileId]/route'
4245

4346
const WORKSPACE_ID = 'ws-1'
4447
const FILE_ID = 'file-1'
4548
const context = { params: Promise.resolve({ fileId: FILE_ID }) }
4649

4750
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
51+
const PRINCIPAL = {
52+
kind: 'personal_api_key' as const,
53+
userId: 'user-1',
54+
keyId: 'key-1',
55+
}
4856

4957
function request() {
5058
return createMockRequest(
@@ -71,16 +79,31 @@ function generatedDocument(name = 'report.docx') {
7179
}
7280
}
7381

82+
function renderedDownload(buffer: Buffer) {
83+
return {
84+
file: generatedDocument(),
85+
stream: new ReadableStream<Uint8Array>({
86+
start(controller) {
87+
controller.enqueue(buffer)
88+
controller.close()
89+
},
90+
}),
91+
contentLength: buffer.length,
92+
contentType: DOCX_MIME,
93+
}
94+
}
95+
7496
describe('v1 file download', () => {
7597
beforeEach(() => {
7698
vi.clearAllMocks()
77-
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
99+
mockCheckRateLimit.mockResolvedValue({
100+
allowed: true,
101+
userId: 'user-1',
102+
principal: PRINCIPAL,
103+
})
78104
mockValidateWorkspaceAccess.mockResolvedValue(null)
79105
mockGetWorkspaceFile.mockResolvedValue(generatedDocument())
80-
mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
81-
buffer: Buffer.from('PKrendered'),
82-
contentType: DOCX_MIME,
83-
})
106+
mockDownloadWorkspaceFileStream.mockResolvedValue(renderedDownload(Buffer.from('PKrendered')))
84107
})
85108

86109
it('serves the rendered bytes and the rendered content type', async () => {
@@ -104,19 +127,16 @@ describe('v1 file download', () => {
104127

105128
it('reports Content-Length from the rendered bytes, not the declared source size', async () => {
106129
const rendered = Buffer.alloc(50_000)
107-
mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
108-
buffer: rendered,
109-
contentType: DOCX_MIME,
110-
})
130+
mockDownloadWorkspaceFileStream.mockResolvedValue(renderedDownload(rendered))
111131

112132
const response = await GET(request(), context)
113133

114134
expect(response.headers.get('Content-Length')).toBe(String(rendered.length))
115135
})
116136

117137
it('returns a retryable 409 while the artifact is still compiling', async () => {
118-
mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
119-
new DocCompileUserError('Document is still being generated')
138+
mockDownloadWorkspaceFileStream.mockRejectedValue(
139+
new OrchestrationError('conflict', 'Document is still being generated')
120140
)
121141

122142
const response = await GET(request(), context)
@@ -127,11 +147,17 @@ describe('v1 file download', () => {
127147
})
128148

129149
it('404s a file that does not exist', async () => {
130-
mockGetWorkspaceFile.mockResolvedValue(null)
150+
mockDownloadWorkspaceFileStream.mockRejectedValue(
151+
new OrchestrationError('not_found', 'File not found')
152+
)
131153

132154
const response = await GET(request(), context)
133155

134156
expect(response.status).toBe(404)
135-
expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled()
157+
expect(mockDownloadWorkspaceFileStream).toHaveBeenCalledWith({
158+
principal: PRINCIPAL,
159+
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID },
160+
request: expect.anything(),
161+
})
136162
})
137163
})

0 commit comments

Comments
 (0)