Skip to content

Commit 848b187

Browse files
committed
improvement(network): consolidate scoped request lifecycles
1 parent cfc24f3 commit 848b187

49 files changed

Lines changed: 2625 additions & 1965 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/platform/enterprise/security.mdx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ title: Security
33
description: Manage organization session policies and view configured outbound IP addresses
44
---
55

6-
import { Callout } from 'fumadocs-ui/components/callout'
7-
86
Organization owners and admins open **Settings → Security** to manage session policies and view outbound IP addresses. [Single sign-on](/platform/enterprise/sso) remains a separate settings page for identity providers, verified domains, and provisioning.
97

108
## Session policies
@@ -17,18 +15,10 @@ See [Session policies](/platform/enterprise/session-policies) for limits, defaul
1715

1816
## Outbound IP addresses
1917

20-
When an outbound gateway has been configured for your organization, this section lists the IPv4 addresses published by your deployment administrator. Use the copy button beside each address to copy it in `/32` format. Allowlist **every listed address** on the destination firewall.
21-
22-
This section is read-only. Viewing or copying addresses does not provision a gateway or change routing. Contact Sim support for hosted deployments, or your deployment administrator for self-hosted installations, to arrange routing and confirm which connections it covers.
18+
Copy your organization's configured addresses in `/32` format and allowlist **every listed address** on the destination firewall. These addresses apply to supported HTTPS connections from Sim and its background workers.
2319

24-
If dedicated IPs are not configured, the page says so. If settings cannot be loaded, use **Try again**. A paused-routing message means your administrator has blocked outbound routing.
25-
26-
<Callout type="warn">
27-
The listed addresses describe configured routing, not a successful connectivity test. Verify each required connection from both Sim and its background jobs before relying on the allowlist. Browser traffic and traffic originating inside external services do not use this gateway. The current mandatory-gateway mode also blocks unsupported transports, including remote sandbox creation and raw database connections; it does not silently send them through another network.
28-
</Callout>
20+
Contact Sim support or your deployment administrator to configure dedicated IPs and confirm connection coverage.
2921

3022
## Availability
3123

3224
On Sim Cloud, Security settings require an Enterprise organization and an owner or admin role. On self-hosted deployments, the outbound IP section is available to organization administrators; session controls appear only when session policies are enabled. See [self-hosted enterprise configuration](/platform/enterprise/self-hosted).
33-
34-
Existing links to the former **Session policies** and **Network** pages continue to open Security settings.

apps/docs/content/docs/search/gitlab.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Use a self-managed GitLab instance reachable by Sim over HTTPS. The administrato
2525

2626
The CSV path checks the token's identity and project access. It does not require administrator directory access or a custom admin role.
2727

28-
If your GitLab instance restricts inbound traffic by source IP, coordinate the allowlist before connecting. When dedicated routing is configured, organization admins can copy its published addresses from [Settings → Security → Outbound IP addresses](/platform/enterprise/security#outbound-ip-addresses). Confirm access from both the application and background sync jobs; seeing the addresses in Settings does not verify connectivity.
28+
If your GitLab instance restricts access by source IP, allowlist the configured addresses from [Settings → Security → Outbound IP addresses](/platform/enterprise/security#outbound-ip-addresses). Confirm connectivity from Sim and its background sync jobs before the initial sync.
2929

3030
<Callout type="warn">
3131
CSV files define access in Sim. Each mapped user listed for the selected project can read all of that source's indexed, non-confidential content. Sim does not infer that user's GitLab role or feature restrictions in this path. Include only users who should have that access, and replace the files whenever memberships or email mappings change.

apps/sim/app/api/cron/renew-subscriptions/route.test.ts

Lines changed: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,55 @@
33
*
44
* @vitest-environment node
55
*/
6+
7+
import { webhook } from '@sim/db/schema'
68
import {
79
authOAuthUtilsMock,
10+
authOAuthUtilsMockFns,
811
createMockRequest,
912
dbChainMockFns,
13+
queueTableRows,
1014
redisConfigMockFns,
1115
resetDbChainMock,
1216
} from '@sim/testing'
13-
import { sleep } from '@sim/utils/helpers'
1417
import { beforeEach, describe, expect, it, vi } from 'vitest'
1518

16-
const { mockVerifyCronAuth } = vi.hoisted(() => ({
19+
const mocks = vi.hoisted(() => ({
1720
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
21+
detached: vi.fn<(label: string, work: () => Promise<unknown>) => void>(),
22+
enabled: vi.fn(() => true),
23+
workspace: vi.fn(),
24+
route: vi.fn(async (organizationId: string | null | undefined) => ({ organizationId })),
25+
fetch: vi.fn<typeof fetch>(),
26+
credentialOwner: vi.fn(),
1827
}))
1928

2029
vi.mock('@/lib/auth/internal', () => ({
21-
verifyCronAuth: mockVerifyCronAuth,
30+
verifyCronAuth: mocks.mockVerifyCronAuth,
2231
}))
2332

2433
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
34+
vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.detached }))
35+
vi.mock('@/lib/core/network/config.server', () => ({
36+
isOutboundRoutingEnabled: mocks.enabled,
37+
resolveOutboundRoute: mocks.route,
38+
}))
39+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
40+
loadActiveWorkspaceApplicationContext: mocks.workspace,
41+
}))
42+
vi.mock('@/lib/core/security/input-validation.server', () => ({ outboundFetch: mocks.fetch }))
43+
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
44+
getCredentialOwner: mocks.credentialOwner,
45+
getNotificationUrl: () => 'https://example.com/api/webhooks/trigger/teams',
46+
}))
2547

26-
import { GET } from './route'
48+
import {
49+
resolveCurrentOutboundRoute,
50+
runWithOutboundOrganization,
51+
} from '@/lib/core/network/context.server'
52+
import { GET } from '@/app/api/cron/renew-subscriptions/route'
53+
54+
const NEW_EXPIRATION = '2030-01-04T00:00:00.000Z'
2755

2856
function createRequest() {
2957
return createMockRequest(
@@ -34,24 +62,59 @@ function createRequest() {
3462
)
3563
}
3664

37-
const flushMicrotasks = () => sleep(0)
65+
function expiringWebhook(id: string, workspaceId: string | null) {
66+
return {
67+
workspaceId,
68+
webhook: {
69+
id,
70+
workflowId: `workflow-${id}`,
71+
providerConfig: {
72+
triggerId: 'microsoftteams_chat_subscription',
73+
subscriptionExpiration: new Date(Date.now() + 60_000).toISOString(),
74+
credentialId: 'shared-credential',
75+
externalSubscriptionId: `subscription-${id}`,
76+
chatId: 'chat-1',
77+
},
78+
},
79+
}
80+
}
81+
82+
async function runBackground() {
83+
expect(mocks.detached).toHaveBeenCalledExactlyOnceWith(
84+
'teams-subscription-renewal',
85+
expect.any(Function)
86+
)
87+
await mocks.detached.mock.calls[0][1]()
88+
}
3889

3990
describe('Teams subscription renewal route (fire-and-forget)', () => {
4091
beforeEach(() => {
4192
vi.clearAllMocks()
4293
resetDbChainMock()
4394
redisConfigMockFns.mockAcquireLock.mockResolvedValue(true)
4495
redisConfigMockFns.mockReleaseLock.mockResolvedValue(true)
45-
mockVerifyCronAuth.mockReturnValue(null)
96+
mocks.mockVerifyCronAuth.mockReturnValue(null)
97+
mocks.enabled.mockReturnValue(true)
98+
mocks.workspace.mockResolvedValue({ workspaceOrganizationId: 'org-1' })
99+
mocks.credentialOwner.mockResolvedValue({ accountId: 'account-1', userId: 'credential-owner' })
100+
authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded.mockImplementation(async () => {
101+
await resolveCurrentOutboundRoute()
102+
return 'access-token'
103+
})
104+
mocks.fetch.mockImplementation(async () => {
105+
await resolveCurrentOutboundRoute()
106+
return Response.json({ expirationDateTime: NEW_EXPIRATION })
107+
})
46108
})
47109

48110
it('returns the auth error when cron auth fails', async () => {
49-
mockVerifyCronAuth.mockReturnValueOnce(new Response(null, { status: 401 }) as never)
111+
mocks.mockVerifyCronAuth.mockReturnValueOnce(new Response(null, { status: 401 }) as never)
50112

51113
const response = await GET(createRequest())
52114

53115
expect(response.status).toBe(401)
54116
expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled()
117+
expect(mocks.detached).not.toHaveBeenCalled()
55118
})
56119

57120
it('acknowledges with 202 and renews in the background after acquiring the lock', async () => {
@@ -67,7 +130,8 @@ describe('Teams subscription renewal route (fire-and-forget)', () => {
67130
{ reclaimOnFailure: true }
68131
)
69132

70-
await flushMicrotasks()
133+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
134+
await runBackground()
71135
expect(dbChainMockFns.select).toHaveBeenCalled()
72136
expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith(
73137
'teams-subscription-renewal-lock',
@@ -84,5 +148,120 @@ describe('Teams subscription renewal route (fire-and-forget)', () => {
84148
const data = await response.json()
85149
expect(data).toMatchObject({ status: 'skip' })
86150
expect(dbChainMockFns.select).not.toHaveBeenCalled()
151+
expect(mocks.detached).not.toHaveBeenCalled()
152+
})
153+
154+
it('scopes refresh and Graph calls by each canonical workspace, not the credential owner', async () => {
155+
queueTableRows(webhook, [
156+
expiringWebhook('first', 'workspace-1'),
157+
expiringWebhook('second', 'workspace-2'),
158+
])
159+
mocks.workspace
160+
.mockResolvedValueOnce({ workspaceOrganizationId: 'org-1' })
161+
.mockResolvedValueOnce({ workspaceOrganizationId: null })
162+
163+
await GET(createRequest())
164+
await runWithOutboundOrganization('caller-org', runBackground)
165+
166+
expect(mocks.workspace.mock.calls).toEqual([['workspace-1'], ['workspace-2']])
167+
expect(mocks.route.mock.calls).toEqual([['org-1'], ['org-1'], [null], [null]])
168+
expect(mocks.fetch.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
169+
['https://graph.microsoft.com/v1.0/subscriptions/subscription-first', 'PATCH'],
170+
['https://graph.microsoft.com/v1.0/subscriptions/subscription-second', 'PATCH'],
171+
])
172+
expect(dbChainMockFns.set).toHaveBeenCalledTimes(2)
173+
expect(await resolveCurrentOutboundRoute()).toEqual({ organizationId: undefined })
87174
})
175+
176+
it.each([404, 410])(
177+
'recreates an expired subscription through the same scope after Graph returns %s',
178+
async (status) => {
179+
queueTableRows(webhook, [expiringWebhook('expired', 'workspace-1')])
180+
mocks.fetch
181+
.mockImplementationOnce(async () => {
182+
await resolveCurrentOutboundRoute()
183+
return Response.json({ error: { message: 'Subscription expired' } }, { status })
184+
})
185+
.mockImplementationOnce(async () => {
186+
await resolveCurrentOutboundRoute()
187+
return Response.json({ id: 'replacement', expirationDateTime: NEW_EXPIRATION })
188+
})
189+
190+
await GET(createRequest())
191+
await runBackground()
192+
193+
expect(mocks.route.mock.calls).toEqual([['org-1'], ['org-1'], ['org-1']])
194+
expect(mocks.fetch).toHaveBeenLastCalledWith(
195+
'https://graph.microsoft.com/v1.0/subscriptions',
196+
expect.objectContaining({ method: 'POST' })
197+
)
198+
expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith({
199+
providerConfig: expect.objectContaining({
200+
externalSubscriptionId: 'replacement',
201+
subscriptionExpiration: NEW_EXPIRATION,
202+
}),
203+
updatedAt: expect.any(Date),
204+
})
205+
}
206+
)
207+
208+
it.each([null, 'removed-workspace'])(
209+
'skips unresolved workspace %s without provider calls and renews the next webhook',
210+
async (workspaceId) => {
211+
queueTableRows(webhook, [
212+
expiringWebhook('unresolved', workspaceId),
213+
expiringWebhook('valid', 'workspace-1'),
214+
])
215+
mocks.workspace.mockImplementation(async (id: string) =>
216+
id === 'workspace-1' ? { workspaceOrganizationId: 'org-1' } : null
217+
)
218+
219+
await GET(createRequest())
220+
await runBackground()
221+
222+
expect(authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded).toHaveBeenCalledOnce()
223+
expect(mocks.fetch).toHaveBeenCalledExactlyOnceWith(
224+
'https://graph.microsoft.com/v1.0/subscriptions/subscription-valid',
225+
expect.objectContaining({ method: 'PATCH' })
226+
)
227+
expect(dbChainMockFns.set).toHaveBeenCalledOnce()
228+
expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledOnce()
229+
}
230+
)
231+
232+
it('continues after a routed provider failure and releases the lock', async () => {
233+
queueTableRows(webhook, [
234+
expiringWebhook('failed', 'workspace-1'),
235+
expiringWebhook('valid', 'workspace-2'),
236+
])
237+
mocks.workspace
238+
.mockResolvedValueOnce({ workspaceOrganizationId: 'org-1' })
239+
.mockResolvedValueOnce({ workspaceOrganizationId: 'org-2' })
240+
mocks.fetch.mockImplementationOnce(async () => {
241+
await resolveCurrentOutboundRoute()
242+
throw new Error('Gateway unavailable')
243+
})
244+
245+
await GET(createRequest())
246+
await runBackground()
247+
248+
expect(mocks.route.mock.calls).toEqual([['org-1'], ['org-1'], ['org-2'], ['org-2']])
249+
expect(dbChainMockFns.set).toHaveBeenCalledOnce()
250+
expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledOnce()
251+
})
252+
253+
it.each(['workspace-1', null])(
254+
'renews legacy workspace %s without an extra lookup when routing is unconfigured',
255+
async (workspaceId) => {
256+
mocks.enabled.mockReturnValue(false)
257+
queueTableRows(webhook, [expiringWebhook('default', workspaceId)])
258+
259+
await GET(createRequest())
260+
await runBackground()
261+
262+
expect(mocks.workspace).not.toHaveBeenCalled()
263+
expect(mocks.fetch).toHaveBeenCalledOnce()
264+
expect(dbChainMockFns.set).toHaveBeenCalledOnce()
265+
}
266+
)
88267
})

0 commit comments

Comments
 (0)