Skip to content

Commit d7b1874

Browse files
committed
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 5c4dbed commit d7b1874

6 files changed

Lines changed: 95 additions & 38 deletions

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
5858
* A wholesale replace still takes the map lock: without it this can land
5959
* between another writer's read and its write-back, and that writer then
6060
* persists a map derived from the pre-replace state, discarding this one
61-
* entirely. The reconcile below matches the replace, so it stays outside.
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.
6269
*/
6370
await db.transaction(async (tx) => {
6471
await lockPersonalEnvMap(tx, session.user.id)

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,6 @@ import {
3535

3636
const logger = createLogger('WorkspaceEnvironmentAPI')
3737

38-
/**
39-
* Bounds the workspace-environment advisory-lock wait so a stuck holder fails
40-
* fast (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a
41-
* server-side `lock_timeout`. Transaction-scoped via `set_config(..., true)`.
42-
*/
4338
/**
4439
* Restricts decrypted workspace env values to administrators. Members (including
4540
* read-only) receive the variable names with empty values so editor autocomplete

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+
})

apps/sim/lib/credentials/environment.ts

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
hasWorkspaceAdminAccess,
1818
} from '@/lib/workspaces/permissions/utils'
1919

20-
const PERSONAL_ENV_CREDENTIAL_WRITE_CHUNK_SIZE = 500
20+
const ENV_CREDENTIAL_WRITE_CHUNK_SIZE = 500
2121

2222
export interface WorkspaceMembership {
2323
ownerId: string | null
@@ -437,27 +437,35 @@ export async function createWorkspaceEnvCredentials(params: {
437437

438438
const now = params.updatedAt ?? new Date()
439439

440-
const inserted = await executor
441-
.insert(credential)
442-
.values(
443-
keys.map((envKey) => ({
444-
id: generateId(),
445-
workspaceId,
446-
type: 'env_workspace' as const,
447-
displayName: envKey,
448-
envKey,
449-
createdBy: actingUserId,
450-
createdAt: now,
451-
updatedAt: now,
452-
}))
453-
)
454-
.onConflictDoNothing()
455-
.returning({ id: credential.id })
456-
const createdIds = inserted.map((row) => row.id)
440+
const credentialValues = keys.map((envKey) => ({
441+
id: generateId(),
442+
workspaceId,
443+
type: 'env_workspace' as const,
444+
displayName: envKey,
445+
envKey,
446+
createdBy: actingUserId,
447+
createdAt: now,
448+
updatedAt: now,
449+
}))
450+
const createdIds: string[] = []
451+
for (const values of chunkArray(credentialValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
452+
const inserted = await executor
453+
.insert(credential)
454+
.values(values)
455+
.onConflictDoNothing()
456+
.returning({ id: credential.id })
457+
createdIds.push(...inserted.map((row) => row.id))
458+
}
457459

458460
if (createdIds.length === 0 || memberUserIds.length === 0) return
459461

460-
// Bulk-insert memberships for all new credentials × all workspace members in one query
462+
/**
463+
* Chunked because the row count is keys × members and neither side is
464+
* bounded: a wide enough save exceeds Postgres's 65535 bind parameters and
465+
* throws. Unchunked that was a partial success — the value was already
466+
* committed — but this now runs inside the value's transaction, so it would
467+
* roll the save back, deterministically, on every retry.
468+
*/
461469
const membershipValues = createdIds.flatMap((credentialId) =>
462470
memberUserIds.map((memberUserId) => ({
463471
id: generateId(),
@@ -472,7 +480,9 @@ export async function createWorkspaceEnvCredentials(params: {
472480
}))
473481
)
474482

475-
await executor.insert(credentialMember).values(membershipValues).onConflictDoNothing()
483+
for (const values of chunkArray(membershipValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
484+
await executor.insert(credentialMember).values(values).onConflictDoNothing()
485+
}
476486
}
477487

478488
/**
@@ -529,7 +539,7 @@ export async function upsertPersonalEnvCredentialForUser(params: {
529539
createdAt: updatedAt,
530540
updatedAt,
531541
}))
532-
for (const values of chunkArray(credentialValues, PERSONAL_ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
542+
for (const values of chunkArray(credentialValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
533543
await tx.insert(credential).values(values).onConflictDoNothing()
534544
}
535545

@@ -570,7 +580,7 @@ export async function upsertPersonalEnvCredentialForUser(params: {
570580
createdAt: updatedAt,
571581
updatedAt,
572582
}))
573-
for (const values of chunkArray(membershipValues, PERSONAL_ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
583+
for (const values of chunkArray(membershipValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
574584
await tx
575585
.insert(credentialMember)
576586
.values(values)
@@ -696,7 +706,7 @@ export async function syncPersonalEnvCredentialsForUser(params: {
696706
updatedAt: now,
697707
}))
698708
)
699-
for (const values of chunkArray(credentialValues, PERSONAL_ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
709+
for (const values of chunkArray(credentialValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
700710
await tx.insert(credential).values(values).onConflictDoNothing()
701711
}
702712

@@ -724,10 +734,7 @@ export async function syncPersonalEnvCredentialsForUser(params: {
724734
createdAt: now,
725735
updatedAt: now,
726736
}))
727-
for (const values of chunkArray(
728-
membershipValues,
729-
PERSONAL_ENV_CREDENTIAL_WRITE_CHUNK_SIZE
730-
)) {
737+
for (const values of chunkArray(membershipValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) {
731738
await tx
732739
.insert(credentialMember)
733740
.values(values)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,9 @@ export async function deleteCredentialRecord(
601601
})
602602
await deletePersonalEnvCredentialForUser({ userId: envOwnerUserId, envKey, executor: tx })
603603
})
604+
// The value is gone; without this it stays resolvable from the cache for
605+
// its TTL, as the dedicated delete paths already recognise.
606+
invalidateEffectiveDecryptedEnvCache({ userId: envOwnerUserId })
604607
return true
605608
}
606609

@@ -649,6 +652,7 @@ export async function deleteCredentialRecord(
649652
executor: tx,
650653
})
651654
})
655+
invalidateEffectiveDecryptedEnvCache({ workspaceId })
652656
return true
653657
}
654658

apps/sim/lib/environment/utils.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
} from '@/lib/workspaces/permissions/utils'
2222

2323
const logger = createLogger('EnvironmentUtils')
24-
const WORKSPACE_ENV_LOCK_TIMEOUT_MS = 5_000
2524
const EFFECTIVE_ENVIRONMENT_CACHE_TTL_MS = 2_000
2625
const EFFECTIVE_ENVIRONMENT_CACHE_MAX_ENTRIES = 1_000
2726

@@ -439,6 +438,12 @@ export async function upsertPersonalEnvVars(
439438
* The read above only decides which values changed; the merge has to be made
440439
* against a read taken under the lock, or a key written concurrently is
441440
* absent from this map and dropped by the write-back.
441+
*
442+
* One consequence worth naming: a key whose submitted value already matched
443+
* the earlier read is not re-encrypted, so a value written concurrently for
444+
* that key now survives instead of being overwritten with the identical
445+
* plaintext. `added`/`updated` describe the earlier read and are reporting
446+
* only — the keys actually written are exactly the re-encrypted ones.
442447
*/
443448
const finalEncrypted = await db.transaction(async (tx) => {
444449
await lockPersonalEnvMap(tx, userId)
@@ -449,22 +454,22 @@ export async function upsertPersonalEnvVars(
449454
.where(eq(environment.userId, userId))
450455
.limit(1)
451456
const current = (currentRow?.variables as Record<string, string>) || {}
452-
const finalEncrypted = { ...current, ...newlyEncrypted }
457+
const merged = { ...current, ...newlyEncrypted }
453458

454459
await tx
455460
.insert(environment)
456461
.values({
457462
id: generateId(),
458463
userId,
459-
variables: finalEncrypted,
464+
variables: merged,
460465
updatedAt: new Date(),
461466
})
462467
.onConflictDoUpdate({
463468
target: [environment.userId],
464-
set: { variables: finalEncrypted, updatedAt: new Date() },
469+
set: { variables: merged, updatedAt: new Date() },
465470
})
466471

467-
return finalEncrypted
472+
return merged
468473
})
469474

470475
invalidateEffectiveDecryptedEnvCache({ userId })

0 commit comments

Comments
 (0)