Skip to content

Commit b552c94

Browse files
committed
fix(secrets): make env writes and their authorization one transaction
Two rounds of ordering fixes each produced the next finding, because the value upsert, the credential inserts, and the visibility flip were three separate commits with the disclosure check somewhere among them. Moving the check only changed which writes were stranded by a denial. The PUT now runs authorization and every write it guards in one transaction, so a denial rolls all of it back and there is no ordering left to get wrong. createWorkspaceEnvCredentials takes an executor to join it. upsertWorkspaceEnvVars had the same shape on the copilot tool path and now validates the requested visibility inside its own transaction, before the write, where the stored key set is exact. Authorization also share-locks the permissions and credential_member rows that grant the caller's access before reading them, so a concurrent revocation either is observed and denies, or waits for the transaction rather than racing the UPDATE. Two UI fixes from the same review: a renamed variable saves as delete-old plus create-new, so it now carries its visibility across the rename instead of silently reverting to secret; and variable drafts join allWorkspaceKeys, so personal-vs-workspace conflict detection no longer misses names drafted in the Variables section.
1 parent feb112c commit b552c94

5 files changed

Lines changed: 268 additions & 186 deletions

File tree

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

Lines changed: 38 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ const {
99
mockGetWorkspaceById,
1010
mockGetUserEntityPermissions,
1111
mockGetWorkspaceEnvKeyAdminAccess,
12-
mockAuthorizeVisibility,
1312
mockSetVisibility,
1413
mockCreateWorkspaceEnvCredentials,
14+
mockRecordAudit,
1515
MockVisibilityAccessError,
1616
} = vi.hoisted(() => ({
1717
mockGetPersonalEnvKeyRawAccess: vi.fn(),
1818
mockGetWorkspaceById: vi.fn(),
1919
mockGetUserEntityPermissions: vi.fn(),
2020
mockGetWorkspaceEnvKeyAdminAccess: vi.fn(),
21-
mockAuthorizeVisibility: vi.fn(),
2221
mockSetVisibility: vi.fn(),
2322
mockCreateWorkspaceEnvCredentials: vi.fn(),
23+
mockRecordAudit: vi.fn(),
2424
// Declared inside vi.hoisted: `vi.mock` factories hoist above module-scope
2525
// class declarations, so a plain `class` here is in its TDZ when the factory
2626
// runs and the mock module fails to initialize.
@@ -34,6 +34,16 @@ const {
3434
},
3535
}))
3636

37+
vi.mock('@/lib/core/security/encryption', () => ({
38+
encryptSecret: vi.fn(async (value: string) => ({ encrypted: `enc:${value}` })),
39+
}))
40+
41+
vi.mock('@sim/audit', () => ({
42+
recordAudit: mockRecordAudit,
43+
AuditAction: { ENVIRONMENT_UPDATED: 'environment.updated' },
44+
AuditResourceType: { ENVIRONMENT: 'environment' },
45+
}))
46+
3747
vi.mock('@/lib/workspaces/permissions/utils', () => ({
3848
getWorkspaceById: mockGetWorkspaceById,
3949
getUserEntityPermissions: mockGetUserEntityPermissions,
@@ -46,7 +56,6 @@ vi.mock('@/lib/credentials/environment', () => ({
4656
getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess,
4757
createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials,
4858
deleteWorkspaceEnvCredentials: vi.fn(),
49-
authorizeWorkspaceEnvVisibilityChange: mockAuthorizeVisibility,
5059
setWorkspaceEnvVisibility: mockSetVisibility,
5160
WorkspaceEnvVisibilityAccessError: MockVisibilityAccessError,
5261
}))
@@ -244,68 +253,54 @@ describe('PUT /api/workspaces/[id]/environment — visibility ordering', () => {
244253
}
245254

246255
/**
247-
* The ordering defect Greptile caught. A request mixing an allowed NEW key
248-
* with an unauthorized visibility flip used to commit the key and its
249-
* credential rows and only THEN return 403 — a rejected call that changed
250-
* workspace state and skipped its audit record.
256+
* A rejected request must change nothing. The value upsert, the credential
257+
* rows, and the visibility flip all run in ONE transaction now, so a denial
258+
* anywhere throws and rolls the rest back — no ordering left to get wrong.
251259
*/
252-
it('writes nothing when a visibility change is denied', async () => {
253-
mockAuthorizeVisibility.mockRejectedValue(new MockVisibilityAccessError(['STRIPE_KEY']))
260+
it('returns 403 and records no audit when a visibility change is denied', async () => {
261+
mockSetVisibility.mockRejectedValue(new MockVisibilityAccessError(['STRIPE_KEY']))
254262

255-
const { status } = await callPut({
263+
const { status, body } = await callPut({
256264
variables: { BRAND_NEW: 'v' },
257265
visibility: { STRIPE_KEY: 'variable' },
258266
})
259267

260268
expect(status).toBe(403)
261-
// The whole point: authorization ran before any write reached the database.
262-
expect(mockCreateWorkspaceEnvCredentials).not.toHaveBeenCalled()
263-
expect(mockSetVisibility).not.toHaveBeenCalled()
269+
expect(body.error).toContain('admin')
270+
// The audit record lives after the transaction, so a rejected request
271+
// cannot reach it — which is the observable proof nothing was committed.
272+
expect(mockRecordAudit).not.toHaveBeenCalled()
264273
})
265274

266-
it('re-decides authorization at write time rather than reusing the pre-flight', async () => {
267-
mockAuthorizeVisibility.mockResolvedValue([
268-
{ credentialId: 'c-1', envKey: 'SUPPORT_EMAIL', next: 'variable' },
269-
])
275+
/**
276+
* Both writes must be transaction-scoped. If either call moves back outside
277+
* the transaction it loses its `executor` and a denial in the other would
278+
* strand it committed — the exact failure this consolidation removed.
279+
*/
280+
it('runs the credential inserts and the visibility flip inside the transaction', async () => {
270281
mockSetVisibility.mockResolvedValue({ changedKeys: ['SUPPORT_EMAIL'] })
271282

272283
const { status } = await callPut({
273-
variables: {},
284+
variables: { NEW_KEY: 'v' },
274285
visibility: { SUPPORT_EMAIL: 'variable' },
275286
})
276287

277288
expect(status).toBe(200)
278-
expect(mockAuthorizeVisibility).toHaveBeenCalled()
279-
// The pre-flight's resolved credential IDs are deliberately NOT carried into
280-
// the write: the request the setter receives is the raw visibility map, so
281-
// access is re-checked against current membership next to the UPDATE.
289+
expect(mockCreateWorkspaceEnvCredentials).toHaveBeenCalledWith(
290+
expect.objectContaining({ executor: expect.anything() })
291+
)
282292
expect(mockSetVisibility).toHaveBeenCalledWith(
283293
expect.objectContaining({
284294
workspaceId: WORKSPACE_ID,
285295
actingUserId: 'u-1',
286296
updates: { SUPPORT_EMAIL: 'variable' },
297+
executor: expect.anything(),
287298
})
288299
)
289-
})
290-
291-
/**
292-
* The stale-authorization window. The pre-flight above passes, then the
293-
* caller's credential-admin access is revoked while the value writes run. The
294-
* write-time decision must still refuse — reusing the pre-flight result here
295-
* would let a former admin disclose a secret after losing the right to.
296-
*/
297-
it('returns 403 when access is revoked between the pre-flight and the write', async () => {
298-
mockAuthorizeVisibility.mockResolvedValue([
299-
{ credentialId: 'c-1', envKey: 'STRIPE_KEY', next: 'variable' },
300-
])
301-
mockSetVisibility.mockRejectedValue(new MockVisibilityAccessError(['STRIPE_KEY']))
302-
303-
const { status, body } = await callPut({
304-
variables: {},
305-
visibility: { STRIPE_KEY: 'variable' },
306-
})
307-
308-
expect(status).toBe(403)
309-
expect(body.error).toContain('admin')
300+
// Ordering matters: the disclosure change is applied last, after the writes
301+
// it must not be separated from.
302+
expect(mockCreateWorkspaceEnvCredentials.mock.invocationCallOrder[0]).toBeLessThan(
303+
mockSetVisibility.mock.invocationCallOrder[0]
304+
)
310305
})
311306
})

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

Lines changed: 82 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ 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'
1818
import {
19-
authorizeWorkspaceEnvVisibilityChange,
2019
createWorkspaceEnvCredentials,
2120
deleteWorkspaceEnvCredentials,
2221
type EnvVisibility,
@@ -250,118 +249,104 @@ export const PUT = withRouteHandler(
250249
)
251250
}
252251

253-
// Pre-flight, BEFORE any write. Running the check after the value upsert
254-
// let a request that mixes an allowed new key with an unauthorized flip
255-
// commit the key and its credential rows and THEN return 403 — a rejected
256-
// call that changed workspace state and skipped its audit record. The
257-
// decision is deliberately discarded: it is re-made adjacent to the UPDATE
258-
// below, so this pass only guarantees a denied request attempts no writes.
259-
if (visibility) {
260-
try {
261-
await authorizeWorkspaceEnvVisibilityChange({
262-
workspaceId,
263-
updates: visibility,
264-
actingUserId: userId,
265-
})
266-
} catch (error) {
267-
if (error instanceof WorkspaceEnvVisibilityAccessError) {
268-
logger.warn(`[${requestId}] Workspace env visibility change denied`, {
269-
workspaceId,
270-
userId,
271-
keys: error.keys,
272-
})
273-
return NextResponse.json({ error: error.message }, { status: 403 })
274-
}
275-
throw error
276-
}
277-
}
278-
279252
const encryptedIncoming = await Promise.all(
280253
Object.entries(variables).map(async ([key, value]) => {
281254
const { encrypted } = await encryptSecret(value)
282255
return [key, encrypted] as const
283256
})
284257
).then((entries) => Object.fromEntries(entries))
285258

286-
const { existingEncrypted, merged } = await db.transaction(async (tx) => {
287-
await tx.execute(
288-
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
289-
)
290-
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 0))`)
291-
292-
const [existingRow] = await tx
293-
.select()
294-
.from(workspaceEnvironment)
295-
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
296-
.limit(1)
297-
298-
const existing = ((existingRow?.variables as Record<string, string>) ?? {}) as Record<
299-
string,
300-
string
301-
>
302-
const mergedVars = { ...existing, ...encryptedIncoming }
259+
/**
260+
* One transaction for authorization AND every write it guards.
261+
*
262+
* The value upsert, the credential rows, and the visibility flip used to
263+
* be three separate commits with the disclosure check somewhere among
264+
* them, so a request that was ultimately rejected could still leave the
265+
* first writes committed and skip its audit record. Ordering the check
266+
* earlier only moved which writes were stranded. With all of it in one
267+
* transaction there is no ordering to get wrong: a denial throws and
268+
* every write in the request rolls back together.
269+
*
270+
* `setWorkspaceEnvVisibility` runs LAST, after the writes it must not be
271+
* separated from, and share-locks the rows granting the caller's access
272+
* before reading them — so a revocation committing mid-request either is
273+
* observed and denies, or waits for this transaction.
274+
*/
275+
let flippedKeys: string[] = []
276+
let existingEncrypted: Record<string, string>
277+
let merged: Record<string, string>
278+
try {
279+
;({ existingEncrypted, merged } = await db.transaction(async (tx) => {
280+
await tx.execute(
281+
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
282+
)
283+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 0))`)
284+
285+
const [existingRow] = await tx
286+
.select()
287+
.from(workspaceEnvironment)
288+
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
289+
.limit(1)
290+
291+
const existing = ((existingRow?.variables as Record<string, string>) ?? {}) as Record<
292+
string,
293+
string
294+
>
295+
const mergedVars = { ...existing, ...encryptedIncoming }
296+
297+
if (Object.keys(encryptedIncoming).length > 0) {
298+
await tx
299+
.insert(workspaceEnvironment)
300+
.values({
301+
id: generateId(),
302+
workspaceId,
303+
variables: mergedVars,
304+
createdAt: new Date(),
305+
updatedAt: new Date(),
306+
})
307+
.onConflictDoUpdate({
308+
target: [workspaceEnvironment.workspaceId],
309+
set: { variables: mergedVars, updatedAt: new Date() },
310+
})
311+
}
303312

304-
await tx
305-
.insert(workspaceEnvironment)
306-
.values({
307-
id: generateId(),
313+
// Derived from the stored map, not the credential rows: a legacy key
314+
// present in jsonb without a credential row is NOT new, and minting an
315+
// ACL for it would make the caller its secret-admin.
316+
await createWorkspaceEnvCredentials({
308317
workspaceId,
309-
variables: mergedVars,
310-
createdAt: new Date(),
311-
updatedAt: new Date(),
312-
})
313-
.onConflictDoUpdate({
314-
target: [workspaceEnvironment.workspaceId],
315-
set: { variables: mergedVars, updatedAt: new Date() },
318+
newKeys: Object.keys(variables).filter((k) => !(k in existing)),
319+
actingUserId: userId,
320+
visibilityByKey: visibility,
321+
executor: tx,
316322
})
317323

318-
return { existingEncrypted: existing, merged: mergedVars }
319-
})
320-
321-
invalidateEffectiveDecryptedEnvCache({ workspaceId })
322-
const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted))
323-
await createWorkspaceEnvCredentials({
324-
workspaceId,
325-
newKeys,
326-
actingUserId: userId,
327-
visibilityByKey: visibility,
328-
})
329-
330-
// Re-authorized here rather than reusing the pre-flight decision above.
331-
// That decision was made before the value writes and the credential
332-
// inserts, so acting on it would let an admin whose access was revoked in
333-
// the meantime still disclose a secret. The check runs adjacent to the
334-
// UPDATE inside one transaction, and reads permissions through `db` (not
335-
// `tx`) so it observes the latest committed membership rather than this
336-
// transaction's snapshot. Brand-new keys took their policy from
337-
// `visibilityByKey` on create, so only pre-existing keys can appear here.
338-
let flippedKeys: string[] = []
339-
if (visibility) {
340-
try {
341-
flippedKeys = (
342-
await db.transaction((tx) =>
343-
setWorkspaceEnvVisibility({
344-
workspaceId,
345-
updates: visibility,
346-
actingUserId: userId,
347-
executor: tx,
348-
})
349-
)
350-
).changedKeys
351-
} catch (error) {
352-
if (error instanceof WorkspaceEnvVisibilityAccessError) {
353-
logger.warn(`[${requestId}] Workspace env visibility change denied at apply`, {
324+
if (visibility) {
325+
const applied = await setWorkspaceEnvVisibility({
354326
workspaceId,
355-
userId,
356-
keys: error.keys,
357-
reason: 'access-revoked-mid-request',
327+
updates: visibility,
328+
actingUserId: userId,
329+
executor: tx,
358330
})
359-
return NextResponse.json({ error: error.message }, { status: 403 })
331+
flippedKeys = applied.changedKeys
360332
}
361-
throw error
333+
334+
return { existingEncrypted: existing, merged: mergedVars }
335+
}))
336+
} catch (error) {
337+
if (error instanceof WorkspaceEnvVisibilityAccessError) {
338+
logger.warn(`[${requestId}] Workspace env visibility change denied`, {
339+
workspaceId,
340+
userId,
341+
keys: error.keys,
342+
})
343+
// Nothing to undo: the denial rolled the whole transaction back.
344+
return NextResponse.json({ error: error.message }, { status: 403 })
362345
}
346+
throw error
363347
}
364-
if (flippedKeys.length > 0) invalidateEffectiveDecryptedEnvCache({ workspaceId })
348+
349+
invalidateEffectiveDecryptedEnvCache({ workspaceId })
365350

366351
recordAudit({
367352
workspaceId,

0 commit comments

Comments
 (0)