Skip to content

Commit b1103e3

Browse files
fix(copilot): return lock denials and stop bootstrapping a legacy secret's ACL
Two defects in the previous commits, both found by review. assertWorkflowMutable throws, and the three orchestration entry points awaited it outside any try/catch, so a locked workflow escaped as an exception instead of the { success: false } result the callers consume — performChatDeploy and the copilot deploy tools would have surfaced a generic 500 rather than a lock denial. performRevertToVersion already converted it; the three now do too, through a shared helper. upsertWorkspaceEnvVars derived newKeys for createWorkspaceEnvCredentials from the credential rows rather than the stored variables. A secret written before credential rows existed has no ACL, so overwriting it looked like adding a new key: it minted a credential and made the caller that secret's admin. The environment route derives newKeys from the locked jsonb read for exactly this reason, and now so does this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 92e1a3e commit b1103e3

4 files changed

Lines changed: 125 additions & 9 deletions

File tree

apps/sim/lib/environment/utils.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ const {
99
mockGetUserEntityPermissions,
1010
mockGetWorkspaceEnvKeyAdminAccess,
1111
mockRecordAudit,
12+
mockTx,
1213
} = vi.hoisted(() => ({
1314
mockCreateWorkspaceEnvCredentials: vi.fn(),
1415
mockEncryptSecret: vi.fn(),
1516
mockGetUserEntityPermissions: vi.fn(),
1617
mockGetWorkspaceEnvKeyAdminAccess: vi.fn(),
1718
mockRecordAudit: vi.fn(),
19+
mockTx: {
20+
execute: vi.fn(),
21+
select: vi.fn(),
22+
insert: vi.fn(),
23+
},
1824
}))
1925

2026
// vitest.setup.ts mocks this module globally; this suite tests the real one.
@@ -35,6 +41,11 @@ vi.mock('@/lib/credentials/environment', () => ({
3541
getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess,
3642
syncPersonalEnvCredentialsForUser: vi.fn(),
3743
}))
44+
vi.mock('@sim/db', () => ({
45+
db: {
46+
transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(mockTx)),
47+
},
48+
}))
3849
vi.mock('@/lib/workspaces/permissions/utils', () => ({
3950
checkWorkspaceAccess: vi.fn(),
4051
getUserEntityPermissions: mockGetUserEntityPermissions,
@@ -80,12 +91,23 @@ describe('upsertWorkspaceEnvVars', () => {
8091
expect(mockEncryptSecret).not.toHaveBeenCalled()
8192
})
8293

94+
function stubStoredVariables(variables: Record<string, string>) {
95+
mockTx.execute.mockResolvedValue(undefined)
96+
mockTx.select.mockReturnValue({
97+
from: () => ({ where: () => ({ limit: async () => [{ variables }] }) }),
98+
})
99+
mockTx.insert.mockReturnValue({
100+
values: () => ({ onConflictDoUpdate: async () => undefined }),
101+
})
102+
}
103+
83104
it('allows a key admin to rotate the key they administer', async () => {
84105
mockGetUserEntityPermissions.mockResolvedValue('write')
85106
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
86107
adminKeys: new Set(['STRIPE_KEY']),
87108
knownKeys: new Set(['STRIPE_KEY']),
88109
})
110+
stubStoredVariables({ STRIPE_KEY: 'old-cipher' })
89111

90112
await expect(
91113
upsertWorkspaceEnvVars('ws-1', { STRIPE_KEY: 'rotated' }, 'user-1')
@@ -103,6 +125,7 @@ describe('upsertWorkspaceEnvVars', () => {
103125
adminKeys: new Set<string>(),
104126
knownKeys: new Set(['STRIPE_KEY']),
105127
})
128+
stubStoredVariables({ STRIPE_KEY: 'old-cipher' })
106129

107130
await expect(
108131
upsertWorkspaceEnvVars('ws-1', { STRIPE_KEY: 'rotated' }, 'user-1')
@@ -115,4 +138,37 @@ describe('upsertWorkspaceEnvVars', () => {
115138
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
116139
expect(mockRecordAudit).not.toHaveBeenCalled()
117140
})
141+
142+
it('does not mint a credential for a legacy secret already in the stored map', async () => {
143+
// A secret written before credential rows existed has no ACL. Treating it as
144+
// new would create one and make the caller its secret-admin — the route
145+
// derives newKeys from the stored variables for exactly this reason.
146+
mockGetUserEntityPermissions.mockResolvedValue('admin')
147+
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
148+
adminKeys: new Set<string>(),
149+
knownKeys: new Set<string>(),
150+
})
151+
stubStoredVariables({ LEGACY_KEY: 'old-cipher' })
152+
153+
await upsertWorkspaceEnvVars('ws-1', { LEGACY_KEY: 'rotated' }, 'user-1')
154+
155+
expect(mockCreateWorkspaceEnvCredentials).toHaveBeenCalledWith(
156+
expect.objectContaining({ newKeys: [] })
157+
)
158+
})
159+
160+
it('mints a credential for a genuinely new key', async () => {
161+
mockGetUserEntityPermissions.mockResolvedValue('write')
162+
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
163+
adminKeys: new Set<string>(),
164+
knownKeys: new Set<string>(),
165+
})
166+
stubStoredVariables({})
167+
168+
await upsertWorkspaceEnvVars('ws-1', { BRAND_NEW: 'value' }, 'user-1')
169+
170+
expect(mockCreateWorkspaceEnvCredentials).toHaveBeenCalledWith(
171+
expect.objectContaining({ newKeys: ['BRAND_NEW'] })
172+
)
173+
})
118174
})

apps/sim/lib/environment/utils.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ export async function upsertWorkspaceEnvVars(
375375

376376
// Read-modify-write on a single jsonb column, so serialize against the
377377
// route's identically-locked transaction or concurrent writers lose keys.
378-
await db.transaction(async (tx) => {
378+
const existingEncrypted = await db.transaction(async (tx) => {
379379
await tx.execute(
380380
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
381381
)
@@ -386,10 +386,8 @@ export async function upsertWorkspaceEnvVars(
386386
.from(workspaceEnvironment)
387387
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
388388
.limit(1)
389-
const merged = {
390-
...((existingRow?.variables as Record<string, string>) || {}),
391-
...newlyEncrypted,
392-
}
389+
const existing = (existingRow?.variables as Record<string, string>) || {}
390+
const merged = { ...existing, ...newlyEncrypted }
393391

394392
await tx
395393
.insert(workspaceEnvironment)
@@ -404,10 +402,15 @@ export async function upsertWorkspaceEnvVars(
404402
target: [workspaceEnvironment.workspaceId],
405403
set: { variables: merged, updatedAt: new Date() },
406404
})
405+
406+
return existing
407407
})
408408

409409
invalidateEffectiveDecryptedEnvCache({ workspaceId })
410-
const newKeys = updatedKeys.filter((key) => !knownKeys.has(key))
410+
// Derived from the stored variables, not from the credential rows: a legacy
411+
// secret present in the jsonb map without a credential row is NOT new, and
412+
// minting an ACL for it would make the caller its secret-admin.
413+
const newKeys = updatedKeys.filter((key) => !(key in existingEncrypted))
411414
await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId })
412415

413416
recordAudit({

apps/sim/lib/workflows/orchestration/deploy.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
queueTableRows,
88
resetDbChainMock,
99
schemaMock,
10+
workflowAuthzMockFns,
1011
} from '@sim/testing'
1112
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
1213

@@ -97,9 +98,12 @@ vi.mock('@/lib/workflows/schedules', () => ({
9798
validateWorkflowSchedules: mockValidateWorkflowSchedules,
9899
}))
99100

101+
// Resolves to the global @sim/platform-authz/workflow mock, so instanceof matches.
102+
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
100103
import {
101104
performActivateVersion,
102105
performFullDeploy,
106+
performFullUndeploy,
103107
performRevertToVersion,
104108
} from '@/lib/workflows/orchestration/deploy'
105109

@@ -660,3 +664,38 @@ describe('performActivateVersion workspace event emission', () => {
660664
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
661665
})
662666
})
667+
668+
describe('mutation lock on the orchestration entry points', () => {
669+
const mockAssertMutable = workflowAuthzMockFns.mockAssertWorkflowMutable
670+
671+
beforeEach(() => {
672+
vi.clearAllMocks()
673+
resetDbChainMock()
674+
mockAssertMutable.mockRejectedValue(new WorkflowLockedError('Workflow is locked'))
675+
})
676+
677+
it.each([
678+
['performFullDeploy', () => performFullDeploy({ workflowId: 'wf-1', userId: 'user-1' })],
679+
['performFullUndeploy', () => performFullUndeploy({ workflowId: 'wf-1', userId: 'user-1' })],
680+
[
681+
'performActivateVersion',
682+
() => performActivateVersion({ workflowId: 'wf-1', version: 2, userId: 'user-1' }),
683+
],
684+
])('%s returns a lock denial instead of throwing', async (_name, call) => {
685+
// Callers like performChatDeploy and the copilot deploy tools consume the
686+
// result object; a throw surfaces as a generic 500 instead of a denial.
687+
const result = await call()
688+
689+
expect(result.success).toBe(false)
690+
expect(result.error).toContain('locked')
691+
expect(mockRecordAudit).not.toHaveBeenCalled()
692+
})
693+
694+
it('proceeds past the gate when the workflow is mutable', async () => {
695+
mockAssertMutable.mockResolvedValue(undefined)
696+
697+
await performFullUndeploy({ workflowId: 'wf-1', userId: 'user-1' })
698+
699+
expect(mockAssertMutable).toHaveBeenCalledWith('wf-1')
700+
})
701+
})

apps/sim/lib/workflows/orchestration/deploy.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,21 @@ export interface PerformFullDeployParams {
9696
actorId?: string
9797
}
9898

99+
/**
100+
* Resolves a mutation-lock denial to a message instead of throwing, so the entry
101+
* points below return their `{ success: false }` result shape rather than
102+
* surfacing a 500 to callers that expect one — matching `performRevertToVersion`.
103+
*/
104+
async function workflowLockDenial(workflowId: string): Promise<string | null> {
105+
try {
106+
await assertWorkflowMutable(workflowId)
107+
return null
108+
} catch (error) {
109+
if (error instanceof WorkflowLockedError) return error.message
110+
throw error
111+
}
112+
}
113+
99114
export interface PerformFullDeployResult {
100115
success: boolean
101116
deployedAt?: Date
@@ -121,7 +136,8 @@ export async function performFullDeploy(
121136

122137
// Backstop for every caller — routes may assert first to render their own 423,
123138
// but the copilot deploy tools call this directly.
124-
await assertWorkflowMutable(workflowId)
139+
const lockDenial = await workflowLockDenial(workflowId)
140+
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' }
125141

126142
const [workflowRecord] = await db
127143
.select()
@@ -462,7 +478,8 @@ export async function performFullUndeploy(
462478
const actorId = params.actorId ?? userId
463479
const requestId = params.requestId ?? generateRequestId()
464480

465-
await assertWorkflowMutable(workflowId)
481+
const lockDenial = await workflowLockDenial(workflowId)
482+
if (lockDenial) return { success: false, error: lockDenial }
466483

467484
const [workflowRecord] = await db
468485
.select()
@@ -574,7 +591,8 @@ export async function performActivateVersion(
574591
const actorId = params.actorId ?? userId
575592
const requestId = params.requestId ?? generateRequestId()
576593

577-
await assertWorkflowMutable(workflowId)
594+
const lockDenial = await workflowLockDenial(workflowId)
595+
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' }
578596

579597
const [versionRow] = await db
580598
.select({

0 commit comments

Comments
 (0)