Skip to content

Commit 30bb92a

Browse files
committed
feat(byok): support multiple keys per provider with round-robin rotation
1 parent d4722f9 commit 30bb92a

13 files changed

Lines changed: 17180 additions & 103 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { auditMock, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockDbState,
9+
mockGetSession,
10+
mockGetUserEntityPermissions,
11+
mockGetWorkspaceById,
12+
mockEncryptSecret,
13+
mockDecryptSecret,
14+
mockUpdateSet,
15+
mockInsertValues,
16+
mockDeleteWhere,
17+
} = vi.hoisted(() => {
18+
const state = {
19+
selectResults: [] as unknown[][],
20+
insertReturning: [] as unknown[],
21+
deleteReturning: [] as unknown[],
22+
}
23+
return {
24+
mockDbState: state,
25+
mockGetSession: vi.fn(),
26+
mockGetUserEntityPermissions: vi.fn(),
27+
mockGetWorkspaceById: vi.fn(),
28+
mockEncryptSecret: vi.fn(),
29+
mockDecryptSecret: vi.fn(),
30+
mockUpdateSet: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })),
31+
mockInsertValues: vi.fn(() => ({
32+
returning: vi.fn(() => Promise.resolve(state.insertReturning)),
33+
})),
34+
mockDeleteWhere: vi.fn(() => ({
35+
returning: vi.fn(() => Promise.resolve(state.deleteReturning)),
36+
})),
37+
}
38+
})
39+
40+
vi.mock('@sim/db', () => ({
41+
db: {
42+
select: vi.fn(() => {
43+
const chain: Record<string, unknown> = {}
44+
chain.from = vi.fn().mockReturnValue(chain)
45+
chain.where = vi.fn().mockImplementation(() => {
46+
const result: any = Promise.resolve(mockDbState.selectResults.shift() ?? [])
47+
result.limit = vi.fn(() => result)
48+
result.orderBy = vi.fn(() => result)
49+
return result
50+
})
51+
return chain
52+
}),
53+
update: vi.fn(() => ({ set: mockUpdateSet })),
54+
insert: vi.fn(() => ({ values: mockInsertValues })),
55+
delete: vi.fn(() => ({ where: mockDeleteWhere })),
56+
},
57+
}))
58+
59+
vi.mock('@sim/audit', () => auditMock)
60+
61+
vi.mock('@/lib/auth', () => ({
62+
getSession: mockGetSession,
63+
}))
64+
65+
vi.mock('@/lib/core/security/encryption', () => ({
66+
encryptSecret: mockEncryptSecret,
67+
decryptSecret: mockDecryptSecret,
68+
}))
69+
70+
vi.mock('@/lib/posthog/server', () => ({
71+
captureServerEvent: vi.fn(),
72+
}))
73+
74+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
75+
getUserEntityPermissions: mockGetUserEntityPermissions,
76+
getWorkspaceById: mockGetWorkspaceById,
77+
}))
78+
79+
import { DELETE, GET, POST } from '@/app/api/workspaces/[id]/byok-keys/route'
80+
81+
const WORKSPACE_ID = 'workspace-1'
82+
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
83+
84+
const storedKeyRow = (id: string, name: string | null = null) => ({
85+
id,
86+
providerId: 'openai',
87+
encryptedApiKey: `encrypted-${id}`,
88+
name,
89+
createdBy: 'user-1',
90+
createdAt: new Date('2026-01-01T00:00:00Z'),
91+
updatedAt: new Date('2026-01-01T00:00:00Z'),
92+
})
93+
94+
describe('workspace BYOK keys route', () => {
95+
beforeEach(() => {
96+
vi.clearAllMocks()
97+
mockDbState.selectResults = []
98+
mockDbState.insertReturning = []
99+
mockDbState.deleteReturning = []
100+
101+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
102+
mockGetUserEntityPermissions.mockResolvedValue('admin')
103+
mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID })
104+
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-value', iv: 'iv' })
105+
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
106+
decrypted: encrypted.replace('encrypted-', 'sk-decrypted-value-'),
107+
}))
108+
})
109+
110+
describe('GET', () => {
111+
it('lists every stored key with name and masked value', async () => {
112+
mockDbState.selectResults = [[storedKeyRow('key-1', 'Production'), storedKeyRow('key-2')]]
113+
114+
const res = await GET(createMockRequest('GET'), routeContext)
115+
116+
expect(res.status).toBe(200)
117+
const body = await res.json()
118+
expect(body.keys).toHaveLength(2)
119+
expect(body.keys[0]).toMatchObject({ id: 'key-1', name: 'Production', providerId: 'openai' })
120+
expect(body.keys[0].maskedKey).toBe('sk-dec...ey-1')
121+
expect(body.keys[1]).toMatchObject({ id: 'key-2', name: null })
122+
})
123+
124+
it('returns 401 when the user has no workspace permission', async () => {
125+
mockGetUserEntityPermissions.mockResolvedValue(null)
126+
127+
const res = await GET(createMockRequest('GET'), routeContext)
128+
129+
expect(res.status).toBe(401)
130+
})
131+
})
132+
133+
describe('POST', () => {
134+
it('returns 403 when the user is not a workspace admin', async () => {
135+
mockGetUserEntityPermissions.mockResolvedValue('write')
136+
137+
const res = await POST(
138+
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new' }),
139+
routeContext
140+
)
141+
142+
expect(res.status).toBe(403)
143+
expect(mockInsertValues).not.toHaveBeenCalled()
144+
})
145+
146+
it('adds a new key even when the provider already has keys', async () => {
147+
mockDbState.selectResults = [[{ keyCount: 2 }]]
148+
mockDbState.insertReturning = [
149+
{ id: 'key-3', providerId: 'openai', name: 'Backup', createdAt: new Date() },
150+
]
151+
152+
const res = await POST(
153+
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key', name: 'Backup' }),
154+
routeContext
155+
)
156+
157+
expect(res.status).toBe(200)
158+
const body = await res.json()
159+
expect(body.success).toBe(true)
160+
expect(body.key).toMatchObject({ id: 'key-3', name: 'Backup' })
161+
expect(mockInsertValues).toHaveBeenCalledWith(
162+
expect.objectContaining({
163+
workspaceId: WORKSPACE_ID,
164+
providerId: 'openai',
165+
encryptedApiKey: 'encrypted-value',
166+
name: 'Backup',
167+
})
168+
)
169+
})
170+
171+
it('stores a null name when none is provided', async () => {
172+
mockDbState.selectResults = [[{ keyCount: 0 }]]
173+
mockDbState.insertReturning = [
174+
{ id: 'key-1', providerId: 'openai', name: null, createdAt: new Date() },
175+
]
176+
177+
const res = await POST(
178+
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }),
179+
routeContext
180+
)
181+
182+
expect(res.status).toBe(200)
183+
expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ name: null }))
184+
})
185+
186+
it('rejects adding a key beyond the per-provider cap', async () => {
187+
mockDbState.selectResults = [[{ keyCount: 10 }]]
188+
189+
const res = await POST(
190+
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }),
191+
routeContext
192+
)
193+
194+
expect(res.status).toBe(400)
195+
const body = await res.json()
196+
expect(body.error).toContain('at most 10 keys')
197+
expect(mockInsertValues).not.toHaveBeenCalled()
198+
})
199+
200+
it('updates the targeted key in place when keyId is provided', async () => {
201+
mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]]
202+
203+
const res = await POST(
204+
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'key-2' }),
205+
routeContext
206+
)
207+
208+
expect(res.status).toBe(200)
209+
const body = await res.json()
210+
expect(body.key).toMatchObject({ id: 'key-2', name: 'Old name' })
211+
expect(mockUpdateSet).toHaveBeenCalledWith(
212+
expect.objectContaining({ encryptedApiKey: 'encrypted-value', name: 'Old name' })
213+
)
214+
expect(mockInsertValues).not.toHaveBeenCalled()
215+
})
216+
217+
it('clears the name when updating with an empty name', async () => {
218+
mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]]
219+
220+
const res = await POST(
221+
createMockRequest('POST', {
222+
providerId: 'openai',
223+
apiKey: 'sk-rotated',
224+
keyId: 'key-2',
225+
name: '',
226+
}),
227+
routeContext
228+
)
229+
230+
expect(res.status).toBe(200)
231+
expect(mockUpdateSet).toHaveBeenCalledWith(expect.objectContaining({ name: null }))
232+
})
233+
234+
it('returns 404 when the keyId does not exist in the workspace', async () => {
235+
mockDbState.selectResults = [[]]
236+
237+
const res = await POST(
238+
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'missing' }),
239+
routeContext
240+
)
241+
242+
expect(res.status).toBe(404)
243+
expect(mockUpdateSet).not.toHaveBeenCalled()
244+
})
245+
246+
it('rejects an empty apiKey', async () => {
247+
const res = await POST(
248+
createMockRequest('POST', { providerId: 'openai', apiKey: '' }),
249+
routeContext
250+
)
251+
252+
expect(res.status).toBe(400)
253+
})
254+
})
255+
256+
describe('DELETE', () => {
257+
it('deletes a single key when keyId is provided', async () => {
258+
mockDbState.deleteReturning = [{ id: 'key-2' }]
259+
260+
const res = await DELETE(
261+
createMockRequest('DELETE', { providerId: 'openai', keyId: 'key-2' }),
262+
routeContext
263+
)
264+
265+
expect(res.status).toBe(200)
266+
expect(await res.json()).toEqual({ success: true })
267+
})
268+
269+
it('returns 404 when keyId is provided but no key matches', async () => {
270+
mockDbState.deleteReturning = []
271+
272+
const res = await DELETE(
273+
createMockRequest('DELETE', { providerId: 'openai', keyId: 'missing' }),
274+
routeContext
275+
)
276+
277+
expect(res.status).toBe(404)
278+
})
279+
280+
it('deletes all provider keys when keyId is omitted', async () => {
281+
mockDbState.deleteReturning = [{ id: 'key-1' }, { id: 'key-2' }]
282+
283+
const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext)
284+
285+
expect(res.status).toBe(200)
286+
expect(await res.json()).toEqual({ success: true })
287+
})
288+
289+
it('succeeds when keyId is omitted and the provider has no keys', async () => {
290+
mockDbState.deleteReturning = []
291+
292+
const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext)
293+
294+
expect(res.status).toBe(200)
295+
expect(await res.json()).toEqual({ success: true })
296+
})
297+
298+
it('returns 403 when the user is not a workspace admin', async () => {
299+
mockGetUserEntityPermissions.mockResolvedValue('write')
300+
301+
const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext)
302+
303+
expect(res.status).toBe(403)
304+
expect(mockDeleteWhere).not.toHaveBeenCalled()
305+
})
306+
})
307+
})

0 commit comments

Comments
 (0)