Skip to content

Commit 686e61c

Browse files
committed
fix(secrets): authorize visibility changes before any write
Two review findings, both real. Ordering: the PUT committed the value upsert and credential rows, then called the visibility change, which can 403. A request mixing an allowed new key with an unauthorized flip therefore persisted half of itself and still failed — state changed by a rejected call, and the audit record for the successful part never written. setWorkspaceEnvVisibility splits into authorize and apply so the route can deny before anything reaches the database. Covered by a test that asserts no credential creation and no apply happen on the denied path. Silent no-op: visibilityByKey only reaches credential CREATION, so asking for a policy on an existing key was dropped while the call still reported success. That includes a variable -> secret remediation, the case where a false success is most harmful — a caller told it worked would never retry. Flipping an existing key needs the stricter disclosure gate this path does not perform, so it now throws instead of pretending. Also consumes the consolidated envVars contract: one kind where a secret is name-only and a non-secret carries its value.
1 parent 1373fe2 commit 686e61c

7 files changed

Lines changed: 257 additions & 61 deletions

File tree

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

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,29 @@ const {
99
mockGetWorkspaceById,
1010
mockGetUserEntityPermissions,
1111
mockGetWorkspaceEnvKeyAdminAccess,
12+
mockAuthorizeVisibility,
13+
mockApplyVisibility,
14+
mockCreateWorkspaceEnvCredentials,
15+
MockVisibilityAccessError,
1216
} = vi.hoisted(() => ({
1317
mockGetPersonalEnvKeyRawAccess: vi.fn(),
1418
mockGetWorkspaceById: vi.fn(),
1519
mockGetUserEntityPermissions: vi.fn(),
1620
mockGetWorkspaceEnvKeyAdminAccess: vi.fn(),
21+
mockAuthorizeVisibility: vi.fn(),
22+
mockApplyVisibility: vi.fn(),
23+
mockCreateWorkspaceEnvCredentials: vi.fn(),
24+
// Declared inside vi.hoisted: `vi.mock` factories hoist above module-scope
25+
// class declarations, so a plain `class` here is in its TDZ when the factory
26+
// runs and the mock module fails to initialize.
27+
MockVisibilityAccessError: class extends Error {
28+
keys: string[]
29+
constructor(keys: string[]) {
30+
super('You must be an admin of these secrets to change their visibility')
31+
this.name = 'WorkspaceEnvVisibilityAccessError'
32+
this.keys = keys
33+
}
34+
},
1735
}))
1836

1937
vi.mock('@/lib/workspaces/permissions/utils', () => ({
@@ -26,11 +44,14 @@ const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAn
2644
vi.mock('@/lib/credentials/environment', () => ({
2745
getPersonalEnvKeyRawAccess: mockGetPersonalEnvKeyRawAccess,
2846
getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess,
29-
createWorkspaceEnvCredentials: vi.fn(),
47+
createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials,
3048
deleteWorkspaceEnvCredentials: vi.fn(),
49+
authorizeWorkspaceEnvVisibilityChange: mockAuthorizeVisibility,
50+
applyWorkspaceEnvVisibilityChange: mockApplyVisibility,
51+
WorkspaceEnvVisibilityAccessError: MockVisibilityAccessError,
3152
}))
3253

33-
import { GET } from '@/app/api/workspaces/[id]/environment/route'
54+
import { GET, PUT } from '@/app/api/workspaces/[id]/environment/route'
3455

3556
const mockGetSession = authMockFns.mockGetSession
3657

@@ -202,3 +223,63 @@ describe('GET /api/workspaces/[id]/environment', () => {
202223
})
203224
})
204225
})
226+
227+
describe('PUT /api/workspaces/[id]/environment — visibility ordering', () => {
228+
beforeEach(() => {
229+
vi.clearAllMocks()
230+
mockGetSession.mockResolvedValue({ user: { id: 'u-1' } })
231+
mockGetUserEntityPermissions.mockResolvedValue('write')
232+
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
233+
adminKeys: new Set<string>(),
234+
knownKeys: new Set<string>(),
235+
variableKeys: new Set<string>(),
236+
})
237+
mockApplyVisibility.mockResolvedValue({ changedKeys: [] })
238+
})
239+
240+
async function callPut(body: unknown) {
241+
const request = createMockRequest('PUT', body)
242+
const response = await PUT(request, buildParams())
243+
return { status: response.status, body: await response.json() }
244+
}
245+
246+
/**
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.
251+
*/
252+
it('writes nothing when a visibility change is denied', async () => {
253+
mockAuthorizeVisibility.mockRejectedValue(new MockVisibilityAccessError(['STRIPE_KEY']))
254+
255+
const { status } = await callPut({
256+
variables: { BRAND_NEW: 'v' },
257+
visibility: { STRIPE_KEY: 'variable' },
258+
})
259+
260+
expect(status).toBe(403)
261+
// The whole point: authorization ran before any write reached the database.
262+
expect(mockCreateWorkspaceEnvCredentials).not.toHaveBeenCalled()
263+
expect(mockApplyVisibility).not.toHaveBeenCalled()
264+
})
265+
266+
it('authorizes before applying on the success path', async () => {
267+
mockAuthorizeVisibility.mockResolvedValue([
268+
{ credentialId: 'c-1', envKey: 'SUPPORT_EMAIL', next: 'variable' },
269+
])
270+
mockApplyVisibility.mockResolvedValue({ changedKeys: ['SUPPORT_EMAIL'] })
271+
272+
const { status } = await callPut({
273+
variables: {},
274+
visibility: { SUPPORT_EMAIL: 'variable' },
275+
})
276+
277+
expect(status).toBe(200)
278+
expect(mockAuthorizeVisibility).toHaveBeenCalled()
279+
expect(mockApplyVisibility).toHaveBeenCalledWith(
280+
expect.objectContaining({
281+
changes: [{ credentialId: 'c-1', envKey: 'SUPPORT_EMAIL', next: 'variable' }],
282+
})
283+
)
284+
})
285+
})

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

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ 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,
21+
authorizeWorkspaceEnvVisibilityChange,
1922
createWorkspaceEnvCredentials,
2023
deleteWorkspaceEnvCredentials,
2124
type EnvVisibility,
2225
getPersonalEnvKeyRawAccess,
2326
getWorkspaceEnvKeyAdminAccess,
24-
setWorkspaceEnvVisibility,
2527
WorkspaceEnvVisibilityAccessError,
2628
} from '@/lib/credentials/environment'
2729
import {
@@ -249,6 +251,31 @@ export const PUT = withRouteHandler(
249251
)
250252
}
251253

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[] = []
259+
if (visibility) {
260+
try {
261+
authorizedVisibilityChanges = 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+
252279
const encryptedIncoming = await Promise.all(
253280
Object.entries(variables).map(async ([key, value]) => {
254281
const { encrypted } = await encryptSecret(value)
@@ -300,34 +327,13 @@ export const PUT = withRouteHandler(
300327
visibilityByKey: visibility,
301328
})
302329

303-
// Flips are applied after the credential rows exist so a create-as-variable
304-
// in the same request lands on a row rather than silently no-opping. Only
305-
// pre-existing keys can flip; brand-new ones got their policy above.
306-
let flippedKeys: string[] = []
307-
if (visibility) {
308-
const existingKeyUpdates = Object.fromEntries(
309-
Object.entries(visibility).filter(([key]) => !newKeys.includes(key))
310-
)
311-
try {
312-
const { changedKeys } = await setWorkspaceEnvVisibility({
313-
workspaceId,
314-
updates: existingKeyUpdates,
315-
actingUserId: userId,
316-
})
317-
flippedKeys = changedKeys
318-
} catch (error) {
319-
if (error instanceof WorkspaceEnvVisibilityAccessError) {
320-
logger.warn(`[${requestId}] Workspace env visibility change denied`, {
321-
workspaceId,
322-
userId,
323-
keys: error.keys,
324-
})
325-
return NextResponse.json({ error: error.message }, { status: 403 })
326-
}
327-
throw error
328-
}
329-
if (flippedKeys.length > 0) invalidateEffectiveDecryptedEnvCache({ workspaceId })
330-
}
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+
})
336+
if (flippedKeys.length > 0) invalidateEffectiveDecryptedEnvCache({ workspaceId })
331337

332338
recordAudit({
333339
workspaceId,

apps/sim/lib/copilot/chat/workspace-context.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ describe('buildWorkspaceMd - connected integrations / credentials', () => {
125125
expect(md).toContain('## Environment Variables (2)')
126126
expect(md).toContain('- OPENAI_API_KEY')
127127
expect(md).toContain('- STRIPE_SECRET_KEY')
128-
expect(buildVfsSnapshot(data).envVars).toEqual(['OPENAI_API_KEY', 'STRIPE_SECRET_KEY'])
128+
expect(buildVfsSnapshot(data).envVars).toEqual([
129+
{ name: 'OPENAI_API_KEY' },
130+
{ name: 'STRIPE_SECRET_KEY' },
131+
])
129132
})
130133

131134
it('shows values for non-secret env vars and only names for secrets', () => {
@@ -141,8 +144,10 @@ describe('buildWorkspaceMd - connected integrations / credentials', () => {
141144
expect(md).toContain('- OPENAI_API_KEY\n')
142145
expect(md).not.toContain('OPENAI_API_KEY =')
143146

144-
// Carried as a struct kind so Go can diff a VALUE change, not just a name.
145-
expect(buildVfsSnapshot(data).nonSecretEnvVars).toEqual([
147+
// One kind carries both: a secret is name-only, a non-secret adds its value
148+
// so Go can diff a VALUE change and not just a name.
149+
expect(buildVfsSnapshot(data).envVars).toEqual([
150+
{ name: 'OPENAI_API_KEY' },
146151
{ name: 'SUPPORT_EMAIL', value: 'help@acme.com' },
147152
])
148153
})
@@ -151,7 +156,8 @@ describe('buildWorkspaceMd - connected integrations / credentials', () => {
151156
const data = baseData({ envVariables: ['OPENAI_API_KEY'] })
152157

153158
expect(buildWorkspaceMd(data)).not.toContain('non-secret')
154-
expect(buildVfsSnapshot(data).nonSecretEnvVars).toEqual([])
159+
// Every name still ships; none carries a value.
160+
expect(buildVfsSnapshot(data).envVars).toEqual([{ name: 'OPENAI_API_KEY' }])
155161
})
156162
})
157163

apps/sim/lib/copilot/chat/workspace-context.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -703,14 +703,17 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 {
703703
...(c.displayName ? { displayName: c.displayName } : {}),
704704
...(c.role ? { role: c.role } : {}),
705705
})),
706-
envVars: data.envVariables,
707-
// Carries values, unlike `envVars`. That is the point: a non-secret value
708-
// edit changes no NAME, so a name-only kind would emit no delta and leave
709-
// the model answering from a stale baseline.
710-
nonSecretEnvVars: (data.nonSecretEnvVariables ?? []).map((v) => ({
711-
name: v.name,
712-
value: v.value,
713-
})),
706+
// One kind for both. A secret carries its name and nothing else; a
707+
// non-secret also carries its value, which is what lets the differ report a
708+
// value edit — the name alone does not change, so a names-only shape would
709+
// emit no delta and leave the model answering from a stale baseline.
710+
envVars: (() => {
711+
const values = new Map((data.nonSecretEnvVariables ?? []).map((v) => [v.name, v.value]))
712+
return data.envVariables.map((name) => {
713+
const value = values.get(name)
714+
return value === undefined ? { name } : { name, value }
715+
})
716+
})(),
714717
customTools: (data.customTools ?? []).map((t) => ({ id: t.id, name: t.name })),
715718
customBlocks: (data.customBlocks ?? []).map((b) => ({
716719
type: b.type,

apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@
77
export interface VfsSnapshotV1 {
88
customBlocks?: VfsSnapshotV1CustomBlock[]
99
customTools?: VfsSnapshotV1NamedResource[]
10-
envVars?: string[]
10+
envVars?: VfsSnapshotV1EnvVar[]
1111
files?: VfsSnapshotV1File[]
1212
integrations?: VfsSnapshotV1Integration[]
1313
knowledgeBases?: VfsSnapshotV1KnowledgeBase[]
1414
mcpServers?: VfsSnapshotV1McpServer[]
1515
members?: VfsSnapshotV1Member[]
16-
nonSecretEnvVars?: VfsSnapshotV1NonSecretEnvVar[]
1716
sandboxes?: VfsSnapshotV1Sandbox[]
1817
skills?: VfsSnapshotV1Skill[]
1918
tables?: VfsSnapshotV1Table[]
@@ -37,6 +36,14 @@ export interface VfsSnapshotV1NamedResource {
3736
id: string
3837
name: string
3938
}
39+
/**
40+
* This interface was referenced by `VfsSnapshotV1`'s JSON-Schema
41+
* via the `definition` "VfsSnapshotV1EnvVar".
42+
*/
43+
export interface VfsSnapshotV1EnvVar {
44+
name: string
45+
value?: string
46+
}
4047
/**
4148
* This interface was referenced by `VfsSnapshotV1`'s JSON-Schema
4249
* via the `definition` "VfsSnapshotV1File".
@@ -88,14 +95,6 @@ export interface VfsSnapshotV1Member {
8895
name?: string
8996
permissionType?: string
9097
}
91-
/**
92-
* This interface was referenced by `VfsSnapshotV1`'s JSON-Schema
93-
* via the `definition` "VfsSnapshotV1NonSecretEnvVar".
94-
*/
95-
export interface VfsSnapshotV1NonSecretEnvVar {
96-
name: string
97-
value?: string
98-
}
9998
/**
10099
* This interface was referenced by `VfsSnapshotV1`'s JSON-Schema
101100
* via the `definition` "VfsSnapshotV1Sandbox".

0 commit comments

Comments
 (0)