Skip to content

Commit 94c5382

Browse files
committed
fix(network): scope callbacks and nested execution
1 parent 58b0b45 commit 94c5382

12 files changed

Lines changed: 218 additions & 17 deletions

File tree

apps/sim/app/o/[organizationId]/settings/navigation.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe('organization settings navigation', () => {
9393
'governance:access-control',
9494
'governance:sso',
9595
'governance:sessions',
96+
'governance:network',
9697
'governance:data-retention',
9798
'governance:data-drains',
9899
'sim-search:integrations',

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
5252
'audit-logs',
5353
'sso',
5454
'sessions',
55+
'network',
5556
'data-retention',
5657
'data-drains',
5758
'whitelabeling',

apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('unified settings navigation', () => {
4545
{ id: 'self-host', label: 'Self hosting', section: 'platform' },
4646
{ id: 'sso', label: 'Single sign-on', section: 'organization' },
4747
{ id: 'sessions', label: 'Session policies', section: 'organization' },
48+
{ id: 'network', label: 'Network', section: 'organization' },
4849
{ id: 'data-retention', label: 'Data retention', section: 'organization' },
4950
{ id: 'data-drains', label: 'Data drains', section: 'organization' },
5051
{ id: 'whitelabeling', label: 'White-labeling', section: 'organization' },
@@ -91,6 +92,7 @@ describe('unified settings navigation', () => {
9192
'whitelabeling',
9293
'sso',
9394
'sessions',
95+
'network',
9496
'data-retention',
9597
'data-drains',
9698
])

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
} from '@sim/testing'
88
import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
99
import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core/execution-limits'
10+
import {
11+
resolveCurrentOutboundRoute,
12+
runWithOutboundOrganization,
13+
} from '@/lib/core/network/context.server'
1014
import { OrchestrationError } from '@/lib/core/orchestration/types'
1115
import { getBlock } from '@/blocks/registry'
1216
import { BlockType } from '@/executor/constants'
@@ -28,6 +32,21 @@ const mockWorkflowLogger = vi.mocked(loggerMock.createLogger).mock.results[
2832
vi.mocked(createLogger).mock.calls.findIndex(([name]) => name === 'WorkflowBlockHandler')
2933
].value
3034

35+
const outboundMocks = vi.hoisted(() => ({
36+
enabled: vi.fn(() => false),
37+
workspace: vi.fn(),
38+
route: vi.fn(async (organizationId: string | null | undefined) => ({ organizationId })),
39+
}))
40+
vi.mock('@/lib/core/network/config.server', () => ({
41+
isOutboundRoutingEnabled: outboundMocks.enabled,
42+
resolveOutboundRoute: outboundMocks.route,
43+
}))
44+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
45+
loadActiveWorkspaceApplicationContext: outboundMocks.workspace,
46+
}))
47+
48+
beforeEach(() => outboundMocks.enabled.mockReturnValue(false))
49+
3150
const {
3251
mockExecutorExecute,
3352
mockCreateSnapshot,
@@ -632,7 +651,9 @@ describe('WorkflowBlockHandler', () => {
632651
expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled()
633652
})
634653

635-
it('resolves a source-scoped billing attribution for custom block children', async () => {
654+
it('resolves source billing and routing for custom block children', async () => {
655+
outboundMocks.enabled.mockReturnValue(true)
656+
outboundMocks.workspace.mockResolvedValue({ workspaceOrganizationId: 'source-org' })
636657
const consumerAttribution = { actorUserId: 'consumer-1', workspaceId: 'workspace-consumer' }
637658
const sourceAttribution = { actorUserId: 'owner-9', workspaceId: 'workspace-source' }
638659
const customBlock = {
@@ -688,9 +709,18 @@ describe('WorkflowBlockHandler', () => {
688709
}
689710
})
690711
mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } })
691-
mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } })
712+
mockExecutorExecute.mockImplementationOnce(async () => {
713+
await resolveCurrentOutboundRoute()
714+
expect(outboundMocks.route).toHaveBeenLastCalledWith('source-org')
715+
return { success: true, output: { data: 'ok' } }
716+
})
692717

693-
await handler.execute(ctx, customBlock, {})
718+
await runWithOutboundOrganization('consumer-org', async () => {
719+
await handler.execute(ctx, customBlock, {})
720+
await resolveCurrentOutboundRoute()
721+
expect(outboundMocks.route).toHaveBeenLastCalledWith('consumer-org')
722+
})
723+
expect(outboundMocks.workspace).toHaveBeenCalledExactlyOnceWith('workspace-source')
694724

695725
expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith(
696726
expect.objectContaining({

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { isRecordLike } from '@sim/utils/object'
55
import type { Variable, WorkflowState } from '@sim/workflow-types/workflow'
66
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
77
import { getExecutionDeadlineAt } from '@/lib/core/execution-limits'
8+
import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
89
import { asOrchestrationError } from '@/lib/core/orchestration/types'
10+
import { resourceScopeFromOwner } from '@/lib/core/resource-scope'
911
import { getExecutionEnvironment } from '@/lib/environment/utils'
1012
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
1113
import { readWorkflowDefinitionAsExecutor } from '@/lib/internal/workflows/read-definition'
@@ -908,7 +910,13 @@ export class WorkflowBlockHandler implements BlockHandler {
908910

909911
const startTime = performance.now()
910912

911-
const result = await subExecutor.execute(workflowId)
913+
const executeChild = () => subExecutor.execute(workflowId)
914+
const result = await (isCustomBlock
915+
? withResourceOutboundScope(
916+
resourceScopeFromOwner({ workspaceId: childWorkspaceId }),
917+
executeChild
918+
)
919+
: executeChild())
912920
const executionResult = this.toExecutionResult(result)
913921
const duration = performance.now() - startTime
914922

apps/sim/lib/core/network/fixtures/gateway-runtime.fixture.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
77
import { createGatewayDispatcher } from '@/lib/core/network/gateway.server'
88
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
99

10-
const [proxyPort, originPort] = process.argv.slice(2)
11-
if (!proxyPort || !originPort) throw new Error('Local fixture ports are required')
10+
const [proxyPort, originPort, certificatePath] = process.argv.slice(2)
11+
if (!proxyPort || !originPort || !certificatePath)
12+
throw new Error('Local fixture ports and certificate are required')
1213
const gateway = {
1314
id: 'synthetic',
1415
url: `https://127.0.0.1:${proxyPort}`,
1516
servername: 'gateway.invalid',
1617
generation: 'test',
1718
token: 'a'.repeat(48),
18-
ca: readFileSync(new URL('./gateway-test-cert.pem', import.meta.url), 'utf8'),
19+
ca: readFileSync(certificatePath, 'utf8'),
1920
}
2021
const dispatcher = createGatewayDispatcher(gateway, {
2122
profile: 'selfHostedService',

apps/sim/lib/core/network/gateway.server.test.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { execFile } from 'node:child_process'
21
/** @vitest-environment node */
3-
import { readFileSync } from 'node:fs'
2+
import { execFile } from 'node:child_process'
3+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
44
import { createServer as httpServer } from 'node:http'
55
import { createServer as httpsServer } from 'node:https'
66
import { type AddressInfo, connect, type Socket } from 'node:net'
7+
import { tmpdir } from 'node:os'
8+
import { join } from 'node:path'
79
import { getCACertificates, setDefaultCACertificates } from 'node:tls'
810
import { fileURLToPath } from 'node:url'
911
import { promisify } from 'node:util'
@@ -30,8 +32,10 @@ import {
3032
secureFetchWithPinnedIP,
3133
} from '@/lib/core/security/input-validation.server'
3234

33-
const cert = readFileSync(new URL('./fixtures/gateway-test-cert.pem', import.meta.url), 'utf8')
34-
const key = readFileSync(new URL('./fixtures/gateway-test-key.pem', import.meta.url), 'utf8')
35+
const certificateDirectory = mkdtempSync(join(tmpdir(), 'gateway-tls-'))
36+
const certificatePath = join(certificateDirectory, 'cert.pem')
37+
const keyPath = join(certificateDirectory, 'key.pem')
38+
let cert = ''
3539
const sockets = new Set<Socket>()
3640
const admissions: Array<{
3741
token: string | undefined
@@ -54,11 +58,11 @@ const origin = httpServer(async (req, res) => {
5458
res.end(req.method === 'POST' ? Buffer.concat(chunks) : 'reached')
5559
})
5660
const proxiedPorts = new Set<number>()
57-
const secureOrigin = httpsServer({ cert, key }, (req, res) => {
61+
const secureOrigin = httpsServer((req, res) => {
5862
res.setHeader('x-via-proxy', proxiedPorts.has(req.socket.remotePort ?? 0) ? 'yes' : 'no')
5963
res.end('tls reached')
6064
})
61-
const proxy = httpsServer({ cert, key })
65+
const proxy = httpsServer()
6266
proxy.on('connect', (req, socket, head) => {
6367
socket.on('error', () => {})
6468
const tlsSocket = req.socket as Socket & { servername?: string }
@@ -88,6 +92,29 @@ let originPort = 0
8892
let securePort = 0
8993
const trust = getCACertificates('default')
9094
beforeAll(async () => {
95+
await promisify(execFile)('openssl', [
96+
'req',
97+
'-x509',
98+
'-newkey',
99+
'rsa:2048',
100+
'-sha256',
101+
'-nodes',
102+
'-keyout',
103+
keyPath,
104+
'-out',
105+
certificatePath,
106+
'-days',
107+
'2',
108+
'-subj',
109+
'/CN=gateway.invalid',
110+
'-addext',
111+
'subjectAltName=DNS:gateway.invalid,DNS:localhost',
112+
'-addext',
113+
'extendedKeyUsage=serverAuth',
114+
])
115+
cert = readFileSync(certificatePath, 'utf8')
116+
const key = readFileSync(keyPath, 'utf8')
117+
for (const server of [secureOrigin, proxy]) server.setSecureContext({ cert, key })
91118
setDefaultCACertificates([...trust, cert])
92119
for (const server of [origin, secureOrigin, proxy]) {
93120
server.on('connection', (socket) => {
@@ -120,6 +147,7 @@ afterAll(async () => {
120147
)
121148
)
122149
setDefaultCACertificates(trust)
150+
rmSync(certificateDirectory, { recursive: true, force: true })
123151
})
124152
const url = () => `http://localhost:${originPort}`
125153
const options = { profile: 'selfHostedService' as const }
@@ -266,14 +294,13 @@ describe('organization gateways over real TLS CONNECT sockets', () => {
266294
fileURLToPath(new URL('./fixtures/gateway-runtime.fixture.ts', import.meta.url)),
267295
String((proxy.address() as AddressInfo).port),
268296
String(securePort),
297+
certificatePath,
269298
],
270299
{
271300
timeout: 15_000,
272301
env: {
273302
...process.env,
274-
NODE_EXTRA_CA_CERTS: fileURLToPath(
275-
new URL('./fixtures/gateway-test-cert.pem', import.meta.url)
276-
),
303+
NODE_EXTRA_CA_CERTS: certificatePath,
277304
OUTBOUND_ROUTING_SOURCE: 'env',
278305
OUTBOUND_ROUTING_CONFIG: JSON.stringify({
279306
schemaVersion: 1,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({
5+
enabled: vi.fn(() => true),
6+
workspace: vi.fn(),
7+
route: vi.fn(async (organizationId: string | null | undefined) => ({ organizationId })),
8+
}))
9+
vi.mock('@/lib/core/network/config.server', () => ({
10+
isOutboundRoutingEnabled: mocks.enabled,
11+
resolveOutboundRoute: mocks.route,
12+
}))
13+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
14+
loadActiveWorkspaceApplicationContext: mocks.workspace,
15+
}))
16+
17+
import {
18+
resolveCurrentOutboundRoute,
19+
runWithOutboundOrganization,
20+
} from '@/lib/core/network/context.server'
21+
import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
22+
23+
describe('canonical resource outbound scope', () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks()
26+
mocks.enabled.mockReturnValue(true)
27+
})
28+
29+
it.each(['publisher-org', null])(
30+
'uses current workspace ownership %s and restores its caller',
31+
async (organizationId) => {
32+
mocks.workspace.mockResolvedValue({ workspaceOrganizationId: organizationId })
33+
await runWithOutboundOrganization('caller-org', async () => {
34+
expect(
35+
await withResourceOutboundScope(
36+
{ kind: 'workspace', workspaceId: 'source-workspace' },
37+
resolveCurrentOutboundRoute
38+
)
39+
).toEqual({ organizationId })
40+
expect(await resolveCurrentOutboundRoute()).toEqual({ organizationId: 'caller-org' })
41+
})
42+
expect(mocks.workspace).toHaveBeenCalledExactlyOnceWith('source-workspace')
43+
}
44+
)
45+
46+
it('rejects a missing or archived workspace before executing provider work', async () => {
47+
mocks.workspace.mockResolvedValue(null)
48+
await expect(
49+
withResourceOutboundScope(
50+
{ kind: 'workspace', workspaceId: 'removed' },
51+
resolveCurrentOutboundRoute
52+
)
53+
).rejects.toMatchObject({ code: 'MISSING_SCOPE' })
54+
expect(mocks.route).not.toHaveBeenCalled()
55+
})
56+
57+
it('adds no workspace query when routing is unconfigured', async () => {
58+
mocks.enabled.mockReturnValue(false)
59+
const run = vi.fn(async () => 'done')
60+
expect(await withResourceOutboundScope({ kind: 'workspace', workspaceId: 'source' }, run)).toBe(
61+
'done'
62+
)
63+
expect(mocks.workspace).not.toHaveBeenCalled()
64+
expect(run).toHaveBeenCalledOnce()
65+
})
66+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { isOutboundRoutingEnabled } from '@/lib/core/network/config.server'
2+
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
3+
import { OutboundRoutingError } from '@/lib/core/network/routing'
4+
import type { ResourceScope } from '@/lib/core/resource-scope'
5+
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
6+
7+
/** Establishes routing after resource authorization, reloading current workspace ownership when needed. */
8+
export async function withResourceOutboundScope<T>(
9+
scope: ResourceScope,
10+
run: () => Promise<T>
11+
): Promise<T> {
12+
if (!isOutboundRoutingEnabled()) return run()
13+
if (scope.kind === 'organization') return runWithOutboundOrganization(scope.organizationId, run)
14+
const workspace = await loadActiveWorkspaceApplicationContext(scope.workspaceId)
15+
if (!workspace) throw new OutboundRoutingError('MISSING_SCOPE')
16+
return runWithOutboundOrganization(workspace.workspaceOrganizationId, run)
17+
}

apps/sim/lib/credential-groups/application/public-enrollment.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ const mocks = vi.hoisted(() => ({
2020
completeMcpOAuth: vi.fn(),
2121
startMcpOAuth: vi.fn(),
2222
startOAuth: vi.fn(),
23+
routingEnabled: vi.fn(() => false),
24+
workspace: vi.fn(),
25+
route: vi.fn(async (organizationId: string | null | undefined) => ({ organizationId })),
26+
}))
27+
28+
vi.mock('@/lib/core/network/config.server', () => ({
29+
isOutboundRoutingEnabled: mocks.routingEnabled,
30+
resolveOutboundRoute: mocks.route,
31+
}))
32+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
33+
loadActiveWorkspaceApplicationContext: mocks.workspace,
2334
}))
2435

2536
vi.mock('@/lib/organizations/settings-access', () => ({
@@ -53,6 +64,10 @@ vi.mock('@/lib/credential-groups/trigger', () => ({
5364
fireCredentialGroupTrigger: mocks.fireTrigger,
5465
}))
5566

67+
import {
68+
resolveCurrentOutboundRoute,
69+
runWithOutboundOrganization,
70+
} from '@/lib/core/network/context.server'
5671
import {
5772
completePublicCredentialGroupEnrollment,
5873
completePublicCredentialGroupMcpOAuth,
@@ -101,6 +116,7 @@ const oauthAttempt = {
101116
describe('public Credential Group enrollment application operations', () => {
102117
beforeEach(() => {
103118
vi.clearAllMocks()
119+
mocks.routingEnabled.mockReturnValue(false)
104120
mocks.bind.mockResolvedValue(undefined)
105121
mocks.memberAccess.mockResolvedValue({ isMember: false })
106122
mocks.searchAvailable.mockResolvedValue(true)
@@ -333,6 +349,33 @@ describe('public Credential Group enrollment application operations', () => {
333349
expect.objectContaining({ event: 'credential_reconnected', enrollmentStatus: 'completed' })
334350
)
335351
})
352+
it.each(['organization', 'workspace'] as const)(
353+
'scopes OAuth callbacks to the authorized %s and restores the caller',
354+
async (kind) => {
355+
mocks.routingEnabled.mockReturnValue(true)
356+
mocks.workspace.mockResolvedValue({ workspaceOrganizationId: 'workspace-org' })
357+
const owner =
358+
kind === 'organization'
359+
? { organizationId: 'org-1', workspaceId: undefined }
360+
: { workspaceId: 'workspace-1', organizationId: undefined }
361+
const completion = await mocks.completeOAuth()
362+
mocks.completeOAuth.mockImplementationOnce(async () => {
363+
await resolveCurrentOutboundRoute()
364+
return completion
365+
})
366+
await runWithOutboundOrganization('caller-org', async () => {
367+
await completePublicCredentialGroupOAuth.execute({
368+
principal: { ...principal, ...owner },
369+
input: { attempt: { ...oauthAttempt, ...owner }, code: 'code' },
370+
})
371+
expect(mocks.route).toHaveBeenLastCalledWith(
372+
kind === 'organization' ? 'org-1' : 'workspace-org'
373+
)
374+
await resolveCurrentOutboundRoute()
375+
expect(mocks.route).toHaveBeenLastCalledWith('caller-org')
376+
})
377+
}
378+
)
336379
it('rejects a consumed attempt after invitation rotation before exchanging its code', async () => {
337380
mocks.bind.mockRejectedValue(new Error('Invitation is invalid or expired'))
338381
await expect(

0 commit comments

Comments
 (0)