Skip to content

Commit 5c4dbed

Browse files
committed
fix(credentials): serialize every personal env map writer
Exporting the personal lock while two writers skipped it left the map unserialized: `upsertPersonalEnvVars` merged against a read taken outside any lock, and the settings PUT replaced the map wholesale. A wholesale replace landing between another writer's read and its write-back is discarded whole, so it takes the lock too. The delete path now removes the key's mirrors directly instead of reconciling against a key list. The reconcile prunes every mirror absent from that list, so a secret added between the read and the prune lost its mirror while its value survived. `setPersonalSecret` already takes the map lock and then the user-identity fence inside it, so the targeted delete introduces no new lock order.
1 parent 29db915 commit 5c4dbed

4 files changed

Lines changed: 77 additions & 42 deletions

File tree

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getSession } from '@/lib/auth'
1212
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
1313
import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15+
import { lockPersonalEnvMap } from '@/lib/credentials/env-locks'
1516
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
1617
import type { EnvironmentVariable } from '@/lib/environment/api'
1718
import { captureServerEvent } from '@/lib/posthog/server'
@@ -53,21 +54,31 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
5354
})
5455
).then((entries) => Object.fromEntries(entries))
5556

56-
await db
57-
.insert(environment)
58-
.values({
59-
id: generateId(),
60-
userId: session.user.id,
61-
variables: encryptedVariables,
62-
updatedAt: new Date(),
63-
})
64-
.onConflictDoUpdate({
65-
target: [environment.userId],
66-
set: {
57+
/**
58+
* A wholesale replace still takes the map lock: without it this can land
59+
* between another writer's read and its write-back, and that writer then
60+
* persists a map derived from the pre-replace state, discarding this one
61+
* entirely. The reconcile below matches the replace, so it stays outside.
62+
*/
63+
await db.transaction(async (tx) => {
64+
await lockPersonalEnvMap(tx, session.user.id)
65+
66+
await tx
67+
.insert(environment)
68+
.values({
69+
id: generateId(),
70+
userId: session.user.id,
6771
variables: encryptedVariables,
6872
updatedAt: new Date(),
69-
},
70-
})
73+
})
74+
.onConflictDoUpdate({
75+
target: [environment.userId],
76+
set: {
77+
variables: encryptedVariables,
78+
updatedAt: new Date(),
79+
},
80+
})
81+
})
7182

7283
await syncPersonalEnvCredentialsForUser({
7384
userId: session.user.id,

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const {
2121
mockDeleteConnectionCredential,
2222
mockDeleteOrphanedOAuthAccount,
2323
mockDeleteWorkspaceEnvCredentials,
24-
mockSyncPersonalEnvCredentialsForUser,
24+
mockDeletePersonalEnvCredentialForUser,
2525
} = vi.hoisted(() => ({
2626
mockRecordAudit: vi.fn(),
2727
mockGetCredentialActorContext: vi.fn(),
@@ -34,7 +34,7 @@ const {
3434
mockDeleteConnectionCredential: vi.fn(),
3535
mockDeleteOrphanedOAuthAccount: vi.fn(),
3636
mockDeleteWorkspaceEnvCredentials: vi.fn(),
37-
mockSyncPersonalEnvCredentialsForUser: vi.fn(),
37+
mockDeletePersonalEnvCredentialForUser: vi.fn(),
3838
}))
3939

4040
vi.mock('@sim/audit', () => ({
@@ -62,7 +62,7 @@ vi.mock('@/lib/credentials/deletion', () => ({
6262
}))
6363
vi.mock('@/lib/credentials/environment', () => ({
6464
deleteWorkspaceEnvCredentials: mockDeleteWorkspaceEnvCredentials,
65-
syncPersonalEnvCredentialsForUser: mockSyncPersonalEnvCredentialsForUser,
65+
deletePersonalEnvCredentialForUser: mockDeletePersonalEnvCredentialForUser,
6666
}))
6767
vi.mock('@/lib/credentials/atlassian-service-account', () => ({
6868
AtlassianValidationError: class AtlassianValidationError extends Error {},
@@ -770,6 +770,15 @@ describe('deleteCredentialRecord', () => {
770770
return sql.includes('pg_advisory_xact_lock') && params.includes('user-1')
771771
})
772772
expect(locked).toBe(true)
773+
/**
774+
* Targeted, not a reconcile against a key list: a list read before the
775+
* prune can miss a secret added since, and prune that secret's mirror
776+
* while its value survives.
777+
*/
778+
expect(mockDeletePersonalEnvCredentialForUser).toHaveBeenCalledWith(
779+
expect.objectContaining({ userId: 'user-1', envKey: 'MY_KEY' })
780+
)
781+
expect(mockDeletePersonalEnvCredentialForUser.mock.calls[0][0].executor).toBeDefined()
773782
})
774783

775784
it('revokes the backing OAuth grant of a deleted oauth credential', async () => {

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

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ import {
3232
import { slackCustomBotDisplayName } from '@/lib/credentials/display-name'
3333
import { lockPersonalEnvMap, lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
3434
import {
35+
deletePersonalEnvCredentialForUser,
3536
deleteWorkspaceEnvCredentials,
36-
syncPersonalEnvCredentialsForUser,
3737
} from '@/lib/credentials/environment'
3838
import type { ServiceAccountFieldId } from '@/lib/credentials/service-account-fields'
3939
import {
@@ -569,13 +569,15 @@ export async function deleteCredentialRecord(
569569
const { envKey, envOwnerUserId } = credentialRow
570570
/**
571571
* Same read-modify-write on the personal map, under the same lock its
572-
* other writers take. The credential reconcile stays outside: it opens its
573-
* own transaction and takes the user-identity fence, so nesting it here
574-
* would have two transactions taking two locks in opposite orders. It is a
575-
* reconcile against the stored keys, so a failure is repaired by the next
576-
* one rather than entrenched.
572+
* other writers take, with the mirrors removed in the same transaction.
573+
*
574+
* Targeted rather than a reconcile: the reconcile prunes every mirror
575+
* absent from a caller-supplied key list, so a secret added between the
576+
* read and the prune lost its mirror while its value survived. Deleting
577+
* this one key's mirrors cannot strand another secret, and the lock order
578+
* — map, then user identity — is the one `setPersonalSecret` already takes.
577579
*/
578-
const current = await db.transaction(async (tx) => {
580+
await db.transaction(async (tx) => {
579581
await lockPersonalEnvMap(tx, envOwnerUserId)
580582

581583
const [personalRow] = await tx
@@ -597,11 +599,7 @@ export async function deleteCredentialRecord(
597599
target: [environment.userId],
598600
set: { variables, updatedAt: new Date() },
599601
})
600-
return variables
601-
})
602-
await syncPersonalEnvCredentialsForUser({
603-
userId: credentialRow.envOwnerUserId,
604-
envKeys: Object.keys(current),
602+
await deletePersonalEnvCredentialForUser({ userId: envOwnerUserId, envKey, executor: tx })
605603
})
606604
return true
607605
}

apps/sim/lib/environment/utils.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { generateId } from '@sim/utils/id'
77
import { eq, inArray } from 'drizzle-orm'
88
import { LRUCache } from 'lru-cache'
99
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
10-
import { lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
10+
import { lockPersonalEnvMap, lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
1111
import {
1212
createWorkspaceEnvCredentials,
1313
getAccessibleEnvCredentials,
@@ -435,20 +435,37 @@ export async function upsertPersonalEnvVars(
435435
newlyEncrypted[key] = encrypted
436436
}
437437

438-
const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted }
438+
/**
439+
* The read above only decides which values changed; the merge has to be made
440+
* against a read taken under the lock, or a key written concurrently is
441+
* absent from this map and dropped by the write-back.
442+
*/
443+
const finalEncrypted = await db.transaction(async (tx) => {
444+
await lockPersonalEnvMap(tx, userId)
439445

440-
await db
441-
.insert(environment)
442-
.values({
443-
id: generateId(),
444-
userId,
445-
variables: finalEncrypted,
446-
updatedAt: new Date(),
447-
})
448-
.onConflictDoUpdate({
449-
target: [environment.userId],
450-
set: { variables: finalEncrypted, updatedAt: new Date() },
451-
})
446+
const [currentRow] = await tx
447+
.select({ variables: environment.variables })
448+
.from(environment)
449+
.where(eq(environment.userId, userId))
450+
.limit(1)
451+
const current = (currentRow?.variables as Record<string, string>) || {}
452+
const finalEncrypted = { ...current, ...newlyEncrypted }
453+
454+
await tx
455+
.insert(environment)
456+
.values({
457+
id: generateId(),
458+
userId,
459+
variables: finalEncrypted,
460+
updatedAt: new Date(),
461+
})
462+
.onConflictDoUpdate({
463+
target: [environment.userId],
464+
set: { variables: finalEncrypted, updatedAt: new Date() },
465+
})
466+
467+
return finalEncrypted
468+
})
452469

453470
invalidateEffectiveDecryptedEnvCache({ userId })
454471
await syncPersonalEnvCredentialsForUser({

0 commit comments

Comments
 (0)