Skip to content

Commit 58b0b45

Browse files
committed
feat(network): add organization outbound routing
1 parent 64bce6a commit 58b0b45

298 files changed

Lines changed: 4420 additions & 1555 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/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,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getOrganizationNetworkContract } from '@/lib/api/contracts/organization-network'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
readOrganizationNetwork,
10+
readOrganizationNetworkOperation,
11+
} from '@/lib/core/network/application/read-organization-network'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
export const GET = defineInternalJsonRoute({
16+
contract: getOrganizationNetworkContract,
17+
auth: internalSessionAuth,
18+
operation: readOrganizationNetworkOperation,
19+
rateLimit: internalRateLimits.user({ bucketName: 'organization-network-read' }),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
mapInput: ({ params }) => ({ organizationId: params.id }),
22+
useCase: readOrganizationNetwork,
23+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
24+
})

‎apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ const DataRetentionSettings = dynamic(() =>
6464
const DataDrainsSettings = dynamic(() =>
6565
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
6666
)
67+
const OrganizationNetworkSettings = dynamic(() =>
68+
import('@/components/settings/organization-network').then((m) => m.OrganizationNetworkSettings)
69+
)
6770
const UsageMonitoring = dynamic(() =>
6871
import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
6972
)
@@ -114,6 +117,7 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
114117
)}
115118
{section === 'sso' && <SSO organizationId={organizationId} />}
116119
{section === 'sessions' && <SessionPolicySettings organizationId={organizationId} />}
120+
{section === 'network' && <OrganizationNetworkSettings organizationId={organizationId} />}
117121
{section === 'data-retention' && <DataRetentionSettings organizationId={organizationId} />}
118122
{section === 'data-drains' && <DataDrainsSettings organizationId={organizationId} />}
119123
{section === 'whitelabeling' && <WhitelabelingSettings organizationId={organizationId} />}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ const DataRetentionSettings = dynamic(() =>
106106
const DataDrainsSettings = dynamic(() =>
107107
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
108108
)
109+
const OrganizationNetworkSettings = dynamic(() =>
110+
import('@/components/settings/organization-network').then((m) => m.OrganizationNetworkSettings)
111+
)
109112
const UsageMonitoring = dynamic(() =>
110113
import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
111114
)
@@ -210,6 +213,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
210213
{effectiveSection === 'data-drains' && organizationId && (
211214
<DataDrainsSettings organizationId={organizationId} />
212215
)}
216+
{effectiveSection === 'network' && organizationId && (
217+
<OrganizationNetworkSettings organizationId={organizationId} />
218+
)}
213219
{effectiveSection === 'whitelabeling' && organizationId && (
214220
<WhitelabelingSettings organizationId={organizationId} />
215221
)}

‎apps/sim/components/settings/navigation.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ describe('settings navigation boundaries', () => {
117117
'self-host',
118118
'sso',
119119
'sessions',
120+
'network',
120121
'data-retention',
121122
'data-drains',
122123
'whitelabeling',
@@ -238,6 +239,7 @@ describe('settings navigation boundaries', () => {
238239
'audit-logs': false,
239240
sso: true,
240241
sessions: true,
242+
network: true,
241243
'data-retention': false,
242244
'data-drains': false,
243245
usage: true,
@@ -300,6 +302,7 @@ describe('settings navigation boundaries', () => {
300302
'connected-accounts',
301303
'data-drains',
302304
'data-retention',
305+
'network',
303306
'organization',
304307
'sessions',
305308
'sso',
@@ -320,6 +323,7 @@ describe('settings navigation boundaries', () => {
320323
'audit-logs': 'audit-logs',
321324
sso: 'sso',
322325
sessions: 'sessions',
326+
network: 'network',
323327
'data-retention': 'data-retention',
324328
'data-drains': 'data-drains',
325329
whitelabeling: 'whitelabeling',

‎apps/sim/components/settings/navigation.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type OrganizationSettingsSection =
5656
| 'audit-logs'
5757
| 'sso'
5858
| 'sessions'
59+
| 'network'
5960
| 'data-retention'
6061
| 'data-drains'
6162
| 'whitelabeling'
@@ -116,6 +117,7 @@ export type UnifiedSettingsSection =
116117
| 'sandboxes'
117118
| 'admin'
118119
| 'sessions'
120+
| 'network'
119121
| 'data-retention'
120122
| 'data-drains'
121123
| 'mothership'
@@ -715,6 +717,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
715717
organizationSection: 'sessions',
716718
},
717719
},
720+
{
721+
label: 'Network',
722+
icon: Globe,
723+
unified: {
724+
id: 'network',
725+
description: 'View outbound routing and the IP addresses to allow on your firewalls.',
726+
group: 'organization',
727+
order: 9,
728+
requiresHosted: true,
729+
requiresEnterprise: true,
730+
selfHostedOverride: 'always',
731+
organizationSection: 'network',
732+
},
733+
},
718734
{
719735
label: 'Data retention',
720736
icon: Database,
@@ -906,6 +922,7 @@ const ORGANIZATION_SECTION_GROUPS: Record<OrganizationSettingsSection, Organizat
906922
'access-control': 'governance',
907923
sso: 'governance',
908924
sessions: 'governance',
925+
network: 'governance',
909926
'data-retention': 'governance',
910927
'data-drains': 'governance',
911928
integrations: 'sim-search',
@@ -1049,6 +1066,7 @@ export function getOrganizationSettingsFeatures(
10491066
'audit-logs': features.auditLogs,
10501067
sso: features.sso,
10511068
sessions: features.sessionPolicies,
1069+
network: true,
10521070
'data-retention': features.dataRetention,
10531071
'data-drains': features.dataDrains,
10541072
usage: features.usageMonitoring,
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use client'
2+
3+
import { Chip, ChipTag, toast } from '@sim/emcn'
4+
import { Duplicate, RefreshCw } from '@sim/emcn/icons'
5+
import { SettingsPanel } from '@/components/settings/settings-panel'
6+
import type { OrganizationNetwork } from '@/lib/api/contracts/organization-network'
7+
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
8+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
9+
import { useOrganizationNetwork } from '@/hooks/queries/organization-network'
10+
11+
interface OrganizationNetworkSettingsProps {
12+
organizationId: string
13+
}
14+
15+
const MODE_LABELS = {
16+
direct: 'Default routing',
17+
gateway: 'Gateway configured',
18+
blocked: 'Routing paused',
19+
unavailable: 'Status unavailable',
20+
} as const
21+
22+
interface NetworkDetailsProps {
23+
data: OrganizationNetwork
24+
}
25+
26+
function NetworkDetails({ data }: NetworkDetailsProps) {
27+
if (data.mode === 'direct') {
28+
return (
29+
<p className='text-[var(--text-muted)] text-sm'>
30+
This organization uses the deployment’s default outbound network. Contact your platform
31+
administrator to arrange dedicated outbound IPs.
32+
</p>
33+
)
34+
}
35+
if (data.mode === 'blocked' || data.mode === 'unavailable') {
36+
return (
37+
<p role='status' className='text-[var(--text-muted)] text-sm'>
38+
{data.mode === 'blocked'
39+
? 'Outbound routing has been paused by your platform administrator.'
40+
: 'Routing configuration could not be verified. Contact your platform administrator.'}{' '}
41+
Required gateway requests do not fall back to the default network.
42+
</p>
43+
)
44+
}
45+
const { publicIps } = data
46+
async function copyAllowlist() {
47+
try {
48+
await navigator.clipboard.writeText(publicIps.map((ip) => `${ip}/32`).join('\n'))
49+
toast.success('IP allowlist copied')
50+
} catch {
51+
toast.error('Could not copy the IPs. Select and copy them below.')
52+
}
53+
}
54+
return (
55+
<>
56+
<SettingsSection
57+
label='Outbound IP addresses'
58+
action={
59+
publicIps.length > 0 ? (
60+
<Chip leftIcon={Duplicate} onClick={copyAllowlist}>
61+
Copy allowlist
62+
</Chip>
63+
) : undefined
64+
}
65+
>
66+
<div className='flex flex-col gap-3'>
67+
{data.publicIps.length > 0 ? (
68+
<ul className='flex flex-col gap-2'>
69+
{data.publicIps.map((ip) => (
70+
<li key={ip} className='font-mono text-[var(--text-body)] text-sm'>
71+
{ip}/32
72+
</li>
73+
))}
74+
</ul>
75+
) : (
76+
<p className='text-[var(--text-muted)] text-sm'>
77+
Your platform administrator has not published the gateway’s outbound IPs yet.
78+
</p>
79+
)}
80+
{data.region && <p className='text-[var(--text-muted)] text-sm'>Region: {data.region}</p>}
81+
<p className='text-[var(--text-muted)] text-sm'>
82+
Allow every listed address on destination firewalls. These are configured addresses;
83+
verify connectivity with an integration before relying on the allowlist.
84+
</p>
85+
</div>
86+
</SettingsSection>
87+
<SettingsSection label='Traffic coverage'>
88+
<p className='text-[var(--text-muted)] text-sm'>
89+
Supported HTTPS integration requests use this gateway from Sim and its background jobs.
90+
Browser traffic and traffic originating inside external services are outside this routing
91+
policy. Some transports, including remote sandboxes and database connections, are not
92+
supported when a gateway is required.
93+
</p>
94+
</SettingsSection>
95+
</>
96+
)
97+
}
98+
99+
export function OrganizationNetworkSettings({ organizationId }: OrganizationNetworkSettingsProps) {
100+
const { data, error, isPending, isFetching, refetch } = useOrganizationNetwork(organizationId)
101+
return (
102+
<SettingsPanel
103+
actions={[
104+
{
105+
id: 'refresh',
106+
text: 'Refresh',
107+
icon: RefreshCw,
108+
disabled: isFetching,
109+
onSelect: () => void refetch(),
110+
},
111+
]}
112+
>
113+
<div className='flex flex-col gap-7'>
114+
{isPending ? (
115+
<SettingsEmptyState variant='inline'>
116+
<span role='status'>Loading network settings…</span>
117+
</SettingsEmptyState>
118+
) : error ? (
119+
<SettingsEmptyState variant='inline' tone='error'>
120+
<span role='alert'>{error.message}</span>
121+
</SettingsEmptyState>
122+
) : data ? (
123+
<>
124+
<div>
125+
<ChipTag>{MODE_LABELS[data.mode]}</ChipTag>
126+
</div>
127+
<NetworkDetails data={data} />
128+
</>
129+
) : null}
130+
</div>
131+
</SettingsPanel>
132+
)
133+
}

‎apps/sim/connectors/fireflies/fireflies.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { z } from 'zod'
4+
import { outboundFetch } from '@/lib/core/network/fetch.server'
45
import { isPayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
56
import {
67
isRetryableError,
@@ -200,7 +201,7 @@ async function firefliesGraphQL(
200201
return retryWithExponentialBackoff(
201202
async () => {
202203
/** One retry layer owns transport, HTTP, and GraphQL semantic failures. */
203-
const response = await fetch(FIREFLIES_GRAPHQL_URL, {
204+
const response = await outboundFetch(FIREFLIES_GRAPHQL_URL, {
204205
method: 'POST',
205206
headers: {
206207
'Content-Type': 'application/json',

‎apps/sim/connectors/github/request.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHash } from 'node:crypto'
22
import { createLogger } from '@sim/logger'
3+
import { outboundFetch } from '@/lib/core/network/fetch.server'
34
import { acquireProviderCapacity } from '@/lib/core/rate-limiter/provider-capacity'
45
import {
56
type ProviderCapacityDeferralReason,
@@ -119,7 +120,7 @@ export async function fetchGitHubWithRetry(
119120
}
120121

121122
try {
122-
const response = await fetch(input, init)
123+
const response = await outboundFetch(input, init)
123124
quota = readRequestQuota(response.headers)
124125
let secondaryLimit = false
125126
let forbiddenBody: string | undefined

‎apps/sim/connectors/google-drive/google-drive-errors.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { outboundFetch } from '@/lib/core/network/fetch.server'
12
import {
23
attachRetryHeaders,
34
isRetryableError,
@@ -202,7 +203,7 @@ export async function fetchGoogleDriveWithRetry(
202203
): Promise<Response> {
203204
return retryWithExponentialBackoff(
204205
async () => {
205-
const response = await fetch(url, options)
206+
const response = await outboundFetch(url, options)
206207
if (response.ok) return response
207208

208209
const error = await readGoogleDriveApiError(response)

0 commit comments

Comments
 (0)