Skip to content

Commit 09239ca

Browse files
authored
feat(network): add organization-specific HTTPS egress (#7807)
* feat(network): add organization-specific HTTPS egress * fix(network): reject missing gateway catalogs * fix(network): preserve transport and background execution behavior * test(network): remove unused runtime fixture argument * chore(network): keep routing result types private
1 parent c734e46 commit 09239ca

157 files changed

Lines changed: 5499 additions & 2444 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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.
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+
### Connection coverage
23+
24+
Dedicated routing supports public IPv4 destinations over HTTPS on port 443. It applies to supported Search connector API requests, HTTP request blocks, MCP and A2A connections, and HTTP requests made through Sim's local Function runtime. Background jobs use the same organization assignment.
25+
26+
Native database connections, AWS SDK integrations, remote sandbox traffic, and browser requests keep their existing network paths. Other provider SDKs and OAuth authorization or token refresh calls require separate coverage confirmation. Dedicated IPs do not change access permissions in connected services.
27+
28+
Google Drive, Fireflies, Google Workspace user and group discovery, and Atlassian OAuth site discovery currently use their existing network paths.
29+
30+
If dedicated routing is unavailable, affected requests fail instead of using shared IPs. Organizations without dedicated routing keep their existing behavior.
31+
32+
## Availability
33+
34+
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.
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/sim/app/api/auth/sso/register/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
99
import { auth, getSession } from '@/lib/auth'
1010
import { hasSSOAccess } from '@/lib/billing'
1111
import { isSsoEnabled } from '@/lib/core/config/env-flags'
12+
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
1213
import {
1314
secureFetchWithPinnedIP,
1415
validateUrlWithDNS,
@@ -369,7 +370,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
369370
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint
370371

371372
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
372-
const discoveryResult = await fetchOIDCDiscoveryDocument(discoveryUrl)
373+
const discoveryResult = await runWithOutboundOrganization(membership.organizationId, () =>
374+
fetchOIDCDiscoveryDocument(discoveryUrl)
375+
)
373376

374377
if (needsDiscovery) {
375378
logger.info('Fetching OIDC discovery document for missing endpoints', {

apps/sim/app/api/link-preview/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getLinkPreviewContract } from '@/lib/api/contracts/link-preview'
1010
import { parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { getRedisClient } from '@/lib/core/config/redis'
13+
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
1314
import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers'
1415
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -104,7 +105,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
104105

105106
let preview: LinkPreview = null
106107
try {
107-
preview = await fetchPreview(url)
108+
/** Link previews have no organization owner and use a shared URL cache. */
109+
preview = await runWithOutboundOrganization(null, () => fetchPreview(url))
108110
} catch (error) {
109111
logger.info('Link preview fetch failed; returning null preview', {
110112
host: new URL(url).hostname,

apps/sim/app/api/mcp/oauth/callback/route.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { NextResponse } from 'next/server'
88
import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
99
import { parseRequest } from '@/lib/api/server'
1010
import { getSession } from '@/lib/auth'
11+
import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
1112
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1213
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
1314
import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
@@ -238,11 +239,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
238239
const provider = new SimMcpOauthProvider({ row, preregistered })
239240
let result: Awaited<ReturnType<typeof mcpAuthGuarded>>
240241
try {
241-
result = await timedStep('mcpAuthGuarded', 120_000, () =>
242-
mcpAuthGuarded(provider, {
243-
serverUrl,
244-
authorizationCode: code,
245-
})
242+
result = await withResourceOutboundScope({ workspaceId: serverWorkspaceId }, () =>
243+
timedStep('mcpAuthGuarded', 120_000, () =>
244+
mcpAuthGuarded(provider, { serverUrl, authorizationCode: code })
245+
)
246246
)
247247
} catch (e) {
248248
logger.error('Token exchange failed during MCP OAuth callback', e)
@@ -267,7 +267,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
267267
try {
268268
// forceRefresh: skip any stale cache from before re-auth.
269269
await timedStep('discoverServerTools', 60_000, () =>
270-
mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force')
270+
withResourceOutboundScope({ workspaceId: serverWorkspaceId }, () =>
271+
mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force')
272+
)
271273
)
272274
} catch (e) {
273275
logger.warn('Post-auth tools refresh failed', toError(e).message)

apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { testDataDrainContract } from '@/lib/api/contracts/data-drains'
66
import { parseRequest } from '@/lib/api/server'
7+
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access'
910
import { getDestination } from '@/lib/data-drains/destinations/registry'
@@ -29,7 +30,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
2930
}
3031

3132
const destination = getDestination(drain.destinationType)
32-
if (!destination.test) {
33+
const testConnection = destination.test
34+
if (!testConnection) {
3335
return NextResponse.json(
3436
{ error: `Destination '${drain.destinationType}' does not support connection testing` },
3537
{ status: 400 }
@@ -44,7 +46,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
4446
const controller = new AbortController()
4547
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS)
4648
try {
47-
await destination.test({ config, credentials, signal: controller.signal })
49+
await runWithOutboundOrganization(drain.organizationId, () =>
50+
testConnection({ config, credentials, signal: controller.signal })
51+
)
4852
recordAudit({
4953
workspaceId: null,
5054
actorId: access.session.user.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 & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,6 @@ const AuditLogs = dynamic(() =>
5151
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
5252
)
5353
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
54-
const SessionPolicySettings = dynamic(() =>
55-
import('@/ee/session-policy/components/session-policy-settings').then(
56-
(m) => m.SessionPolicySettings
57-
)
58-
)
5954
const DataRetentionSettings = dynamic(() =>
6055
import('@/ee/data-retention/components/data-retention-settings').then(
6156
(m) => m.DataRetentionSettings
@@ -64,6 +59,9 @@ const DataRetentionSettings = dynamic(() =>
6459
const DataDrainsSettings = dynamic(() =>
6560
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
6661
)
62+
const OrganizationSecuritySettings = dynamic(() =>
63+
import('@/components/settings/organization-security').then((m) => m.OrganizationSecuritySettings)
64+
)
6765
const UsageMonitoring = dynamic(() =>
6866
import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
6967
)
@@ -113,7 +111,7 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
113111
/>
114112
)}
115113
{section === 'sso' && <SSO organizationId={organizationId} />}
116-
{section === 'sessions' && <SessionPolicySettings organizationId={organizationId} />}
114+
{section === 'security' && <OrganizationSecuritySettings organizationId={organizationId} />}
117115
{section === 'data-retention' && <DataRetentionSettings organizationId={organizationId} />}
118116
{section === 'data-drains' && <DataDrainsSettings organizationId={organizationId} />}
119117
{section === 'whitelabeling' && <WhitelabelingSettings organizationId={organizationId} />}

0 commit comments

Comments
 (0)