Skip to content

Commit 7b01cd8

Browse files
committed
fix(credentials): clean up personal secrets in archived workspaces
1 parent 360d653 commit 7b01cd8

5 files changed

Lines changed: 151 additions & 99 deletions

File tree

apps/sim/app/api/environment/route.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
6060
* persists a map derived from the pre-replace state, discarding this one
6161
* entirely.
6262
*
63-
* The reconcile below stays outside because it opens its own transaction.
64-
* That leaves a known gap: it prunes mirrors against this request's key
65-
* list, so a secret added after the commit loses its mirror while its
66-
* value survives. Closing it means having the reconcile read the map
67-
* itself rather than trust a caller's list, across all four of its
68-
* callers.
63+
* The reconcile below opens its own transaction and re-reads the map
64+
* under this same lock so a later save cannot be undone by stale keys.
6965
*/
7066
await db.transaction(async (tx) => {
7167
await lockPersonalEnvMap(tx, session.user.id)
@@ -89,7 +85,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
8985

9086
await syncPersonalEnvCredentialsForUser({
9187
userId: session.user.id,
92-
envKeys: Object.keys(variables),
9388
})
9489

9590
recordAudit({

apps/sim/lib/credentials/environment.test.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { credential, permissions, workspace } from '@sim/db/schema'
5-
import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
4+
import { credential, environment, permissions, workspace } from '@sim/db/schema'
5+
import {
6+
dbChainMock,
7+
dbChainMockFns,
8+
flattenMockConditions,
9+
queueTableRows,
10+
resetDbChainMock,
11+
} from '@sim/testing'
612
import { eq } from 'drizzle-orm'
713
import { beforeEach, describe, expect, it, vi } from 'vitest'
814
import type { DbOrTx } from '@/lib/db/types'
915

10-
const { mockAcquireUserBillingIdentityLock } = vi.hoisted(() => ({
16+
const { mockAcquireUserBillingIdentityLock, mockLockPersonalEnvMap } = vi.hoisted(() => ({
1117
mockAcquireUserBillingIdentityLock: vi.fn(),
18+
mockLockPersonalEnvMap: vi.fn(),
1219
}))
1320

1421
vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({
1522
acquireUserBillingIdentityLock: mockAcquireUserBillingIdentityLock,
1623
}))
1724

25+
vi.mock('@/lib/credentials/env-locks', () => ({
26+
lockPersonalEnvMap: mockLockPersonalEnvMap,
27+
}))
28+
1829
import {
1930
createWorkspaceEnvCredentials,
2031
getEnrolledManagedOAuthCredentials,
@@ -196,30 +207,35 @@ describe('syncPersonalEnvCredentialsForUser', () => {
196207
vi.clearAllMocks()
197208
resetDbChainMock()
198209
mockAcquireUserBillingIdentityLock.mockResolvedValue(undefined)
210+
mockLockPersonalEnvMap.mockResolvedValue(undefined)
199211
})
200212

201-
it('uses one transaction and acquires the transfer fence before discovering workspaces', async () => {
213+
it('locks the map before the transfer fence and reads current keys before reconciling', async () => {
202214
const base = dbChainMock.db
203215
const tx = {
204216
select: vi.fn(base.select),
205217
insert: vi.fn(base.insert),
206218
delete: vi.fn(base.delete),
207219
} as unknown as DbOrTx
208220
dbChainMockFns.transaction.mockImplementationOnce(async (callback) => callback(tx))
221+
queueTableRows(environment, [{ variables: { API_KEY: 'encrypted' } }])
209222
queueTableRows(permissions, [{ workspaceId: 'ws-1' }])
210223
queueTableRows(workspace, [])
211224
queueTableRows(credential, [{ id: 'credential-1' }])
212225

213226
await syncPersonalEnvCredentialsForUser({
214227
userId: 'user-1',
215-
envKeys: ['API_KEY'],
216228
})
217229

230+
expect(mockLockPersonalEnvMap).toHaveBeenCalledWith(tx, 'user-1')
231+
expect(mockLockPersonalEnvMap.mock.invocationCallOrder[0]).toBeLessThan(
232+
mockAcquireUserBillingIdentityLock.mock.invocationCallOrder[0]
233+
)
218234
expect(mockAcquireUserBillingIdentityLock).toHaveBeenCalledWith(tx, 'user-1')
219235
expect(mockAcquireUserBillingIdentityLock.mock.invocationCallOrder[0]).toBeLessThan(
220236
(tx.select as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]
221237
)
222-
expect(tx.select).toHaveBeenCalledTimes(3)
238+
expect(tx.select).toHaveBeenCalledTimes(4)
223239
expect(tx.insert).toHaveBeenCalledTimes(2)
224240
expect(tx.delete).toHaveBeenCalledTimes(1)
225241
})
@@ -232,19 +248,19 @@ describe('syncPersonalEnvCredentialsForUser', () => {
232248
delete: vi.fn(base.delete),
233249
} as unknown as DbOrTx
234250
dbChainMockFns.transaction.mockImplementationOnce(async (callback) => callback(tx))
251+
queueTableRows(environment, [{ variables: { API_KEY: 'encrypted' } }])
235252
queueTableRows(permissions, [])
236253
queueTableRows(workspace, [])
237254

238255
await syncPersonalEnvCredentialsForUser({
239256
userId: 'user-1',
240-
envKeys: ['API_KEY'],
241257
})
242258

243259
expect(mockAcquireUserBillingIdentityLock.mock.invocationCallOrder[0]).toBeLessThan(
244260
(tx.select as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]
245261
)
246262
expect(tx.insert).not.toHaveBeenCalled()
247-
expect(tx.delete).not.toHaveBeenCalled()
263+
expect(tx.delete).toHaveBeenCalledTimes(1)
248264
})
249265

250266
it('syncs every workspace with one credential insert, lookup, membership insert, and cleanup', async () => {
@@ -255,23 +271,74 @@ describe('syncPersonalEnvCredentialsForUser', () => {
255271
delete: vi.fn(base.delete),
256272
} as unknown as DbOrTx
257273
dbChainMockFns.transaction.mockImplementationOnce(async (callback) => callback(tx))
274+
queueTableRows(environment, [{ variables: { API_KEY: 'encrypted' } }])
258275
queueTableRows(permissions, [{ workspaceId: 'ws-2' }, { workspaceId: 'ws-1' }])
259276
queueTableRows(workspace, [])
260277
queueTableRows(credential, [{ id: 'credential-1' }, { id: 'credential-2' }])
261278

262279
await syncPersonalEnvCredentialsForUser({
263280
userId: 'user-1',
264-
envKeys: ['API_KEY'],
265281
})
266282

267-
expect(tx.select).toHaveBeenCalledTimes(3)
283+
expect(tx.select).toHaveBeenCalledTimes(4)
268284
expect(tx.insert).toHaveBeenCalledTimes(2)
269285
expect(tx.delete).toHaveBeenCalledTimes(1)
270286
expect(dbChainMockFns.values).toHaveBeenNthCalledWith(1, [
271287
expect.objectContaining({ workspaceId: 'ws-1', envKey: 'API_KEY' }),
272288
expect.objectContaining({ workspaceId: 'ws-2', envKey: 'API_KEY' }),
273289
])
274290
})
291+
292+
it.each([
293+
{ label: 'missing', rows: [] },
294+
{ label: 'empty', rows: [{ variables: {} }] },
295+
])(
296+
'cleans archived-workspace mirrors with a $label map and no active workspaces',
297+
async ({ rows }) => {
298+
queueTableRows(environment, rows)
299+
const deleteWhere = vi.fn().mockResolvedValue([])
300+
dbChainMock.db.delete.mockReturnValue({ where: deleteWhere })
301+
302+
await syncPersonalEnvCredentialsForUser({ userId: 'user-1' })
303+
304+
expect(dbChainMock.db.delete).toHaveBeenCalledWith(credential)
305+
expect(flattenMockConditions(deleteWhere.mock.calls[0][0])).toEqual([
306+
{ type: 'eq', left: credential.type, right: 'env_personal' },
307+
{ type: 'eq', left: credential.envOwnerUserId, right: 'user-1' },
308+
])
309+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
310+
}
311+
)
312+
313+
it.each([
314+
{ label: 'no active workspaces', activeWorkspaces: [] },
315+
{ label: 'an active workspace', activeWorkspaces: [{ workspaceId: 'active-workspace' }] },
316+
])(
317+
'prunes deleted keys across archived workspaces with $label, preserving current keys and owners',
318+
async ({ activeWorkspaces }) => {
319+
queueTableRows(environment, [{ variables: { KEEP: 'encrypted', NEW: 'encrypted-new' } }])
320+
queueTableRows(permissions, activeWorkspaces)
321+
queueTableRows(workspace, [])
322+
const deleteWhere = vi.fn().mockResolvedValue([])
323+
dbChainMock.db.delete.mockReturnValue({ where: deleteWhere })
324+
325+
await syncPersonalEnvCredentialsForUser({ userId: 'user-1' })
326+
327+
expect(flattenMockConditions(deleteWhere.mock.calls[0][0])).toEqual([
328+
{ type: 'eq', left: credential.type, right: 'env_personal' },
329+
{ type: 'eq', left: credential.envOwnerUserId, right: 'user-1' },
330+
{ type: 'notInArray', column: credential.envKey, values: ['KEEP', 'NEW'] },
331+
])
332+
if (activeWorkspaces.length === 0) {
333+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
334+
} else {
335+
expect(dbChainMockFns.values).toHaveBeenCalledWith([
336+
expect.objectContaining({ workspaceId: 'active-workspace', envKey: 'KEEP' }),
337+
expect.objectContaining({ workspaceId: 'active-workspace', envKey: 'NEW' }),
338+
])
339+
}
340+
}
341+
)
275342
})
276343

277344
describe('createWorkspaceEnvCredentials', () => {

apps/sim/lib/credentials/environment.ts

Lines changed: 69 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
credentialGroup,
55
credentialGroupEnrollment,
66
credentialMember,
7+
environment,
78
permissions,
89
user,
910
workspace,
@@ -15,6 +16,7 @@ import { generateId } from '@sim/utils/id'
1516
import { and, asc, eq, inArray, isNotNull, isNull, notInArray, or, sql } from 'drizzle-orm'
1617
import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock'
1718
import { isManagedCredentialGroupBindingLive } from '@/lib/credential-groups/credentials'
19+
import { lockPersonalEnvMap } from '@/lib/credentials/env-locks'
1820
import type { DbOrTx } from '@/lib/db/types'
1921
import {
2022
getEffectiveWorkspacePermission,
@@ -677,101 +679,95 @@ export async function deletePersonalEnvCredentialForUser(params: {
677679
await db.transaction(remove)
678680
}
679681

680-
export async function syncPersonalEnvCredentialsForUser(params: {
681-
userId: string
682-
envKeys: string[]
683-
}): Promise<void> {
684-
const { userId, envKeys } = params
685-
const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean)))
682+
/** Reconciles user-global secret deletions and active-workspace mirrors against the locked map. */
683+
export async function syncPersonalEnvCredentialsForUser(params: { userId: string }): Promise<void> {
684+
const { userId } = params
686685
const now = new Date()
687686

688687
await db.transaction(async (tx) => {
688+
await lockPersonalEnvMap(tx, userId)
689689
/**
690690
* Cross-organization transfer takes this same user-identity fence before
691691
* checking source-owned credentials. If this sync wins, transfer observes
692692
* the new env_personal rows and blocks; if transfer wins, this post-lock
693693
* workspace re-read cannot recreate credentials in the departed org.
694694
*/
695695
await acquireUserBillingIdentityLock(tx, userId)
696-
const workspaceIds = (await getUserWorkspaceIds(userId, tx)).sort()
697-
698-
if (workspaceIds.length === 0) return
699-
700-
if (normalizedKeys.length > 0) {
701-
const credentialValues = workspaceIds.flatMap((workspaceId) =>
702-
normalizedKeys.map((envKey) => ({
703-
id: generateId(),
704-
workspaceId,
705-
type: 'env_personal' as const,
706-
displayName: envKey,
707-
envKey,
708-
envOwnerUserId: userId,
709-
createdBy: userId,
710-
createdAt: now,
711-
updatedAt: now,
712-
}))
696+
const [personalEnvironment] = await tx
697+
.select({ variables: environment.variables })
698+
.from(environment)
699+
.where(eq(environment.userId, userId))
700+
.limit(1)
701+
const envKeys = Object.keys(personalEnvironment?.variables ?? {}).filter(Boolean)
702+
703+
/** Deleted keys must lose mirrors even in archived or no-longer-accessible workspaces. */
704+
await tx
705+
.delete(credential)
706+
.where(
707+
and(
708+
eq(credential.type, 'env_personal'),
709+
eq(credential.envOwnerUserId, userId),
710+
envKeys.length > 0 ? notInArray(credential.envKey, envKeys) : undefined
711+
)
713712
)
714-
for (const values of chunkArray(credentialValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
715-
await tx.insert(credential).values(values).onConflictDoNothing()
716-
}
717713

718-
const currentCredentials = await tx
719-
.select({ id: credential.id })
720-
.from(credential)
721-
.where(
722-
and(
723-
inArray(credential.workspaceId, workspaceIds),
724-
eq(credential.type, 'env_personal'),
725-
eq(credential.envOwnerUserId, userId),
726-
inArray(credential.envKey, normalizedKeys)
727-
)
728-
)
714+
if (envKeys.length === 0) return
729715

730-
if (currentCredentials.length > 0) {
731-
const membershipValues = currentCredentials.map(({ id: credentialId }) => ({
732-
id: generateId(),
733-
credentialId,
734-
userId,
735-
role: 'admin' as const,
736-
status: 'active' as const,
737-
joinedAt: now,
738-
invitedBy: userId,
739-
createdAt: now,
740-
updatedAt: now,
741-
}))
742-
for (const values of chunkArray(membershipValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
743-
await tx
744-
.insert(credentialMember)
745-
.values(values)
746-
.onConflictDoUpdate({
747-
target: [credentialMember.credentialId, credentialMember.userId],
748-
set: { role: 'admin', status: 'active', updatedAt: now },
749-
})
750-
}
751-
}
716+
const workspaceIds = (await getUserWorkspaceIds(userId, tx)).sort()
752717

753-
await tx
754-
.delete(credential)
755-
.where(
756-
and(
757-
inArray(credential.workspaceId, workspaceIds),
758-
eq(credential.type, 'env_personal'),
759-
eq(credential.envOwnerUserId, userId),
760-
notInArray(credential.envKey, normalizedKeys)
761-
)
762-
)
763-
return
718+
if (workspaceIds.length === 0) return
719+
720+
const credentialValues = workspaceIds.flatMap((workspaceId) =>
721+
envKeys.map((envKey) => ({
722+
id: generateId(),
723+
workspaceId,
724+
type: 'env_personal' as const,
725+
displayName: envKey,
726+
envKey,
727+
envOwnerUserId: userId,
728+
createdBy: userId,
729+
createdAt: now,
730+
updatedAt: now,
731+
}))
732+
)
733+
for (const values of chunkArray(credentialValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
734+
await tx.insert(credential).values(values).onConflictDoNothing()
764735
}
765736

766-
await tx
767-
.delete(credential)
737+
const currentCredentials = await tx
738+
.select({ id: credential.id })
739+
.from(credential)
768740
.where(
769741
and(
770742
inArray(credential.workspaceId, workspaceIds),
771743
eq(credential.type, 'env_personal'),
772-
eq(credential.envOwnerUserId, userId)
744+
eq(credential.envOwnerUserId, userId),
745+
inArray(credential.envKey, envKeys)
773746
)
774747
)
748+
749+
if (currentCredentials.length > 0) {
750+
const membershipValues = currentCredentials.map(({ id: credentialId }) => ({
751+
id: generateId(),
752+
credentialId,
753+
userId,
754+
role: 'admin' as const,
755+
status: 'active' as const,
756+
joinedAt: now,
757+
invitedBy: userId,
758+
createdAt: now,
759+
updatedAt: now,
760+
}))
761+
for (const values of chunkArray(membershipValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
762+
await tx
763+
.insert(credentialMember)
764+
.values(values)
765+
.onConflictDoUpdate({
766+
target: [credentialMember.credentialId, credentialMember.userId],
767+
set: { role: 'admin', status: 'active', updatedAt: now },
768+
})
769+
}
770+
}
775771
})
776772
}
777773

apps/sim/lib/credentials/orchestration/index.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -602,11 +602,8 @@ export async function deleteCredentialRecord(
602602
* Same read-modify-write on the personal map, under the same lock its
603603
* other writers take, with the mirrors removed in the same transaction.
604604
*
605-
* Targeted rather than a reconcile: the reconcile prunes every mirror
606-
* absent from a caller-supplied key list, so a secret added between the
607-
* read and the prune lost its mirror while its value survived. Deleting
608-
* this one key's mirrors cannot strand another secret, and the lock order
609-
* — map, then user identity — is the one `setPersonalSecret` already takes.
605+
* Delete only this key's mirrors across every workspace. The lock order
606+
* — map, then user identity — matches `setPersonalSecret` and bulk sync.
610607
*/
611608
await db.transaction(async (tx) => {
612609
await lockPersonalEnvMap(tx, envOwnerUserId)

0 commit comments

Comments
 (0)