Skip to content

Commit 7169f79

Browse files
committed
feat(network): add organization outbound routing
1 parent 3082de6 commit 7169f79

478 files changed

Lines changed: 8419 additions & 3214 deletions

File tree

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/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Sim Enterprise adds organization controls for access, provisioning, operations,
1111
| [Single sign-on](/platform/enterprise/sso) | Connect a SAML or OIDC identity provider |
1212
| [Verified domains](/platform/enterprise/verified-domains) | Prove domain ownership for SSO and provisioning |
1313
| [Directory provisioning](/platform/enterprise/scim) | Sync users and group-based access from your directory |
14+
| [Security](/platform/enterprise/security) | Manage session policies and view configured outbound IP addresses |
1415
| [Session policies](/platform/enterprise/session-policies) | Set session lifetimes and revoke member sessions |
1516
| [Audit logs](/platform/enterprise/audit-logs) | Investigate configuration and security events |
1617
| [Usage tracking](/platform/enterprise/usage-tracking) | Review usage by member, workspace, model, and source |

apps/docs/content/docs/platform/enterprise/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"sso",
66
"scim",
77
"verified-domains",
8+
"security",
89
"session-policies",
910
"access-control",
1011
"custom-blocks",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
title: Security
3+
description: Manage organization session policies and view configured outbound IP addresses
4+
---
5+
6+
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.
7+
8+
## Session policies
9+
10+
Set **Max session lifetime** and **Idle timeout** in hours, then select **Save**. Leave either field empty to use its default behavior. **Discard** restores your saved values.
11+
12+
**Sign out all members** opens a confirmation before revoking the organization's browser sessions, except your current session. It does not revoke API keys.
13+
14+
See [Session policies](/platform/enterprise/session-policies) for limits, defaults, and how changes affect existing sessions.
15+
16+
## Outbound IP addresses
17+
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.
19+
20+
Contact Sim support or your deployment administrator to configure dedicated IPs and confirm connection coverage.
21+
22+
## Availability
23+
24+
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).

apps/docs/content/docs/platform/enterprise/session-policies.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Session Policies let organization owners and admins on Enterprise plans control
1111

1212
## Setup
1313

14-
Go to **Settings → Organization → Session policies** in your organization settings.
14+
Go to **Settings → Security → Session policies** in your organization settings. The [Security page](/platform/enterprise/security) also shows configured outbound IP addresses; single sign-on remains a separate page.
1515

1616
Both limits are optional. Leave a field empty to keep the default behavior: sessions last 30 days and extend automatically while a member stays active.
1717

apps/docs/content/docs/platform/self-hosting/networking.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ A proxy body limit of 250 MB accommodates all three defaults. If you lower the a
302302
303303
## Outbound connectivity
304304
305-
The shared HTTP transport honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` on both Bun and Node. This covers guarded HTTP requests, pinned model-provider clients, and webhook providers using Sim's fetch adapter. It does not configure a global dispatcher or route non-HTTP protocols.
305+
The shared HTTP transport honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` on both Bun and Node. This covers guarded HTTP requests, model-provider clients using Sim's fetch adapter, and AWS clients using Sim's shared request handler. It does not configure a global dispatcher or route non-HTTP protocols.
306306
307307
Use an `http://` or `https://` proxy URL. For proxy authentication, both the username and password must be nonempty; partial credentials are refused. Lowercase variables take precedence over uppercase ones. `HTTP_PROXY` also applies to HTTPS when `HTTPS_PROXY` is unset. `NO_PROXY` matches the original destination hostname, with optional ports; it accepts exact names, domain suffixes, and `*`, but not CIDR ranges.
308308
@@ -313,14 +313,14 @@ An environment proxy is trusted deployment configuration, so its own address can
313313
| Outbound path | Honors `HTTP_PROXY` / `HTTPS_PROXY` |
314314
|---|---|
315315
| Shared guarded HTTP transport: tools, connectors, outbound webhooks, content fetches, MCP HTTP connections, and model-provider clients using Sim's fetch adapter | Yes, on Bun and Node, subject to the CONNECT requirements above |
316-
| Amazon Bedrock and AWS integration clients | No, these clients use their own AWS SDK transport |
316+
| Amazon Bedrock and AWS integration clients using Sim's shared request handler | Yes, on Bun and Node |
317317
| Object storage on Azure Blob and GCS; Azure Communication Services email | Yes, through the SDK's own proxy support |
318318
| Native `fetch` paths and SDKs that use it, including Resend email, the desktop update feed, and the `/api/telemetry` relay | Yes on Bun; Node requires [`NODE_USE_ENV_PROXY=1`](https://nodejs.org/api/cli.html#node_use_env_proxy1) (Node 22.21+ / 24.0+) |
319319
| Application object storage on S3; application email via Amazon SES | No, these clients use their own AWS SDK transport |
320320
| Email via SMTP, Postgres, and Redis | No, these use raw TCP |
321321
| OTLP export from the server SDK | No |
322322
323-
An explicit HTTP-block `proxyUrl` takes precedence over environment proxy settings.
323+
An assigned organization gateway takes precedence over environment proxy settings. An explicit HTTP-block `proxyUrl` takes precedence over environment settings on direct routes, and is refused when organization policy requires a gateway.
324324
325325
<Callout type="info">
326326
Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over HTTP, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ 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 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.
29+
2830
<Callout type="warn">
2931
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.
3032
</Callout>

apps/sim/app/api/auth/oauth/utils.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@
77
import { redisConfigMockFns } from '@sim/testing'
88
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99

10-
vi.mock('@/lib/oauth/oauth', () => ({
10+
vi.mock('@/lib/oauth/refresh-token.server', () => ({
1111
refreshOAuthToken: vi.fn(),
12-
OAUTH_PROVIDERS: {},
1312
}))
1413

1514
const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
@@ -30,14 +29,14 @@ import {
3029
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
3130
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
3231
} from '@/lib/credentials/client-credential-accounts/descriptors'
33-
import { refreshOAuthToken } from '@/lib/oauth'
3432
import {
3533
getCredential,
3634
refreshAccessTokenIfNeeded,
3735
refreshTokenIfNeeded,
3836
resolveServiceAccountToken,
3937
} from '@/lib/oauth/credential-service'
4038
import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination'
39+
import { refreshOAuthToken } from '@/lib/oauth/refresh-token.server'
4140
import {
4241
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
4342
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,

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

Lines changed: 189 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,57 @@
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', () => ({
43+
secureFetchWithValidation: mocks.fetch,
44+
}))
45+
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
46+
getCredentialOwner: mocks.credentialOwner,
47+
getNotificationUrl: () => 'https://example.com/api/webhooks/trigger/teams',
48+
}))
49+
50+
import {
51+
resolveCurrentOutboundRoute,
52+
runWithOutboundOrganization,
53+
} from '@/lib/core/network/context.server'
54+
import { GET } from '@/app/api/cron/renew-subscriptions/route'
2555

26-
import { GET } from './route'
56+
const NEW_EXPIRATION = '2030-01-04T00:00:00.000Z'
2757

2858
function createRequest() {
2959
return createMockRequest(
@@ -34,24 +64,59 @@ function createRequest() {
3464
)
3565
}
3666

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

3992
describe('Teams subscription renewal route (fire-and-forget)', () => {
4093
beforeEach(() => {
4194
vi.clearAllMocks()
4295
resetDbChainMock()
4396
redisConfigMockFns.mockAcquireLock.mockResolvedValue(true)
4497
redisConfigMockFns.mockReleaseLock.mockResolvedValue(true)
45-
mockVerifyCronAuth.mockReturnValue(null)
98+
mocks.mockVerifyCronAuth.mockReturnValue(null)
99+
mocks.enabled.mockReturnValue(true)
100+
mocks.workspace.mockResolvedValue({ workspaceOrganizationId: 'org-1' })
101+
mocks.credentialOwner.mockResolvedValue({ accountId: 'account-1', userId: 'credential-owner' })
102+
authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded.mockImplementation(async () => {
103+
await resolveCurrentOutboundRoute()
104+
return 'access-token'
105+
})
106+
mocks.fetch.mockImplementation(async () => {
107+
await resolveCurrentOutboundRoute()
108+
return Response.json({ expirationDateTime: NEW_EXPIRATION })
109+
})
46110
})
47111

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

51115
const response = await GET(createRequest())
52116

53117
expect(response.status).toBe(401)
54118
expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled()
119+
expect(mocks.detached).not.toHaveBeenCalled()
55120
})
56121

57122
it('acknowledges with 202 and renews in the background after acquiring the lock', async () => {
@@ -67,7 +132,8 @@ describe('Teams subscription renewal route (fire-and-forget)', () => {
67132
{ reclaimOnFailure: true }
68133
)
69134

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

0 commit comments

Comments
 (0)