Skip to content

Commit da3442e

Browse files
fix(api): distinguish visible resource authorization failures (#6537)
1 parent 1c7e4e1 commit da3442e

13 files changed

Lines changed: 148 additions & 42 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ vi.mock('@/lib/users/queries', () => ({
4747
requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId)!,
4848
}))
4949

50+
import { NoWorkspaceAccessError } from '@/lib/core/application'
5051
import { OrchestrationError } from '@/lib/core/orchestration/types'
5152
import { PUT } from '@/app/api/v2/files/[fileId]/content/route'
5253

@@ -110,9 +111,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => {
110111
})
111112

112113
it('performs authenticated admission before parsing a large or malformed body', async () => {
113-
mocks.admit.mockRejectedValue(
114-
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
115-
)
114+
mocks.admit.mockRejectedValue(new NoWorkspaceAccessError())
116115

117116
const response = await callPut('{not-json')
118117

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ vi.mock('@/lib/users/queries', () => ({
4141
requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId)!,
4242
}))
4343

44-
import { OrchestrationError } from '@/lib/core/orchestration/types'
44+
import { NoWorkspaceAccessError } from '@/lib/core/application'
4545
import { GET } from '@/app/api/v2/files/[fileId]/metadata/route'
4646

4747
const WORKSPACE_ID = 'workspace-1'
@@ -114,9 +114,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
114114
})
115115

116116
it('conceals an authorization failure as not found', async () => {
117-
mocks.readMetadata.mockRejectedValue(
118-
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
119-
)
117+
mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError())
120118

121119
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
122120

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ vi.mock('@/lib/users/queries', () => ({
5555
requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId)!,
5656
}))
5757

58+
import {
59+
InsufficientWorkspacePermissionsError,
60+
NoWorkspaceAccessError,
61+
} from '@/lib/core/application'
5862
import { OrchestrationError } from '@/lib/core/orchestration/types'
5963
import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route'
6064

@@ -136,9 +140,7 @@ describe('v2 single-file routes', () => {
136140
})
137141

138142
it('conceals download authorization failures', async () => {
139-
mocks.download.mockRejectedValue(
140-
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
141-
)
143+
mocks.download.mockRejectedValue(new NoWorkspaceAccessError())
142144

143145
const response = await GET(
144146
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`),
@@ -166,7 +168,7 @@ describe('v2 single-file routes', () => {
166168
})
167169
})
168170

169-
it('maps rename conflicts and conceals authorization failures', async () => {
171+
it('maps rename conflicts and conceals absent workspace access', async () => {
170172
mocks.rename.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists'))
171173
const conflict = await PATCH(
172174
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, {
@@ -178,9 +180,7 @@ describe('v2 single-file routes', () => {
178180
)
179181
expect(conflict.status).toBe(409)
180182

181-
mocks.rename.mockRejectedValueOnce(
182-
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
183-
)
183+
mocks.rename.mockRejectedValueOnce(new NoWorkspaceAccessError())
184184
const concealed = await PATCH(
185185
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, {
186186
method: 'PATCH',
@@ -192,6 +192,23 @@ describe('v2 single-file routes', () => {
192192
expect(concealed.status).toBe(404)
193193
})
194194

195+
it('returns forbidden when the current workspace role cannot rename the file', async () => {
196+
mocks.rename.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
197+
const response = await PATCH(
198+
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, {
199+
method: 'PATCH',
200+
headers: { 'Content-Type': 'application/json' },
201+
body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }),
202+
}),
203+
context
204+
)
205+
206+
expect(response.status).toBe(403)
207+
expect(await response.json()).toEqual({
208+
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
209+
})
210+
})
211+
195212
it('archives through the same principal and operation pipeline', async () => {
196213
const request = new NextRequest(
197214
`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`,

apps/sim/app/api/v2/workflows/[id]/route.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,7 @@ vi.mock('@/lib/core/rate-limiter', () => ({
3636
}))
3737
vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate }))
3838

39-
import {
40-
InsufficientWorkspacePermissionsError,
41-
PersonalApiKeysDisabledError,
42-
} from '@/lib/core/application'
39+
import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application'
4340
import { DELETE, GET, PATCH } from '@/app/api/v2/workflows/[id]/route'
4441

4542
const WORKSPACE_ID = 'workspace-1'
@@ -124,8 +121,8 @@ describe('/api/v2/workflows/[id]', () => {
124121
})
125122
})
126123

127-
it('conceals typed insufficient authorization as workflow absence', async () => {
128-
mocks.readWorkflow.mockRejectedValue(new InsufficientWorkspacePermissionsError())
124+
it('conceals absent workspace access as workflow absence', async () => {
125+
mocks.readWorkflow.mockRejectedValue(new NoWorkspaceAccessError())
129126
const response = await GET(
130127
new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`),
131128
routeContext

apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ vi.mock('@/lib/workflows/application/cancel-run', () => ({
5252
},
5353
}))
5454

55-
import { InsufficientWorkspacePermissionsError } from '@/lib/core/application'
55+
import {
56+
InsufficientWorkspacePermissionsError,
57+
NoWorkspaceAccessError,
58+
} from '@/lib/core/application'
5659
import { POST as cancelPost } from '@/app/api/v2/workflows/[id]/runs/[runId]/cancel/route'
5760
import { GET } from '@/app/api/v2/workflows/[id]/runs/[runId]/route'
5861

@@ -194,7 +197,7 @@ describe('v2 run detail and cancel adapters', () => {
194197
})
195198

196199
it('conceals canonical run authorization failures as absence', async () => {
197-
mocks.readRun.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
200+
mocks.readRun.mockRejectedValueOnce(new NoWorkspaceAccessError())
198201

199202
const response = await callStatus()
200203

@@ -263,17 +266,17 @@ describe('v2 run detail and cancel adapters', () => {
263266
expect(mocks.cancel).not.toHaveBeenCalled()
264267
})
265268

266-
it('conceals cancellation authorization failures using canonical run policy', async () => {
269+
it('returns forbidden when the current workspace role cannot cancel the run', async () => {
267270
mocks.cancel.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
268271

269272
const response = await cancelPost(createMockRequest('POST', undefined, {}), {
270273
params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }),
271274
})
272275

273-
expect(response.status).toBe(404)
276+
expect(response.status).toBe(403)
274277
expect((await response.json()).error).toMatchObject({
275-
code: 'NOT_FOUND',
276-
message: 'Run not found',
278+
code: 'FORBIDDEN',
279+
message: 'Insufficient workspace permissions',
277280
})
278281
expect(mocks.capture).not.toHaveBeenCalled()
279282
})

apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,7 @@ vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({
3535
},
3636
}))
3737

38-
import {
39-
InsufficientWorkspacePermissionsError,
40-
PersonalApiKeysDisabledError,
41-
} from '@/lib/core/application'
38+
import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application'
4239
import { GET } from '@/app/api/v2/workflows/[id]/runs/route'
4340

4441
const principal = {
@@ -176,7 +173,7 @@ describe('GET /api/v2/workflows/[id]/runs', () => {
176173
})
177174

178175
it('conceals workflow authorization failures as absence', async () => {
179-
mocks.listRuns.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
176+
mocks.listRuns.mockRejectedValueOnce(new NoWorkspaceAccessError())
180177

181178
const response = await callGet()
182179

apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { V2ErrorPolicy } from '@/lib/api/server/routes'
66
import {
77
DelegatedWorkspaceAuthorizationError,
88
InsufficientWorkspacePermissionsError,
9+
NoWorkspaceAccessError,
910
PersonalApiKeysDisabledError,
1011
PrincipalKindAuthorizationError,
1112
WorkspaceApiKeyAuthorizationError,
@@ -59,7 +60,7 @@ const policies: Array<{
5960
]
6061

6162
const resourceAuthorizationErrors = [
62-
new InsufficientWorkspacePermissionsError(),
63+
new NoWorkspaceAccessError(),
6364
new WorkspaceApiKeyAuthorizationError(),
6465
new DelegatedWorkspaceAuthorizationError(),
6566
new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'),
@@ -88,11 +89,21 @@ describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessa
8889
})
8990
})
9091

91-
it('preserves unrelated forbidden business failures', async () => {
92-
const response = policy.render(new OrchestrationError('forbidden', 'Business rule denied'))
92+
it('preserves insufficient workspace role as forbidden', async () => {
93+
const response = policy.render(new InsufficientWorkspacePermissionsError())
9394
expect(response?.status).toBe(403)
9495
await expect(response?.json()).resolves.toEqual({
95-
error: { code: 'FORBIDDEN', message: 'Business rule denied' },
96+
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
97+
})
98+
})
99+
100+
it('does not classify generic forbidden errors by message', async () => {
101+
const response = policy.render(
102+
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
103+
)
104+
expect(response?.status).toBe(403)
105+
await expect(response?.json()).resolves.toEqual({
106+
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
96107
})
97108
})
98109
})

apps/sim/lib/api/server/routes/v2-resource-concealment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route'
22
import {
33
DelegatedWorkspaceAuthorizationError,
4-
InsufficientWorkspacePermissionsError,
4+
NoWorkspaceAccessError,
55
PrincipalKindAuthorizationError,
66
WorkspaceApiKeyAuthorizationError,
77
} from '@/lib/core/application'
@@ -12,7 +12,7 @@ type V2ErrorRenderer = V2ErrorPolicy['render']
1212
function isResourceAuthorizationError(error: unknown): boolean {
1313
return (
1414
error instanceof DelegatedWorkspaceAuthorizationError ||
15-
error instanceof InsufficientWorkspacePermissionsError ||
15+
error instanceof NoWorkspaceAccessError ||
1616
error instanceof PrincipalKindAuthorizationError ||
1717
error instanceof WorkspaceApiKeyAuthorizationError
1818
)

apps/sim/lib/core/application/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
DelegatedServiceAuthorizationError,
2020
DelegatedWorkspaceAuthorizationError,
2121
InsufficientWorkspacePermissionsError,
22+
NoWorkspaceAccessError,
2223
PersonalApiKeysDisabledError,
2324
PrincipalKindAuthorizationError,
2425
requireAllowedWorkspacePrincipal,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { SessionPrincipal } from '@sim/auth/principal'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
resolvePermission: vi.fn(),
9+
}))
10+
11+
vi.mock('@sim/platform-authz/workspace', () => ({
12+
permissionSatisfies: (actual: string, required: string) => {
13+
const rank = { read: 1, write: 2, admin: 3 } as const
14+
return rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank]
15+
},
16+
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
17+
}))
18+
19+
import {
20+
authorizeWorkspaceOperation,
21+
defineWorkspaceOperation,
22+
InsufficientWorkspacePermissionsError,
23+
NoWorkspaceAccessError,
24+
} from '@/lib/core/application'
25+
26+
const writeOperation = defineWorkspaceOperation({
27+
id: 'test.write',
28+
minimumRole: 'write',
29+
workspaceApiKey: 'deny',
30+
principalKinds: ['session'],
31+
})
32+
33+
const principal: SessionPrincipal = {
34+
kind: 'session',
35+
userId: 'user-1',
36+
sessionId: 'session-1',
37+
}
38+
39+
const context = {
40+
workspaceId: 'workspace-1',
41+
workspaceOrganizationId: 'organization-1',
42+
allowPersonalApiKeys: true,
43+
}
44+
45+
describe('authorizeWorkspaceOperation', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
})
49+
50+
it('rejects a null effective permission as no workspace access', async () => {
51+
mocks.resolvePermission.mockResolvedValue(null)
52+
53+
await expect(
54+
authorizeWorkspaceOperation(principal, writeOperation, context)
55+
).rejects.toBeInstanceOf(NoWorkspaceAccessError)
56+
})
57+
58+
it('rejects a readable workspace as an insufficient role for a write operation', async () => {
59+
mocks.resolvePermission.mockResolvedValue('read')
60+
61+
await expect(
62+
authorizeWorkspaceOperation(principal, writeOperation, context)
63+
).rejects.toBeInstanceOf(InsufficientWorkspacePermissionsError)
64+
})
65+
66+
it('authorizes the write operation when the current role satisfies it', async () => {
67+
mocks.resolvePermission.mockResolvedValue('write')
68+
69+
await expect(
70+
authorizeWorkspaceOperation(principal, writeOperation, context)
71+
).resolves.toBeUndefined()
72+
})
73+
})

0 commit comments

Comments
 (0)