Skip to content

Commit 5c378fb

Browse files
authored
fix(credentials): write an env value and its credential row together (#7160)
* 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. * fix(workflows): say when a block is dropped before persistence `workflow_blocks.name` is NOT NULL, so a block missing `type` or `name` has to be dropped — but it was dropped silently. A block with no edges left no trace anywhere: not in the returned warnings, not in a log line. The client sanitizer warns on the identical condition; this is its server counterpart, and the warnings array it feeds is already returned by the internal PUT, the v2 write and the importer. * improvement(chat): stop loading a transcript the v2 route never reads Nothing caps a chat transcript — no per-chat message limit on write, no pruning — and the v2 route keys continuity by `chatId`, so it read the whole thing on every resumed turn and dropped it. Opt out there. The load stays the default because the copilot send path does consume it. * 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. * fix(credentials): chunk the credential-ACL write the env save now depends on `createWorkspaceEnvCredentials` wrote keys x members membership rows in one statement, and neither side is bounded by the request contract. Past 65535 bind parameters that throws — previously a partial success, because the value had already committed, but this now runs inside the value's transaction, so it rolls the save back instead, deterministically, on every retry. A 50-member workspace saving 150 keys reaches it. Chunked the same way the two personal paths in this file already are. Also from the audit: - invalidate the decrypted-env cache after `deleteCredentialRecord` removes an env value, matching the dedicated delete paths; without it a deleted secret stayed resolvable for the cache TTL - correct the comment claiming the personal reconcile "matches the replace" — it prunes against this request's key list, so a secret added after the commit still loses its mirror. Naming the gap instead of asserting it away - name the one behavior change the in-transaction re-read introduces: a key whose submitted value already matched is not re-encrypted, so a concurrent write for that key now survives rather than being overwritten - drop the lock-timeout constant and TSDoc left behind when the lock moved into the shared helper, and stop shadowing `finalEncrypted`
1 parent 81f4086 commit 5c378fb

13 files changed

Lines changed: 411 additions & 162 deletions

File tree

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

Lines changed: 31 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,38 @@ 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.
62+
*
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.
69+
*/
70+
await db.transaction(async (tx) => {
71+
await lockPersonalEnvMap(tx, session.user.id)
72+
73+
await tx
74+
.insert(environment)
75+
.values({
76+
id: generateId(),
77+
userId: session.user.id,
6778
variables: encryptedVariables,
6879
updatedAt: new Date(),
69-
},
70-
})
80+
})
81+
.onConflictDoUpdate({
82+
target: [environment.userId],
83+
set: {
84+
variables: encryptedVariables,
85+
updatedAt: new Date(),
86+
},
87+
})
88+
})
7189

7290
await syncPersonalEnvCredentialsForUser({
7391
userId: session.user.id,

apps/sim/app/api/v2/chat/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export const POST = withRouteHandler(
177177
// Chat block do, both of which post a single message with a chat id.
178178
const resolvedChat = await resolveOrCreateChat({
179179
...(conversationId ? { chatId: conversationId } : {}),
180+
includeTranscript: false,
180181
userId,
181182
workspaceId,
182183
model: MOTHERSHIP_CHAT_DEFAULT_MODEL,

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

Lines changed: 26 additions & 21 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,
@@ -34,13 +35,6 @@ import {
3435

3536
const logger = createLogger('WorkspaceEnvironmentAPI')
3637

37-
/**
38-
* Bounds the workspace-environment advisory-lock wait so a stuck holder fails
39-
* fast (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a
40-
* server-side `lock_timeout`. Transaction-scoped via `set_config(..., true)`.
41-
*/
42-
const WORKSPACE_ENV_LOCK_TIMEOUT_MS = 5_000
43-
4438
/**
4539
* Restricts decrypted workspace env values to administrators. Members (including
4640
* read-only) receive the variable names with empty values so editor autocomplete
@@ -237,11 +231,8 @@ export const PUT = withRouteHandler(
237231
})
238232
).then((entries) => Object.fromEntries(entries))
239233

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))`)
234+
const { merged } = await db.transaction(async (tx) => {
235+
await lockWorkspaceEnvMap(tx, workspaceId)
245236

246237
const [existingRow] = await tx
247238
.select()
@@ -269,12 +260,24 @@ export const PUT = withRouteHandler(
269260
set: { variables: mergedVars, updatedAt: new Date() },
270261
})
271262

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

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

279282
recordAudit({
280283
workspaceId,
@@ -370,10 +373,7 @@ export const DELETE = withRouteHandler(
370373
}
371374

372375
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))`)
376+
await lockWorkspaceEnvMap(tx, workspaceId)
377377

378378
const [existingRow] = await tx
379379
.select()
@@ -400,6 +400,12 @@ export const DELETE = withRouteHandler(
400400
.set({ variables: current, updatedAt: new Date() })
401401
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
402402

403+
await deleteWorkspaceEnvCredentials({
404+
workspaceId,
405+
removedKeys: keys,
406+
executor: tx,
407+
})
408+
403409
return { remainingKeysCount: Object.keys(current).length }
404410
})
405411

@@ -408,7 +414,6 @@ export const DELETE = withRouteHandler(
408414
}
409415

410416
invalidateEffectiveDecryptedEnvCache({ workspaceId })
411-
await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys })
412417

413418
recordAudit({
414419
workspaceId,

apps/sim/lib/copilot/chat/lifecycle.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ export async function getAccessibleCopilotChat(
212212
*/
213213
export async function getAccessibleCopilotChatWithMessages(
214214
chatId: string,
215-
userId: string
215+
userId: string,
216+
options?: { includeTranscript?: boolean }
216217
): Promise<CopilotChatDetailRow | null> {
217218
const [chat] = await db
218219
.select(copilotChatDetailColumns)
@@ -223,7 +224,14 @@ export async function getAccessibleCopilotChatWithMessages(
223224
const authorized = await authorizeCopilotChatRow(chat, chatId, userId)
224225
if (!authorized) return null
225226

226-
const messages = await loadCopilotChatMessages(chatId)
227+
/**
228+
* The transcript is unbounded — no per-chat message cap on write and no
229+
* pruning — so a caller that only needs the chat's scope should not pay to
230+
* materialize it. Every check `resolveOrCreateChat` runs reads detail
231+
* columns only, so an empty list stays a truthful "not loaded" rather than
232+
* "no messages" for the callers that opt out.
233+
*/
234+
const messages = options?.includeTranscript === false ? [] : await loadCopilotChatMessages(chatId)
227235
return { ...authorized, messages }
228236
}
229237

@@ -245,15 +253,22 @@ export async function resolveOrCreateChat(params: {
245253
model: string
246254
type?: 'mothership' | 'copilot'
247255
title?: string
256+
/**
257+
* Skips loading the transcript on the resume path. For a caller that keys
258+
* continuity by `chatId` alone and never reads `conversationHistory`.
259+
*/
260+
includeTranscript?: boolean
248261
}): Promise<ChatLoadResult> {
249-
const { chatId, userId, workflowId, workspaceId, model, type, title } = params
262+
const { chatId, userId, workflowId, workspaceId, model, type, title, includeTranscript } = params
250263

251264
if (workspaceId) {
252265
await assertActiveWorkspaceAccess(workspaceId, userId)
253266
}
254267

255268
if (chatId) {
256-
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId)
269+
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, {
270+
includeTranscript,
271+
})
257272

258273
if (chat) {
259274
if (workflowId && chat.workflowId !== workflowId) {
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/environment.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({
1515
}))
1616

1717
import {
18+
createWorkspaceEnvCredentials,
1819
getPersonalEnvKeyRawAccess,
1920
getWorkspaceEnvKeyAdminAccess,
2021
syncPersonalEnvCredentialsForUser,
@@ -239,3 +240,41 @@ describe('syncPersonalEnvCredentialsForUser', () => {
239240
])
240241
})
241242
})
243+
244+
describe('createWorkspaceEnvCredentials', () => {
245+
beforeEach(() => {
246+
vi.clearAllMocks()
247+
resetDbChainMock()
248+
})
249+
250+
/**
251+
* The membership row count is keys × members, and neither is bounded by the
252+
* request contract. A single statement past Postgres's 65535 bind parameters
253+
* throws — and because this now runs inside the value's transaction, that
254+
* would roll back the save on every retry rather than half-committing it.
255+
*/
256+
it('splits a keys x members write too wide for one statement', async () => {
257+
const keys = Array.from({ length: 40 }, (_, i) => `KEY_${i}`)
258+
queueTableRows(workspace, [{ ownerId: 'owner-1' }])
259+
queueTableRows(
260+
permissions,
261+
Array.from({ length: 60 }, (_, i) => ({ userId: `member-${i}` }))
262+
)
263+
// Every chunk of the credential insert reports its rows back as created.
264+
dbChainMockFns.returning.mockImplementation(() =>
265+
Promise.resolve(keys.map((_, i) => ({ id: `credential-${i}` })))
266+
)
267+
268+
await createWorkspaceEnvCredentials({
269+
workspaceId: 'ws-1',
270+
newKeys: keys,
271+
actingUserId: 'member-0',
272+
})
273+
274+
const rowsPerCall = dbChainMockFns.values.mock.calls.map(([rows]) =>
275+
Array.isArray(rows) ? rows.length : 1
276+
)
277+
expect(rowsPerCall.length).toBeGreaterThan(1)
278+
expect(Math.max(...rowsPerCall)).toBeLessThanOrEqual(500)
279+
})
280+
})

0 commit comments

Comments
 (0)