Skip to content

Commit 0a5bc43

Browse files
committed
fix(credentials): write an env value and its credential row together
Every writer of a workspace or personal environment map read-modify-writes a single jsonb column, so they take an advisory lock on the map to serialize. `deleteCredentialRecord` took none, and did the read, the edit and the write-back outside a transaction: a secret written concurrently was read before that write and silently dropped by the write-back. The credential row was also written after its map transaction committed, in four places. The delete direction left a row describing a value that was gone; the create direction was worse than a stale row, because it cannot be repaired by retrying — the key is in the map by then, so the next attempt reads it as pre-existing, computes an empty `newKeys`, and never creates the row. Both helpers already accept `executor`, and `setWorkspaceSecret` has been passing the transaction since the parameter landed; these four were never migrated. The personal reconcile stays outside its transaction: it opens its own and takes the user-identity fence, so nesting it would have two transactions taking two locks in opposite orders. It reconciles against the stored keys, so a failure there is repaired by the next one rather than entrenched. Also folds the four copies of the lock into one helper, since this would have been the fifth.
1 parent e906190 commit 0a5bc43

6 files changed

Lines changed: 216 additions & 90 deletions

File tree

apps/sim/app/api/workspaces/[id]/environment/route.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { workspaceEnvironment } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
7-
import { eq, sql } from 'drizzle-orm'
7+
import { eq } from 'drizzle-orm'
88
import { type NextRequest, NextResponse } from 'next/server'
99
import {
1010
removeWorkspaceEnvironmentContract,
@@ -15,6 +15,7 @@ import { getSession } from '@/lib/auth'
1515
import { encryptSecret } from '@/lib/core/security/encryption'
1616
import { generateRequestId } from '@/lib/core/utils/request'
1717
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
18+
import { lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
1819
import {
1920
createWorkspaceEnvCredentials,
2021
deleteWorkspaceEnvCredentials,
@@ -39,8 +40,6 @@ const logger = createLogger('WorkspaceEnvironmentAPI')
3940
* fast (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a
4041
* server-side `lock_timeout`. Transaction-scoped via `set_config(..., true)`.
4142
*/
42-
const WORKSPACE_ENV_LOCK_TIMEOUT_MS = 5_000
43-
4443
/**
4544
* Restricts decrypted workspace env values to administrators. Members (including
4645
* read-only) receive the variable names with empty values so editor autocomplete
@@ -237,11 +236,8 @@ export const PUT = withRouteHandler(
237236
})
238237
).then((entries) => Object.fromEntries(entries))
239238

240-
const { existingEncrypted, merged } = await db.transaction(async (tx) => {
241-
await tx.execute(
242-
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
243-
)
244-
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 0))`)
239+
const { merged } = await db.transaction(async (tx) => {
240+
await lockWorkspaceEnvMap(tx, workspaceId)
245241

246242
const [existingRow] = await tx
247243
.select()
@@ -269,12 +265,24 @@ export const PUT = withRouteHandler(
269265
set: { variables: mergedVars, updatedAt: new Date() },
270266
})
271267

272-
return { existingEncrypted: existing, merged: mergedVars }
268+
/**
269+
* Inside the transaction because a value committed without its
270+
* credential row cannot be repaired by retrying: the key is in the map
271+
* by then, so the next attempt reads it as pre-existing, computes an
272+
* empty `newKeys`, and never creates the row.
273+
*/
274+
const newKeys = Object.keys(variables).filter((k) => !(k in existing))
275+
await createWorkspaceEnvCredentials({
276+
workspaceId,
277+
newKeys,
278+
actingUserId: userId,
279+
executor: tx,
280+
})
281+
282+
return { merged: mergedVars }
273283
})
274284

275285
invalidateEffectiveDecryptedEnvCache({ workspaceId })
276-
const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted))
277-
await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId })
278286

279287
recordAudit({
280288
workspaceId,
@@ -370,10 +378,7 @@ export const DELETE = withRouteHandler(
370378
}
371379

372380
const result = await db.transaction(async (tx) => {
373-
await tx.execute(
374-
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
375-
)
376-
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 0))`)
381+
await lockWorkspaceEnvMap(tx, workspaceId)
377382

378383
const [existingRow] = await tx
379384
.select()
@@ -400,6 +405,12 @@ export const DELETE = withRouteHandler(
400405
.set({ variables: current, updatedAt: new Date() })
401406
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
402407

408+
await deleteWorkspaceEnvCredentials({
409+
workspaceId,
410+
removedKeys: keys,
411+
executor: tx,
412+
})
413+
403414
return { remainingKeysCount: Object.keys(current).length }
404415
})
405416

@@ -408,7 +419,6 @@ export const DELETE = withRouteHandler(
408419
}
409420

410421
invalidateEffectiveDecryptedEnvCache({ workspaceId })
411-
await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys })
412422

413423
recordAudit({
414424
workspaceId,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { sql } from 'drizzle-orm'
2+
import type { DbOrTx } from '@/lib/db/types'
3+
4+
const ENV_MAP_LOCK_TIMEOUT_MS = 5_000
5+
6+
/**
7+
* Serializes every writer of one environment variables map.
8+
*
9+
* Both maps are a single jsonb column that every writer read-modify-writes, so
10+
* without this two concurrent writers each persist their own copy of the map
11+
* and the later commit silently drops the earlier one's key. The lock is
12+
* transaction-scoped, so it releases on commit or rollback with no unlock path
13+
* to miss, and it must be taken before the read that the write is derived from.
14+
*
15+
* The keys are the bare workspace or user id, matching every writer that
16+
* already takes this lock — a prefixed key would be a different lock and would
17+
* serialize against nothing.
18+
*/
19+
async function lockEnvMap(tx: DbOrTx, lockKey: string): Promise<void> {
20+
await tx.execute(sql`SELECT set_config('lock_timeout', ${`${ENV_MAP_LOCK_TIMEOUT_MS}ms`}, true)`)
21+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`)
22+
}
23+
24+
/** Serializes writers of one workspace's environment variables map. */
25+
export async function lockWorkspaceEnvMap(tx: DbOrTx, workspaceId: string): Promise<void> {
26+
await lockEnvMap(tx, workspaceId)
27+
}
28+
29+
/** Serializes writers of one user's personal environment variables map. */
30+
export async function lockPersonalEnvMap(tx: DbOrTx, userId: string): Promise<void> {
31+
await lockEnvMap(tx, userId)
32+
}

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

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const {
2020
mockGetClientCredentialAccountDescriptor,
2121
mockDeleteConnectionCredential,
2222
mockDeleteOrphanedOAuthAccount,
23+
mockDeleteWorkspaceEnvCredentials,
24+
mockSyncPersonalEnvCredentialsForUser,
2325
} = vi.hoisted(() => ({
2426
mockRecordAudit: vi.fn(),
2527
mockGetCredentialActorContext: vi.fn(),
@@ -31,6 +33,8 @@ const {
3133
mockGetClientCredentialAccountDescriptor: vi.fn(() => undefined),
3234
mockDeleteConnectionCredential: vi.fn(),
3335
mockDeleteOrphanedOAuthAccount: vi.fn(),
36+
mockDeleteWorkspaceEnvCredentials: vi.fn(),
37+
mockSyncPersonalEnvCredentialsForUser: vi.fn(),
3438
}))
3539

3640
vi.mock('@sim/audit', () => ({
@@ -57,8 +61,8 @@ vi.mock('@/lib/credentials/deletion', () => ({
5761
deleteOrphanedOAuthAccount: mockDeleteOrphanedOAuthAccount,
5862
}))
5963
vi.mock('@/lib/credentials/environment', () => ({
60-
deleteWorkspaceEnvCredentials: vi.fn(),
61-
syncPersonalEnvCredentialsForUser: vi.fn(),
64+
deleteWorkspaceEnvCredentials: mockDeleteWorkspaceEnvCredentials,
65+
syncPersonalEnvCredentialsForUser: mockSyncPersonalEnvCredentialsForUser,
6266
}))
6367
vi.mock('@/lib/credentials/atlassian-service-account', () => ({
6468
AtlassianValidationError: class AtlassianValidationError extends Error {},
@@ -713,6 +717,61 @@ describe('deleteCredentialRecord', () => {
713717
expect(mockDeleteConnectionCredential).not.toHaveBeenCalled()
714718
})
715719

720+
/**
721+
* The whole variables map is read, edited and written back here, so a
722+
* concurrent secret write is lost unless this holds the same advisory lock
723+
* every other writer of that map takes.
724+
*/
725+
it('removes a workspace env value under the map lock, with the row', async () => {
726+
await deleteCredentialRecord({
727+
credential: {
728+
id: 'cred-1',
729+
workspaceId: 'ws-1',
730+
type: 'env_workspace',
731+
envKey: 'STRIPE_API_KEY',
732+
providerId: null,
733+
} as never,
734+
reason: 'user_delete',
735+
})
736+
737+
expect(dbChainMockFns.transaction).toHaveBeenCalled()
738+
const locked = dbChainMockFns.execute.mock.calls.some(([statement]) => {
739+
const { sql, params } = (
740+
statement as { toSQL: () => { sql: string; params: unknown[] } }
741+
).toSQL()
742+
return sql.includes('pg_advisory_xact_lock') && params.includes('ws-1')
743+
})
744+
expect(locked).toBe(true)
745+
// Passed the transaction, so the row cannot outlive the value it describes.
746+
expect(mockDeleteWorkspaceEnvCredentials).toHaveBeenCalledWith(
747+
expect.objectContaining({ workspaceId: 'ws-1', removedKeys: ['STRIPE_API_KEY'] })
748+
)
749+
expect(mockDeleteWorkspaceEnvCredentials.mock.calls[0][0].executor).toBeDefined()
750+
})
751+
752+
it('removes a personal env value under the map lock', async () => {
753+
await deleteCredentialRecord({
754+
credential: {
755+
id: 'cred-1',
756+
workspaceId: 'ws-1',
757+
type: 'env_personal',
758+
envKey: 'MY_KEY',
759+
envOwnerUserId: 'user-1',
760+
providerId: null,
761+
} as never,
762+
reason: 'user_delete',
763+
})
764+
765+
expect(dbChainMockFns.transaction).toHaveBeenCalled()
766+
const locked = dbChainMockFns.execute.mock.calls.some(([statement]) => {
767+
const { sql, params } = (
768+
statement as { toSQL: () => { sql: string; params: unknown[] } }
769+
).toSQL()
770+
return sql.includes('pg_advisory_xact_lock') && params.includes('user-1')
771+
})
772+
expect(locked).toBe(true)
773+
})
774+
716775
it('revokes the backing OAuth grant of a deleted oauth credential', async () => {
717776
mockDeleteConnectionCredential.mockResolvedValueOnce(true)
718777

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

Lines changed: 73 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
deleteOrphanedOAuthAccount,
3131
} from '@/lib/credentials/deletion'
3232
import { slackCustomBotDisplayName } from '@/lib/credentials/display-name'
33+
import { lockPersonalEnvMap, lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
3334
import {
3435
deleteWorkspaceEnvCredentials,
3536
syncPersonalEnvCredentialsForUser,
@@ -565,25 +566,39 @@ export async function deleteCredentialRecord(
565566
if (!credentialRow.envKey || !credentialRow.envOwnerUserId) {
566567
throw new Error('Personal environment credential is missing its source identity')
567568
}
568-
const [personalRow] = await db
569-
.select({ variables: environment.variables })
570-
.from(environment)
571-
.where(eq(environment.userId, credentialRow.envOwnerUserId))
572-
.limit(1)
573-
const current = { ...((personalRow?.variables as Record<string, string> | null) ?? {}) }
574-
delete current[credentialRow.envKey]
575-
await db
576-
.insert(environment)
577-
.values({
578-
id: credentialRow.envOwnerUserId,
579-
userId: credentialRow.envOwnerUserId,
580-
variables: current,
581-
updatedAt: new Date(),
582-
})
583-
.onConflictDoUpdate({
584-
target: [environment.userId],
585-
set: { variables: current, updatedAt: new Date() },
586-
})
569+
const { envKey, envOwnerUserId } = credentialRow
570+
/**
571+
* 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.
577+
*/
578+
const current = await db.transaction(async (tx) => {
579+
await lockPersonalEnvMap(tx, envOwnerUserId)
580+
581+
const [personalRow] = await tx
582+
.select({ variables: environment.variables })
583+
.from(environment)
584+
.where(eq(environment.userId, envOwnerUserId))
585+
.limit(1)
586+
const variables = { ...((personalRow?.variables as Record<string, string> | null) ?? {}) }
587+
delete variables[envKey]
588+
await tx
589+
.insert(environment)
590+
.values({
591+
id: envOwnerUserId,
592+
userId: envOwnerUserId,
593+
variables,
594+
updatedAt: new Date(),
595+
})
596+
.onConflictDoUpdate({
597+
target: [environment.userId],
598+
set: { variables, updatedAt: new Date() },
599+
})
600+
return variables
601+
})
587602
await syncPersonalEnvCredentialsForUser({
588603
userId: credentialRow.envOwnerUserId,
589604
envKeys: Object.keys(current),
@@ -595,33 +610,46 @@ export async function deleteCredentialRecord(
595610
if (!credentialRow.envKey) {
596611
throw new Error('Workspace environment credential is missing its source identity')
597612
}
598-
const [workspaceRow] = await db
599-
.select({
600-
id: workspaceEnvironment.id,
601-
createdAt: workspaceEnvironment.createdAt,
602-
variables: workspaceEnvironment.variables,
603-
})
604-
.from(workspaceEnvironment)
605-
.where(eq(workspaceEnvironment.workspaceId, credentialRow.workspaceId))
606-
.limit(1)
607-
const current = { ...((workspaceRow?.variables as Record<string, string> | null) ?? {}) }
608-
delete current[credentialRow.envKey]
609-
await db
610-
.insert(workspaceEnvironment)
611-
.values({
612-
id: workspaceRow?.id ?? generateId(),
613-
workspaceId: credentialRow.workspaceId,
614-
variables: current,
615-
createdAt: workspaceRow?.createdAt ?? new Date(),
616-
updatedAt: new Date(),
617-
})
618-
.onConflictDoUpdate({
619-
target: [workspaceEnvironment.workspaceId],
620-
set: { variables: current, updatedAt: new Date() },
613+
const { envKey, workspaceId } = credentialRow
614+
/**
615+
* The whole variables map is read, edited and written back, so this has to
616+
* hold the same lock every other writer of that map takes — without it a
617+
* secret written concurrently is read before the write and dropped by this
618+
* write-back. The credential row goes in the same transaction so the row
619+
* and the value it describes cannot outlive each other.
620+
*/
621+
await db.transaction(async (tx) => {
622+
await lockWorkspaceEnvMap(tx, workspaceId)
623+
624+
const [workspaceRow] = await tx
625+
.select({
626+
id: workspaceEnvironment.id,
627+
createdAt: workspaceEnvironment.createdAt,
628+
variables: workspaceEnvironment.variables,
629+
})
630+
.from(workspaceEnvironment)
631+
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
632+
.limit(1)
633+
const current = { ...((workspaceRow?.variables as Record<string, string> | null) ?? {}) }
634+
delete current[envKey]
635+
await tx
636+
.insert(workspaceEnvironment)
637+
.values({
638+
id: workspaceRow?.id ?? generateId(),
639+
workspaceId,
640+
variables: current,
641+
createdAt: workspaceRow?.createdAt ?? new Date(),
642+
updatedAt: new Date(),
643+
})
644+
.onConflictDoUpdate({
645+
target: [workspaceEnvironment.workspaceId],
646+
set: { variables: current, updatedAt: new Date() },
647+
})
648+
await deleteWorkspaceEnvCredentials({
649+
workspaceId,
650+
removedKeys: [envKey],
651+
executor: tx,
621652
})
622-
await deleteWorkspaceEnvCredentials({
623-
workspaceId: credentialRow.workspaceId,
624-
removedKeys: [credentialRow.envKey],
625653
})
626654
return true
627655
}

0 commit comments

Comments
 (0)