Skip to content
Open
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
The table of contents is too big for display.
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
24 changes: 24 additions & 0 deletions apps/docs/content/docs/platform/enterprise/security.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
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. [Single sign-on](/platform/enterprise/sso) remains a separate settings page for identity providers, verified domains, and provisioning.

## 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.

## 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. The [Security page](/platform/enterprise/security) also shows configured outbound IP addresses; single sign-on remains a separate page.

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
6 changes: 3 additions & 3 deletions apps/docs/content/docs/platform/self-hosting/networking.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ A proxy body limit of 250 MB accommodates all three defaults. If you lower the a

## Outbound connectivity

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.
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.

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.

Expand All @@ -313,14 +313,14 @@ An environment proxy is trusted deployment configuration, so its own address can
| Outbound path | Honors `HTTP_PROXY` / `HTTPS_PROXY` |
|---|---|
| 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 |
| Amazon Bedrock and AWS integration clients | No, these clients use their own AWS SDK transport |
| Amazon Bedrock and AWS integration clients using Sim's shared request handler | Yes, on Bun and Node |
| Object storage on Azure Blob and GCS; Azure Communication Services email | Yes, through the SDK's own proxy support |
| 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+) |
| Application object storage on S3; application email via Amazon SES | No, these clients use their own AWS SDK transport |
| Email via SMTP, Postgres, and Redis | No, these use raw TCP |
| OTLP export from the server SDK | No |

An explicit HTTP-block `proxyUrl` takes precedence over environment proxy settings.
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.

<Callout type="info">
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.
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/search/gitlab.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Use a self-managed GitLab instance reachable by Sim over HTTPS. The administrato

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

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.

<Callout type="warn">
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.
</Callout>
Expand Down
5 changes: 2 additions & 3 deletions apps/sim/app/api/auth/oauth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@
import { redisConfigMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/oauth/oauth', () => ({
vi.mock('@/lib/oauth/refresh-token.server', () => ({
refreshOAuthToken: vi.fn(),
OAUTH_PROVIDERS: {},
}))

const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
Expand All @@ -30,14 +29,14 @@ import {
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
} from '@/lib/credentials/client-credential-accounts/descriptors'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getCredential,
refreshAccessTokenIfNeeded,
refreshTokenIfNeeded,
resolveServiceAccountToken,
} from '@/lib/oauth/credential-service'
import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination'
import { refreshOAuthToken } from '@/lib/oauth/refresh-token.server'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
Expand Down
197 changes: 189 additions & 8 deletions apps/sim/app/api/cron/renew-subscriptions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,57 @@
*
* @vitest-environment node
*/

import { webhook } from '@sim/db/schema'
import {
authOAuthUtilsMock,
authOAuthUtilsMockFns,
createMockRequest,
dbChainMockFns,
queueTableRows,
redisConfigMockFns,
resetDbChainMock,
} from '@sim/testing'
import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockVerifyCronAuth } = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
detached: vi.fn<(label: string, work: () => Promise<unknown>) => void>(),
enabled: vi.fn(() => true),
workspace: vi.fn(),
route: vi.fn(async (organizationId: string | null | undefined) => ({ organizationId })),
fetch: vi.fn<typeof fetch>(),
credentialOwner: vi.fn(),
}))

vi.mock('@/lib/auth/internal', () => ({
verifyCronAuth: mockVerifyCronAuth,
verifyCronAuth: mocks.mockVerifyCronAuth,
}))

vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.detached }))
vi.mock('@/lib/core/network/config.server', () => ({
isOutboundRoutingEnabled: mocks.enabled,
resolveOutboundRoute: mocks.route,
}))
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
loadActiveWorkspaceApplicationContext: mocks.workspace,
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
secureFetchWithValidation: mocks.fetch,
}))
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
getCredentialOwner: mocks.credentialOwner,
getNotificationUrl: () => 'https://example.com/api/webhooks/trigger/teams',
}))

import {
resolveCurrentOutboundRoute,
runWithOutboundOrganization,
} from '@/lib/core/network/context.server'
import { GET } from '@/app/api/cron/renew-subscriptions/route'

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

function createRequest() {
return createMockRequest(
Expand All @@ -34,24 +64,59 @@ function createRequest() {
)
}

const flushMicrotasks = () => sleep(0)
function expiringWebhook(id: string, workspaceId: string | null) {
return {
workspaceId,
webhook: {
id,
workflowId: `workflow-${id}`,
providerConfig: {
triggerId: 'microsoftteams_chat_subscription',
subscriptionExpiration: new Date(Date.now() + 60_000).toISOString(),
credentialId: 'shared-credential',
externalSubscriptionId: `subscription-${id}`,
chatId: 'chat-1',
},
},
}
}

async function runBackground() {
expect(mocks.detached).toHaveBeenCalledExactlyOnceWith(
'teams-subscription-renewal',
expect.any(Function)
)
await mocks.detached.mock.calls[0][1]()
}

describe('Teams subscription renewal route (fire-and-forget)', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
redisConfigMockFns.mockAcquireLock.mockResolvedValue(true)
redisConfigMockFns.mockReleaseLock.mockResolvedValue(true)
mockVerifyCronAuth.mockReturnValue(null)
mocks.mockVerifyCronAuth.mockReturnValue(null)
mocks.enabled.mockReturnValue(true)
mocks.workspace.mockResolvedValue({ workspaceOrganizationId: 'org-1' })
mocks.credentialOwner.mockResolvedValue({ accountId: 'account-1', userId: 'credential-owner' })
authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded.mockImplementation(async () => {
await resolveCurrentOutboundRoute()
return 'access-token'
})
mocks.fetch.mockImplementation(async () => {
await resolveCurrentOutboundRoute()
return Response.json({ expirationDateTime: NEW_EXPIRATION })
})
})

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

const response = await GET(createRequest())

expect(response.status).toBe(401)
expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled()
expect(mocks.detached).not.toHaveBeenCalled()
})

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

await flushMicrotasks()
expect(dbChainMockFns.select).not.toHaveBeenCalled()
await runBackground()
expect(dbChainMockFns.select).toHaveBeenCalled()
expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith(
'teams-subscription-renewal-lock',
Expand All @@ -84,5 +150,120 @@ describe('Teams subscription renewal route (fire-and-forget)', () => {
const data = await response.json()
expect(data).toMatchObject({ status: 'skip' })
expect(dbChainMockFns.select).not.toHaveBeenCalled()
expect(mocks.detached).not.toHaveBeenCalled()
})

it('scopes refresh and Graph calls by each canonical workspace, not the credential owner', async () => {
queueTableRows(webhook, [
expiringWebhook('first', 'workspace-1'),
expiringWebhook('second', 'workspace-2'),
])
mocks.workspace
.mockResolvedValueOnce({ workspaceOrganizationId: 'org-1' })
.mockResolvedValueOnce({ workspaceOrganizationId: null })

await GET(createRequest())
await runWithOutboundOrganization('caller-org', runBackground)

expect(mocks.workspace.mock.calls).toEqual([['workspace-1'], ['workspace-2']])
expect(mocks.route.mock.calls).toEqual([['org-1'], ['org-1'], [null], [null]])
expect(mocks.fetch.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['https://graph.microsoft.com/v1.0/subscriptions/subscription-first', 'PATCH'],
['https://graph.microsoft.com/v1.0/subscriptions/subscription-second', 'PATCH'],
])
expect(dbChainMockFns.set).toHaveBeenCalledTimes(2)
expect(await resolveCurrentOutboundRoute()).toEqual({ organizationId: undefined })
})

it.each([404, 410])(
'recreates an expired subscription through the same scope after Graph returns %s',
async (status) => {
queueTableRows(webhook, [expiringWebhook('expired', 'workspace-1')])
mocks.fetch
.mockImplementationOnce(async () => {
await resolveCurrentOutboundRoute()
return Response.json({ error: { message: 'Subscription expired' } }, { status })
})
.mockImplementationOnce(async () => {
await resolveCurrentOutboundRoute()
return Response.json({ id: 'replacement', expirationDateTime: NEW_EXPIRATION })
})

await GET(createRequest())
await runBackground()

expect(mocks.route.mock.calls).toEqual([['org-1'], ['org-1'], ['org-1']])
expect(mocks.fetch).toHaveBeenLastCalledWith(
'https://graph.microsoft.com/v1.0/subscriptions',
expect.objectContaining({ method: 'POST' })
)
expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith({
providerConfig: expect.objectContaining({
externalSubscriptionId: 'replacement',
subscriptionExpiration: NEW_EXPIRATION,
}),
updatedAt: expect.any(Date),
})
}
)

it.each([null, 'removed-workspace'])(
'skips unresolved workspace %s without provider calls and renews the next webhook',
async (workspaceId) => {
queueTableRows(webhook, [
expiringWebhook('unresolved', workspaceId),
expiringWebhook('valid', 'workspace-1'),
])
mocks.workspace.mockImplementation(async (id: string) =>
id === 'workspace-1' ? { workspaceOrganizationId: 'org-1' } : null
)

await GET(createRequest())
await runBackground()

expect(authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded).toHaveBeenCalledOnce()
expect(mocks.fetch).toHaveBeenCalledExactlyOnceWith(
'https://graph.microsoft.com/v1.0/subscriptions/subscription-valid',
expect.objectContaining({ method: 'PATCH' })
)
expect(dbChainMockFns.set).toHaveBeenCalledOnce()
expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledOnce()
}
)

it('continues after a routed provider failure and releases the lock', async () => {
queueTableRows(webhook, [
expiringWebhook('failed', 'workspace-1'),
expiringWebhook('valid', 'workspace-2'),
])
mocks.workspace
.mockResolvedValueOnce({ workspaceOrganizationId: 'org-1' })
.mockResolvedValueOnce({ workspaceOrganizationId: 'org-2' })
mocks.fetch.mockImplementationOnce(async () => {
await resolveCurrentOutboundRoute()
throw new Error('Gateway unavailable')
})

await GET(createRequest())
await runBackground()

expect(mocks.route.mock.calls).toEqual([['org-1'], ['org-1'], ['org-2'], ['org-2']])
expect(dbChainMockFns.set).toHaveBeenCalledOnce()
expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledOnce()
})

it.each(['workspace-1', null])(
'renews legacy workspace %s without an extra lookup when routing is unconfigured',
async (workspaceId) => {
mocks.enabled.mockReturnValue(false)
queueTableRows(webhook, [expiringWebhook('default', workspaceId)])

await GET(createRequest())
await runBackground()

expect(mocks.workspace).not.toHaveBeenCalled()
expect(mocks.fetch).toHaveBeenCalledOnce()
expect(dbChainMockFns.set).toHaveBeenCalledOnce()
}
)
})
Loading
Loading