Skip to content

Commit b07d8a5

Browse files
icecrasher321claude
andcommitted
fix(webhooks): route every background env resolution through the identity resolver
Review round 3 (cubic P1). `resolveBackgroundWebhookEnv` short-circuited to `getEffectiveDecryptedEnv` for the two cases with no second identity — a legacy workspaceless webhook, and a workspace with no billing account — which read the owner's variables without passing the resolver's suspension check. cubic flagged the first; the second was the same bypass one line down. Both now name the owner as both identities and go through the resolver, which produces the identical resolution while putting them behind the same gate. Also corrects `provider-subscriptions.test.ts`, which still asserted cleanup resolves via `getEffectiveDecryptedEnv`. That assertion was passing intermittently rather than failing outright: `mockGetEffectiveDecryptedEnv` is a shared singleton on `environmentUtilsMockFns`, so whether it had been called depended on which other files shared the worker. It now asserts the two-identity call, with the billing reader mocked so the split is actually exercised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1d33a91 commit b07d8a5

3 files changed

Lines changed: 62 additions & 21 deletions

File tree

apps/sim/lib/webhooks/env-resolver.test.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,21 +153,44 @@ describe('resolveBackgroundWebhookEnv', () => {
153153
expect(env).toEqual({ SHARED: 'workspace' })
154154
})
155155

156-
it('falls back to the single identity when the workspace has no billing account', async () => {
156+
/**
157+
* Both degenerate cases still go through the resolver, naming the owner as
158+
* both identities. Short-circuiting them to `getEffectiveDecryptedEnv` read the
159+
* owner's variables without passing the resolver's suspension check.
160+
*/
161+
it('names the owner as both identities when the workspace has no billing account', async () => {
157162
mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null)
158163

159164
const env = await resolveBackgroundWebhookEnv('owner-1', 'workspace-1')
160165

161-
expect(mockGetExecutionEnvironment).not.toHaveBeenCalled()
162-
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('owner-1', 'workspace-1')
163-
expect(env).toEqual({ FROM_SINGLE_IDENTITY: 'single' })
166+
expect(mockGetExecutionEnvironment).toHaveBeenCalledWith('owner-1', 'owner-1', 'workspace-1')
167+
expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
168+
expect(env).toEqual({ OWNER_KEY: 'owner-value', WORKSPACE_KEY: 'workspace-value' })
164169
})
165170

166-
it('resolves a workspaceless webhook against the owner alone', async () => {
171+
it('routes a workspaceless webhook through the resolver too', async () => {
167172
const env = await resolveBackgroundWebhookEnv('owner-1')
168173

169174
expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
170-
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('owner-1')
171-
expect(env).toEqual({ FROM_SINGLE_IDENTITY: 'single' })
175+
expect(mockGetExecutionEnvironment).toHaveBeenCalledWith('owner-1', 'owner-1', undefined)
176+
expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
177+
expect(env).toEqual({ OWNER_KEY: 'owner-value', WORKSPACE_KEY: 'workspace-value' })
178+
})
179+
180+
/** A suspended owner contributes nothing, including on the workspaceless path. */
181+
it('yields no personal variables for a suspended owner with no workspace', async () => {
182+
mockGetExecutionEnvironment.mockResolvedValue({
183+
personalDecrypted: {},
184+
workspaceDecrypted: {},
185+
})
186+
187+
const env = await resolveBackgroundWebhookEnv('suspended-owner')
188+
189+
expect(mockGetExecutionEnvironment).toHaveBeenCalledWith(
190+
'suspended-owner',
191+
'suspended-owner',
192+
undefined
193+
)
194+
expect(env).toEqual({})
172195
})
173196
})

apps/sim/lib/webhooks/env-resolver.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,26 @@ export interface WebhookEnvResolutionOptions {
2020
* because every caller here treats an unresolvable secret as a rejected request
2121
* rather than an error.
2222
*
23-
* Falls back to owner-as-both when the workspace has no billing account or no
24-
* workspace is involved at all, which is exactly the previous behavior.
23+
* Every case goes through {@link getExecutionEnvironment}, including the two
24+
* that have no second identity to split against — a legacy workspaceless
25+
* webhook, and a workspace with no billing account. Returning
26+
* `getEffectiveDecryptedEnv` directly for those read the owner's variables
27+
* without passing the resolver's suspension check, so the one arrangement that
28+
* still lent a suspended account's secrets was the one with the least going on.
29+
* Naming the owner as both identities keeps the resolution identical to what
30+
* those cases produced before while putting them behind the same gate.
2531
*/
2632
export async function resolveBackgroundWebhookEnv(
2733
workflowOwnerUserId: string,
2834
workspaceId?: string
2935
): Promise<Record<string, string>> {
30-
if (!workspaceId) {
31-
return getEffectiveDecryptedEnv(workflowOwnerUserId)
32-
}
33-
34-
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
35-
if (!billedAccountUserId) {
36-
return getEffectiveDecryptedEnv(workflowOwnerUserId, workspaceId)
37-
}
36+
const billedAccountUserId = workspaceId
37+
? await getWorkspaceBilledAccountUserId(workspaceId)
38+
: null
3839

3940
const snapshot = await getExecutionEnvironment(
4041
workflowOwnerUserId,
41-
billedAccountUserId,
42+
billedAccountUserId ?? workflowOwnerUserId,
4243
workspaceId
4344
)
4445
return { ...snapshot.personalDecrypted, ...snapshot.workspaceDecrypted }

apps/sim/lib/webhooks/provider-subscriptions.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@ import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing
66
import type { NextRequest } from 'next/server'
77
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
88

9-
const { mockGetEffectiveDecryptedEnv } = environmentUtilsMockFns
9+
const { mockGetEffectiveDecryptedEnv, mockGetExecutionEnvironment } = environmentUtilsMockFns
1010

1111
afterAll(resetEnvironmentUtilsMock)
1212

13-
const { mockGetProviderHandler } = vi.hoisted(() => ({
13+
const { mockGetProviderHandler, mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({
1414
mockGetProviderHandler: vi.fn(),
15+
mockGetWorkspaceBilledAccountUserId: vi.fn(),
1516
}))
1617

1718
vi.mock('@/lib/webhooks/providers', () => ({
1819
getProviderHandler: mockGetProviderHandler,
1920
}))
2021

22+
vi.mock('@/lib/billing/core/billing-attribution', () => ({
23+
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
24+
}))
25+
2126
import {
2227
cleanupExternalWebhook,
2328
createExternalWebhookSubscription,
@@ -128,8 +133,20 @@ describe('cleanupExternalWebhook', () => {
128133
beforeEach(() => {
129134
vi.clearAllMocks()
130135
mockGetEffectiveDecryptedEnv.mockResolvedValue({ CALENDLY_API_KEY: 'real-secret-key' })
136+
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billing-1')
137+
mockGetExecutionEnvironment.mockResolvedValue({
138+
personalDecrypted: {},
139+
workspaceDecrypted: { CALENDLY_API_KEY: 'real-secret-key' },
140+
})
131141
})
132142

143+
/**
144+
* Cleanup resolves through the same two-identity reader as the delivery that
145+
* created the subscription — owner for personal variables, the workspace
146+
* billing account for workspace ones. Reading both slices as the owner let a
147+
* non-admin owner without a credential grant leave `{{VAR}}` unresolved, and
148+
* the provider was handed the literal reference as its credential.
149+
*/
133150
it('resolves {{ENV_VAR}} references before deleting the provider subscription', async () => {
134151
const deleteSubscription = vi.fn().mockResolvedValue(undefined)
135152
mockGetProviderHandler.mockReturnValue({ deleteSubscription })
@@ -150,7 +167,7 @@ describe('cleanupExternalWebhook', () => {
150167

151168
await cleanupExternalWebhook(webhook, workflow, 'request-1')
152169

153-
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', 'workspace-1')
170+
expect(mockGetExecutionEnvironment).toHaveBeenCalledWith('user-1', 'billing-1', 'workspace-1')
154171
expect(deleteSubscription).toHaveBeenCalledWith(
155172
expect.objectContaining({
156173
webhook: expect.objectContaining({

0 commit comments

Comments
 (0)