Skip to content

Commit c269e88

Browse files
fix(credentials): conceal inaccessible credential reads (#6730)
1 parent 337a53f commit c269e88

5 files changed

Lines changed: 95 additions & 3 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { CredentialAccessRequiredError } from '@/lib/credentials/application/authorized-credential-use-case'
7+
8+
const mocks = vi.hoisted(() => ({
9+
read: vi.fn(),
10+
update: vi.fn(),
11+
remove: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/credentials/application/credential-crud', () => ({
15+
CredentialProviderOperationError: class CredentialProviderOperationError extends Error {},
16+
getWorkspaceCredentialUseCase: {
17+
operation: { id: 'credentials.read' },
18+
execute: mocks.read,
19+
},
20+
updateWorkspaceCredentialUseCase: {
21+
operation: { id: 'credentials.update' },
22+
execute: mocks.update,
23+
},
24+
}))
25+
26+
vi.mock('@/lib/credentials/application/service-account', () => ({
27+
deleteCredentialUseCase: {
28+
operation: { id: 'credentials.delete' },
29+
execute: mocks.remove,
30+
},
31+
}))
32+
33+
import { GET } from '@/app/api/credentials/[id]/route'
34+
35+
const CREDENTIAL_ID = 'credential-1'
36+
const routeContext = { params: Promise.resolve({ id: CREDENTIAL_ID }) }
37+
38+
describe('GET /api/credentials/[id]', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
authMockFns.mockGetSession.mockResolvedValue({
42+
user: { id: 'writer-1' },
43+
session: { id: 'session-1' },
44+
})
45+
})
46+
47+
it('preserves the generic denial for a workspace writer without credential access', async () => {
48+
mocks.read.mockRejectedValue(new CredentialAccessRequiredError())
49+
50+
const response = await GET(
51+
createMockRequest('GET', undefined, {}, `http://localhost/api/credentials/${CREDENTIAL_ID}`),
52+
routeContext
53+
)
54+
55+
expect(response.status).toBe(403)
56+
expect(await response.json()).toEqual({ error: 'Forbidden' })
57+
})
58+
})

apps/sim/app/api/credentials/[id]/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@/lib/api/server/routes'
1111
import {
1212
credentialValidationParseOptions,
13+
internalCredentialDetailErrorPolicy,
1314
internalCredentialErrorPolicy,
1415
} from '@/lib/credentials/api/route-policies'
1516
import {
@@ -27,7 +28,7 @@ export const GET = defineInternalJsonRoute({
2728
auth: internalSessionAuth,
2829
operation: credentialOperations.read,
2930
rateLimit,
30-
errorPolicy: internalCredentialErrorPolicy,
31+
errorPolicy: internalCredentialDetailErrorPolicy,
3132
parseOptions: credentialValidationParseOptions,
3233
mapInput: ({ params }) => ({ credentialId: params.id }),
3334
useCase: getWorkspaceCredentialUseCase,

apps/sim/lib/credentials/api/route-policies.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/se
77
import { NoWorkspaceAccessError } from '@/lib/core/application'
88
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
99
import { OrchestrationError } from '@/lib/core/orchestration/types'
10+
import { CredentialAccessRequiredError } from '@/lib/credentials/application/authorized-credential-use-case'
1011
import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud'
1112

1213
export const credentialValidationParseOptions = {
@@ -25,6 +26,14 @@ export const internalCredentialErrorPolicy = extendInternalErrorPolicy(
2526
}
2627
)
2728

29+
export const internalCredentialDetailErrorPolicy = extendInternalErrorPolicy(
30+
internalCredentialErrorPolicy,
31+
(error) =>
32+
error instanceof CredentialAccessRequiredError
33+
? internalErrorResponse(403, { error: 'Forbidden' })
34+
: null
35+
)
36+
2837
export const internalCredentialMemberListErrorPolicy = extendInternalErrorPolicy(
2938
internalCredentialErrorPolicy,
3039
(error) => {

apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import { defineWorkspaceOperation } from '@/lib/core/application'
6-
import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case'
6+
import {
7+
CredentialAccessRequiredError,
8+
defineAuthorizedCredentialUseCase,
9+
} from '@/lib/credentials/application/authorized-credential-use-case'
710
import { defineCredentialOperation } from '@/lib/credentials/application/operations'
811

912
const mocks = vi.hoisted(() => ({
@@ -85,6 +88,20 @@ describe('defineAuthorizedCredentialUseCase', () => {
8588
expect(mocks.execute).toHaveBeenCalledOnce()
8689
})
8790

91+
it('denies member-level reads without credential membership', async () => {
92+
mocks.getActor.mockResolvedValue({
93+
credential,
94+
member: null,
95+
hasWorkspaceAccess: true,
96+
isAdmin: false,
97+
})
98+
99+
await expect(
100+
createUseCase(memberOperation).execute({ principal, input: undefined })
101+
).rejects.toBeInstanceOf(CredentialAccessRequiredError)
102+
expect(mocks.execute).not.toHaveBeenCalled()
103+
})
104+
88105
it('requires credential admin independently of workspace read access', async () => {
89106
await expect(
90107
createUseCase(adminOperation).execute({ principal, input: undefined })

apps/sim/lib/credentials/application/authorized-credential-use-case.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export interface CredentialAuthorizationContext extends WorkspaceAuthorizationCo
1717
credentialAccess?: CredentialActorContext
1818
}
1919

20+
export class CredentialAccessRequiredError extends OrchestrationError {
21+
constructor() {
22+
super('forbidden', 'Credential access required')
23+
this.name = 'CredentialAccessRequiredError'
24+
}
25+
}
26+
2027
export function requireCredentialAccess(
2128
context: CredentialAuthorizationContext
2229
): CredentialActorContext {
@@ -61,7 +68,7 @@ export function defineAuthorizedCredentialUseCase<
6168
switch (definition.operation.minimumCredentialRole) {
6269
case 'member':
6370
if (!actor.member && !actor.isAdmin) {
64-
throw new OrchestrationError('forbidden', 'Credential access required')
71+
throw new CredentialAccessRequiredError()
6572
}
6673
return
6774
case 'admin':

0 commit comments

Comments
 (0)