Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/content/docs/platform/enterprise/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Sim Enterprise adds organization controls for access, provisioning, operations,
| [Single sign-on](/platform/enterprise/sso) | Connect a SAML or OIDC identity provider |
| [Verified domains](/platform/enterprise/verified-domains) | Prove domain ownership for SSO and provisioning |
| [Directory provisioning](/platform/enterprise/scim) | Sync users and group-based access from your directory |
| [Security](/platform/enterprise/security) | Manage session policies and view configured outbound IP addresses |
| [Session policies](/platform/enterprise/session-policies) | Set session lifetimes and revoke member sessions |
| [Audit logs](/platform/enterprise/audit-logs) | Investigate configuration and security events |
| [Usage tracking](/platform/enterprise/usage-tracking) | Review usage by member, workspace, model, and source |
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/platform/enterprise/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"sso",
"scim",
"verified-domains",
"security",
"session-policies",
"access-control",
"custom-blocks",
Expand Down
34 changes: 34 additions & 0 deletions apps/docs/content/docs/platform/enterprise/security.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
title: Security
description: Manage organization session policies and view configured outbound IP addresses
---

Organization owners and admins open **Settings → Security** to manage session policies and view outbound IP addresses.

## Session policies

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.

**Sign out all members** opens a confirmation before revoking the organization's browser sessions, except your current session. It does not revoke API keys.

See [Session policies](/platform/enterprise/session-policies) for limits, defaults, and how changes affect existing sessions.

## Outbound IP addresses

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.

Contact Sim support or your deployment administrator to configure dedicated IPs and confirm connection coverage.

### Connection coverage

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.

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.

Google Drive, Fireflies, Google Workspace user and group discovery, and Atlassian OAuth site discovery currently use their existing network paths.

If dedicated routing is unavailable, affected requests fail instead of using shared IPs. Organizations without dedicated routing keep their existing behavior.

## Availability

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).
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Session Policies let organization owners and admins on Enterprise plans control

## Setup

Go to **Settings → Organization → Session policies** in your organization settings.
Go to **Settings → Security → Session policies** in your organization settings.

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.

Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/api/auth/sso/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { hasSSOAccess } from '@/lib/billing'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
Expand Down Expand Up @@ -369,7 +370,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint

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

if (needsDiscovery) {
logger.info('Fetching OIDC discovery document for missing endpoints', {
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/link-preview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getLinkPreviewContract } from '@/lib/api/contracts/link-preview'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getRedisClient } from '@/lib/core/config/redis'
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -104,7 +105,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {

let preview: LinkPreview = null
try {
preview = await fetchPreview(url)
/** Link previews have no organization owner and use a shared URL cache. */
preview = await runWithOutboundOrganization(null, () => fetchPreview(url))
} catch (error) {
logger.info('Link preview fetch failed; returning null preview', {
host: new URL(url).hostname,
Expand Down
14 changes: 8 additions & 6 deletions apps/sim/app/api/mcp/oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NextResponse } from 'next/server'
import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
Expand Down Expand Up @@ -238,11 +239,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const provider = new SimMcpOauthProvider({ row, preregistered })
let result: Awaited<ReturnType<typeof mcpAuthGuarded>>
try {
result = await timedStep('mcpAuthGuarded', 120_000, () =>
mcpAuthGuarded(provider, {
serverUrl,
authorizationCode: code,
})
result = await withResourceOutboundScope({ workspaceId: serverWorkspaceId }, () =>
timedStep('mcpAuthGuarded', 120_000, () =>
mcpAuthGuarded(provider, { serverUrl, authorizationCode: code })
)
)
} catch (e) {
logger.error('Token exchange failed during MCP OAuth callback', e)
Expand All @@ -267,7 +267,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
// forceRefresh: skip any stale cache from before re-auth.
await timedStep('discoverServerTools', 60_000, () =>
mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force')
withResourceOutboundScope({ workspaceId: serverWorkspaceId }, () =>
mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force')
)
)
} catch (e) {
logger.warn('Post-auth tools refresh failed', toError(e).message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { testDataDrainContract } from '@/lib/api/contracts/data-drains'
import { parseRequest } from '@/lib/api/server'
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access'
import { getDestination } from '@/lib/data-drains/destinations/registry'
Expand All @@ -29,7 +30,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
}

const destination = getDestination(drain.destinationType)
if (!destination.test) {
const testConnection = destination.test
if (!testConnection) {
return NextResponse.json(
{ error: `Destination '${drain.destinationType}' does not support connection testing` },
{ status: 400 }
Expand All @@ -44,7 +46,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS)
try {
await destination.test({ config, credentials, signal: controller.signal })
await runWithOutboundOrganization(drain.organizationId, () =>
testConnection({ config, credentials, signal: controller.signal })
)
recordAudit({
workspaceId: null,
actorId: access.session.user.id,
Expand Down
24 changes: 24 additions & 0 deletions apps/sim/app/api/organizations/[id]/network/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getOrganizationNetworkContract } from '@/lib/api/contracts/organization-network'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
readOrganizationNetwork,
readOrganizationNetworkOperation,
} from '@/lib/core/network/application/read-organization-network'

export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
contract: getOrganizationNetworkContract,
auth: internalSessionAuth,
operation: readOrganizationNetworkOperation,
rateLimit: internalRateLimits.user({ bucketName: 'organization-network-read' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: readOrganizationNetwork,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
})
10 changes: 4 additions & 6 deletions apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,6 @@ const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
const SessionPolicySettings = dynamic(() =>
import('@/ee/session-policy/components/session-policy-settings').then(
(m) => m.SessionPolicySettings
)
)
const DataRetentionSettings = dynamic(() =>
import('@/ee/data-retention/components/data-retention-settings').then(
(m) => m.DataRetentionSettings
Expand All @@ -64,6 +59,9 @@ const DataRetentionSettings = dynamic(() =>
const DataDrainsSettings = dynamic(() =>
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
)
const OrganizationSecuritySettings = dynamic(() =>
import('@/components/settings/organization-security').then((m) => m.OrganizationSecuritySettings)
)
const UsageMonitoring = dynamic(() =>
import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
)
Expand Down Expand Up @@ -113,7 +111,7 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
/>
)}
{section === 'sso' && <SSO organizationId={organizationId} />}
{section === 'sessions' && <SessionPolicySettings organizationId={organizationId} />}
{section === 'security' && <OrganizationSecuritySettings organizationId={organizationId} />}
{section === 'data-retention' && <DataRetentionSettings organizationId={organizationId} />}
{section === 'data-drains' && <DataDrainsSettings organizationId={organizationId} />}
{section === 'whitelabeling' && <WhitelabelingSettings organizationId={organizationId} />}
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/o/[organizationId]/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ describe('organization settings navigation', () => {
)
expect(resolveOrganizationSettingsSection('subscription')).toBe('billing')
expect(resolveOrganizationSettingsSection('domains')).toBe('sso')
expect(resolveOrganizationSettingsSection('sessions')).toBe('security')
expect(resolveOrganizationSettingsSection('/o/one/settings/network')).toBeNull()
expect(resolveOrganizationSettingsSection('skills')).toBeNull()
expect(buildOrganizationNavItems('org', true).map(({ id }) => id)).toEqual([
'home',
Expand All @@ -92,7 +94,7 @@ describe('organization settings navigation', () => {
'governance:audit-logs',
'governance:access-control',
'governance:sso',
'governance:sessions',
'governance:security',
'governance:data-retention',
'governance:data-drains',
'sim-search:integrations',
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/app/o/[organizationId]/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ export function resolveOrganizationSettingsSection(
path,
items: ORGANIZATION_SETTINGS_ITEMS,
defaultSection: null,
aliases: { organization: 'members', team: 'members', subscription: 'billing', domains: 'sso' },
aliases: {
organization: 'members',
team: 'members',
subscription: 'billing',
domains: 'sso',
sessions: 'security',
},
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
'access-control',
'audit-logs',
'sso',
'sessions',
'security',
'data-retention',
'data-drains',
'whitelabeling',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,6 @@ const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
const SessionPolicySettings = dynamic(() =>
import('@/ee/session-policy/components/session-policy-settings').then(
(m) => m.SessionPolicySettings
)
)
const DataRetentionSettings = dynamic(() =>
import('@/ee/data-retention/components/data-retention-settings').then(
(m) => m.DataRetentionSettings
Expand All @@ -106,6 +101,9 @@ const DataRetentionSettings = dynamic(() =>
const DataDrainsSettings = dynamic(() =>
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
)
const OrganizationSecuritySettings = dynamic(() =>
import('@/components/settings/organization-security').then((m) => m.OrganizationSecuritySettings)
)
const UsageMonitoring = dynamic(() =>
import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
)
Expand Down Expand Up @@ -201,15 +199,15 @@ export function SettingsPage({ section }: SettingsPageProps) {
/>
)}
{effectiveSection === 'sso' && organizationId && <SSO organizationId={organizationId} />}
{effectiveSection === 'sessions' && organizationId && (
<SessionPolicySettings key={organizationId} organizationId={organizationId} />
)}
{effectiveSection === 'data-retention' && organizationId && (
<DataRetentionSettings organizationId={organizationId} />
)}
{effectiveSection === 'data-drains' && organizationId && (
<DataDrainsSettings organizationId={organizationId} />
)}
{effectiveSection === 'security' && organizationId && (
<OrganizationSecuritySettings organizationId={organizationId} />
)}
{effectiveSection === 'whitelabeling' && organizationId && (
<WhitelabelingSettings organizationId={organizationId} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function SettingsQueryErrorState({
return (
<SettingsEmptyState variant={variant} tone='error'>
<div className='flex flex-col items-center gap-2'>
<span>{getErrorMessage(error, fallback)}</span>
<span role='alert'>{getErrorMessage(error, fallback)}</span>
<Chip variant='border' disabled={isRetrying} onClick={onRetry}>
{isRetrying ? 'Retrying…' : 'Try again'}
</Chip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('unified settings navigation', () => {
{ id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' },
{ id: 'self-host', label: 'Self hosting', section: 'platform' },
{ id: 'sso', label: 'Single sign-on', section: 'organization' },
{ id: 'sessions', label: 'Session policies', section: 'organization' },
{ id: 'security', label: 'Security', section: 'organization' },
{ id: 'data-retention', label: 'Data retention', section: 'organization' },
{ id: 'data-drains', label: 'Data drains', section: 'organization' },
{ id: 'whitelabeling', label: 'White-labeling', section: 'organization' },
Expand Down Expand Up @@ -90,7 +90,7 @@ describe('unified settings navigation', () => {
'audit-logs',
'whitelabeling',
'sso',
'sessions',
'security',
'data-retention',
'data-drains',
])
Expand Down Expand Up @@ -124,6 +124,7 @@ describe('resolveSettingsSection', () => {
team: 'organization',
'api-keys': 'apikeys',
domains: 'sso',
sessions: 'security',
} as const

it('keeps legacy section links working', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const SECTION_ALIASES: Readonly<Record<string, SettingsSection>> = {
'api-keys': 'apikeys',
/** Verified domains moved into the SSO page. */
domains: 'sso',
sessions: 'security',
}

export interface ResolvedSettingsSection {
Expand Down
Loading
Loading