Skip to content

Commit 2fac109

Browse files
committed
fix(secrets): re-decide visibility authorization at write time
The pre-flight authorization ran before the value upsert and the credential inserts, then its resolved credential IDs were applied afterwards. Access revoked in that window was never observed, so a former credential admin could still flip a secret to a workspace-visible variable. The flip now goes through setWorkspaceEnvVisibility inside its own transaction, which re-authorizes adjacent to the UPDATE. The pre-flight stays, with its result deliberately discarded, so a denied request still attempts no writes at all. applyWorkspaceEnvVisibilityChange is no longer exported — a decision carried across unrelated awaits is exactly what went stale, so there is no supported way to do that again.
1 parent 686e61c commit 2fac109

3 files changed

Lines changed: 94 additions & 27 deletions

File tree

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

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const {
1010
mockGetUserEntityPermissions,
1111
mockGetWorkspaceEnvKeyAdminAccess,
1212
mockAuthorizeVisibility,
13-
mockApplyVisibility,
13+
mockSetVisibility,
1414
mockCreateWorkspaceEnvCredentials,
1515
MockVisibilityAccessError,
1616
} = vi.hoisted(() => ({
@@ -19,7 +19,7 @@ const {
1919
mockGetUserEntityPermissions: vi.fn(),
2020
mockGetWorkspaceEnvKeyAdminAccess: vi.fn(),
2121
mockAuthorizeVisibility: vi.fn(),
22-
mockApplyVisibility: vi.fn(),
22+
mockSetVisibility: vi.fn(),
2323
mockCreateWorkspaceEnvCredentials: 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
@@ -47,7 +47,7 @@ vi.mock('@/lib/credentials/environment', () => ({
4747
createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials,
4848
deleteWorkspaceEnvCredentials: vi.fn(),
4949
authorizeWorkspaceEnvVisibilityChange: mockAuthorizeVisibility,
50-
applyWorkspaceEnvVisibilityChange: mockApplyVisibility,
50+
setWorkspaceEnvVisibility: mockSetVisibility,
5151
WorkspaceEnvVisibilityAccessError: MockVisibilityAccessError,
5252
}))
5353

@@ -234,7 +234,7 @@ describe('PUT /api/workspaces/[id]/environment — visibility ordering', () => {
234234
knownKeys: new Set<string>(),
235235
variableKeys: new Set<string>(),
236236
})
237-
mockApplyVisibility.mockResolvedValue({ changedKeys: [] })
237+
mockSetVisibility.mockResolvedValue({ changedKeys: [] })
238238
})
239239

240240
async function callPut(body: unknown) {
@@ -260,14 +260,14 @@ describe('PUT /api/workspaces/[id]/environment — visibility ordering', () => {
260260
expect(status).toBe(403)
261261
// The whole point: authorization ran before any write reached the database.
262262
expect(mockCreateWorkspaceEnvCredentials).not.toHaveBeenCalled()
263-
expect(mockApplyVisibility).not.toHaveBeenCalled()
263+
expect(mockSetVisibility).not.toHaveBeenCalled()
264264
})
265265

266-
it('authorizes before applying on the success path', async () => {
266+
it('re-decides authorization at write time rather than reusing the pre-flight', async () => {
267267
mockAuthorizeVisibility.mockResolvedValue([
268268
{ credentialId: 'c-1', envKey: 'SUPPORT_EMAIL', next: 'variable' },
269269
])
270-
mockApplyVisibility.mockResolvedValue({ changedKeys: ['SUPPORT_EMAIL'] })
270+
mockSetVisibility.mockResolvedValue({ changedKeys: ['SUPPORT_EMAIL'] })
271271

272272
const { status } = await callPut({
273273
variables: {},
@@ -276,10 +276,36 @@ describe('PUT /api/workspaces/[id]/environment — visibility ordering', () => {
276276

277277
expect(status).toBe(200)
278278
expect(mockAuthorizeVisibility).toHaveBeenCalled()
279-
expect(mockApplyVisibility).toHaveBeenCalledWith(
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.
282+
expect(mockSetVisibility).toHaveBeenCalledWith(
280283
expect.objectContaining({
281-
changes: [{ credentialId: 'c-1', envKey: 'SUPPORT_EMAIL', next: 'variable' }],
284+
workspaceId: WORKSPACE_ID,
285+
actingUserId: 'u-1',
286+
updates: { SUPPORT_EMAIL: 'variable' },
282287
})
283288
)
284289
})
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')
310+
})
285311
})

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

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,13 @@ 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-
type AuthorizedVisibilityChange,
20-
applyWorkspaceEnvVisibilityChange,
2119
authorizeWorkspaceEnvVisibilityChange,
2220
createWorkspaceEnvCredentials,
2321
deleteWorkspaceEnvCredentials,
2422
type EnvVisibility,
2523
getPersonalEnvKeyRawAccess,
2624
getWorkspaceEnvKeyAdminAccess,
25+
setWorkspaceEnvVisibility,
2726
WorkspaceEnvVisibilityAccessError,
2827
} from '@/lib/credentials/environment'
2928
import {
@@ -251,14 +250,15 @@ export const PUT = withRouteHandler(
251250
)
252251
}
253252

254-
// Authorized BEFORE any write. Running this after the value upsert let a
255-
// request that mixes an allowed new key with an unauthorized flip commit
256-
// the key and its credential rows and THEN return 403 — a rejected call
257-
// that changed workspace state and skipped its audit record.
258-
let authorizedVisibilityChanges: AuthorizedVisibilityChange[] = []
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.
259259
if (visibility) {
260260
try {
261-
authorizedVisibilityChanges = await authorizeWorkspaceEnvVisibilityChange({
261+
await authorizeWorkspaceEnvVisibilityChange({
262262
workspaceId,
263263
updates: visibility,
264264
actingUserId: userId,
@@ -327,12 +327,40 @@ export const PUT = withRouteHandler(
327327
visibilityByKey: visibility,
328328
})
329329

330-
// Already authorized above; this only writes. Brand-new keys took their
331-
// policy from `visibilityByKey` on create, so the authorized set only
332-
// ever contains pre-existing keys.
333-
const { changedKeys: flippedKeys } = await applyWorkspaceEnvVisibilityChange({
334-
changes: authorizedVisibilityChanges,
335-
})
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`, {
354+
workspaceId,
355+
userId,
356+
keys: error.keys,
357+
reason: 'access-revoked-mid-request',
358+
})
359+
return NextResponse.json({ error: error.message }, { status: 403 })
360+
}
361+
throw error
362+
}
363+
}
336364
if (flippedKeys.length > 0) invalidateEffectiveDecryptedEnvCache({ workspaceId })
337365

338366
recordAudit({

apps/sim/lib/credentials/environment.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -572,9 +572,16 @@ export async function authorizeWorkspaceEnvVisibilityChange(params: {
572572
/**
573573
* Applies changes already authorized by
574574
* {@link authorizeWorkspaceEnvVisibilityChange}. Performs no permission check of
575-
* its own — never call it with unauthorized input.
575+
* its own.
576+
*
577+
* Deliberately NOT exported. An authorization decision carried across unrelated
578+
* awaits goes stale — the caller's credential-admin or workspace-admin access
579+
* can be revoked in between, and applying by credential ID would then disclose a
580+
* secret to someone who has just lost the right to disclose it. Keeping this
581+
* private forces every caller through {@link setWorkspaceEnvVisibility}, which
582+
* decides and writes adjacently.
576583
*/
577-
export async function applyWorkspaceEnvVisibilityChange(params: {
584+
async function applyWorkspaceEnvVisibilityChange(params: {
578585
changes: AuthorizedVisibilityChange[]
579586
executor?: DbOrTx
580587
}): Promise<{ changedKeys: string[] }> {
@@ -601,8 +608,14 @@ export async function applyWorkspaceEnvVisibilityChange(params: {
601608
* admin on that specific key) rather than the workspace `write` that suffices
602609
* for editing a value.
603610
*
604-
* Authorizes then applies. Callers that must authorize BEFORE unrelated writes
605-
* should use the two halves directly.
611+
* Authorizes and applies adjacently, so the decision cannot go stale between the
612+
* two. Callers that also need to fail fast before unrelated writes can call
613+
* {@link authorizeWorkspaceEnvVisibilityChange} as a pre-flight and discard its
614+
* result — this function still re-decides authoritatively at write time.
615+
*
616+
* Pass `executor` to run the UPDATE inside a surrounding transaction. The
617+
* permission reads intentionally go through `db`, not that transaction, so they
618+
* observe the latest committed membership instead of the transaction's snapshot.
606619
*/
607620
export async function setWorkspaceEnvVisibility(params: {
608621
workspaceId: string

0 commit comments

Comments
 (0)