Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
60 changes: 60 additions & 0 deletions apps/docs/content/docs/en/platform/enterprise/ip-access.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: IP Access
description: Restrict sign-in and API access to your organization's allowed IP ranges
---

import { FAQ } from '@/components/ui/faq'

IP Access lets organization owners and admins on Enterprise plans restrict where members can use Sim from. When the allowlist is enabled, sign-in, session use, API-key requests, and live collaboration connections are only accepted from the listed addresses.

---

## Setup

Go to **Settings → Security → IP access** in your organization settings.

Add one entry per line — individual IPv4/IPv6 addresses or CIDR ranges, each with an optional label after a `#` (for example `203.0.113.7 # Office`, `10.0.0.0/16 # Frankfurt VPN`), up to 200 entries — then turn on **Restrict access by IP** and save.

The settings page shows your current IP. Saving a list that would exclude your own address is rejected, so you cannot lock yourself (and your organization) out in one step.

---

## What is enforced

- **Sign-in** — members outside the allowed ranges cannot establish a session.
- **Existing sessions** — every request re-checks the policy; members who move outside the allowed ranges lose access within about a minute of the policy changing.
- **API keys** — personal and workspace API keys belonging to organization members are only accepted from allowed addresses.
- **Live collaboration** — realtime canvas connections are checked at connect time.

Deployed chats, public form shares, webhooks, and scheduled executions are **not** restricted — they are your organization's outward-facing product surfaces and server-side automations, not member access. This scoping is intentional: those surfaces authenticate by their own means (share tokens, webhook signatures), and gating them on caller IP would break the product for its external audience.

Policy changes and denied access attempts are recorded in the [audit log](/platform/enterprise/audit-logs) — denials include the member and the blocked address, throttled per member so a polling client cannot flood the log.

---

## FAQ

<FAQ
items={[
{
question: 'What happens to a member who is signed in when the policy is enabled?',
answer:
'Their next request from a non-allowed address is rejected and they are effectively signed out. Changes propagate within about a minute.',
},
{
question: 'Can I lock myself out?',
answer:
'Not in a single save — the settings page rejects any list that excludes your current IP. If your network changes afterwards, another admin on an allowed network (or Sim support) can update the list.',
},
{
question: 'Does this affect deployed chats and webhooks?',
answer:
'No. The allowlist governs member access — sign-in, sessions, API keys, and collaboration. Deployed product surfaces and server-side automations keep working from anywhere.',
},
{
question: 'How are client addresses determined behind proxies?',
answer:
'Sim resolves the client address through its configured trusted proxy chain, so forwarded headers cannot be forged. Self-hosted deployments set AUTH_TRUSTED_PROXIES to their proxy addresses; if no trustworthy address can be derived while a policy is active, access is denied rather than guessed.',
},
]}
/>
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/platform/enterprise/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"index",
"sso",
"session-policies",
"ip-access",
"access-control",
"custom-blocks",
"whitelabeling",
Expand Down
9 changes: 9 additions & 0 deletions apps/realtime/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
import type { Socket } from 'socket.io'
import { ANONYMOUS_USER, ANONYMOUS_USER_ID, auth } from '@/auth'
import { isAuthDisabled } from '@/env'
import { isSocketAllowedByNetworkPolicy } from '@/middleware/network-policy'

const logger = createLogger('SocketAuth')

Expand Down Expand Up @@ -66,6 +67,14 @@ export async function authenticateSocket(socket: AuthenticatedSocket, next: (err
return next(new Error('Invalid session'))
}

const networkAllowed = await isSocketAllowedByNetworkPolicy(session.user.id, socket.handshake)
if (!networkAllowed) {
logger.warn(`Socket ${socket.id} rejected by org network policy`, {
userId: session.user.id,
})
return next(new Error('Access restricted by your organization network policy'))
}

// Store user info in socket for later use
socket.userId = session.user.id
socket.userName = session.user.name || session.user.email || 'Unknown User'
Expand Down
128 changes: 128 additions & 0 deletions apps/realtime/src/middleware/network-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { getIp } from '@better-auth/core/utils/ip'
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
buildIpResolutionOptions,
type CompiledAllowlist,
compileAllowlist,
isAddressAllowed,
parseTrustedProxies,
} from '@sim/platform-authz/network'
import { eq } from 'drizzle-orm'

const logger = createLogger('SocketNetworkPolicy')

/** How long a resolved org network policy is served from process memory. */
const POLICY_CACHE_TTL_MS = 60 * 1000

interface CacheEntry {
allowlist: CompiledAllowlist | null
fetchedAt: number
}

const policyCache = new Map<string, CacheEntry>()
const membershipCache = new Map<string, { organizationId: string | null; fetchedAt: number }>()

const IP_RESOLUTION_OPTIONS = buildIpResolutionOptions(
parseTrustedProxies(process.env.AUTH_TRUSTED_PROXIES)
)

/**
* Non-member results converge fast (matching the app-side membership cache)
* so a user who just joined an org through any path cannot dodge the policy
* for the full positive TTL.
*/
const NEGATIVE_MEMBERSHIP_CACHE_TTL_MS = 15 * 1000

async function getMemberOrganizationId(userId: string): Promise<string | null> {
const cached = membershipCache.get(userId)
if (cached) {
const ttl = cached.organizationId ? POLICY_CACHE_TTL_MS : NEGATIVE_MEMBERSHIP_CACHE_TTL_MS
if (Date.now() - cached.fetchedAt < ttl) return cached.organizationId
}
const [row] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
const organizationId = row?.organizationId ?? null
membershipCache.set(userId, { organizationId, fetchedAt: Date.now() })
return organizationId
Comment thread
waleedlatif1 marked this conversation as resolved.
}

async function getOrgAllowlist(organizationId: string): Promise<CompiledAllowlist | null> {
const cached = policyCache.get(organizationId)
if (cached && Date.now() - cached.fetchedAt < POLICY_CACHE_TTL_MS) {
return cached.allowlist
}
const [row] = await db
.select({ settings: organization.networkPolicySettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const ipAllowlist = row?.settings?.ipAllowlist
const allowlist =
ipAllowlist?.enabled && ipAllowlist.cidrs.length > 0
? compileAllowlist(ipAllowlist.cidrs)
: null
policyCache.set(organizationId, { allowlist, fetchedAt: Date.now() })
return allowlist
Comment thread
waleedlatif1 marked this conversation as resolved.
}

interface HandshakeLike {
headers: Record<string, string | string[] | undefined>
address: string
}
Comment thread
waleedlatif1 marked this conversation as resolved.

/**
* Socket-handshake counterpart of the app's org IP-allowlist enforcement.
* Resolves the client IP with Better Auth's trusted-proxy resolver from the
* handshake headers (falling back to the socket peer address when no
* forwarding headers are present), then checks the member org's allowlist.
* Fail-closed like the app side: an active policy with an unresolvable
* client IP denies the connection.
*
* Plan gating is intentionally absent here — `apps/realtime` cannot import
* billing code, and over-denial is the safe direction for a security
* control (the app-side check makes the opposite, fail-open call on DB
* errors because a blip must not lock an org out of the product). A
* downgraded org's stored-but-enabled policy therefore keeps gating sockets
* until the org disables it; the app is the sole writer of the settings.
*/
export async function isSocketAllowedByNetworkPolicy(
userId: string,
handshake: HandshakeLike
): Promise<boolean> {
// Read at call time so the break-glass works without a realtime restart.
if (process.env.DISABLE_ORG_IP_ALLOWLIST === 'true') return true
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated

try {
const organizationId = await getMemberOrganizationId(userId)
if (!organizationId) return true

const allowlist = await getOrgAllowlist(organizationId)
if (!allowlist) return true

const headers = new Headers()
for (const [key, value] of Object.entries(handshake.headers)) {
if (typeof value === 'string') headers.set(key, value)
else if (Array.isArray(value)) headers.set(key, value.join(', '))
}
const clientIp =
getIp(new Request('http://socket.internal/', { headers }), IP_RESOLUTION_OPTIONS) ??
(handshake.address || null)
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated

if (!clientIp) {
logger.warn('Denying socket: network policy active but client IP unresolvable', {
userId,
Comment thread
waleedlatif1 marked this conversation as resolved.
organizationId,
})
return false
}
return isAddressAllowed(clientIp, allowlist)
} catch (error) {
logger.error('Network policy check failed; denying socket', { userId, error })
return false
}
}
175 changes: 175 additions & 0 deletions apps/sim/app/api/organizations/[id]/network-policy/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @vitest-environment node
*/
import { member, organization } from '@sim/db/schema'
import {
createMockRequest,
dbChainMock,
dbChainMockFns,
queueTableRows,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession, mockIsEnterprise, mockGetTrustedClientIp, mockRecordAudit } = vi.hoisted(
() => ({
mockGetSession: vi.fn(),
mockIsEnterprise: vi.fn(),
mockGetTrustedClientIp: vi.fn(),
mockRecordAudit: vi.fn(),
})
)

vi.mock('@sim/db', () => dbChainMock)

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))

vi.mock('@/lib/auth/network-policy', () => ({
getTrustedClientIp: mockGetTrustedClientIp,
invalidateNetworkPolicyCache: vi.fn(),
}))

vi.mock('@/lib/auth/security-policy', () => ({
invalidateSecurityPolicyVersionCache: vi.fn(),
}))

vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: mockIsEnterprise,
}))

vi.mock('@/lib/core/config/env-flags', () => ({
isBillingEnabled: true,
}))

vi.mock('@sim/audit', () => ({
recordAudit: mockRecordAudit,
AuditAction: {
ORGANIZATION_NETWORK_POLICY_UPDATED: 'organization.network_policy.updated',
},
AuditResourceType: { ORGANIZATION: 'organization' },
}))

import { GET, PUT } from '@/app/api/organizations/[id]/network-policy/route'

const ORG_ID = 'org-1'
const routeContext = { params: Promise.resolve({ id: ORG_ID }) }

describe('network policy route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
session: { token: 'tok-1' },
})
mockIsEnterprise.mockResolvedValue(true)
mockGetTrustedClientIp.mockReturnValue('10.0.5.5')
})

describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)
const response = await GET(createMockRequest('GET'), routeContext)
expect(response.status).toBe(401)
})

it('returns the configured policy and caller IP for members', async () => {
queueTableRows(member, [{ id: 'member-1' }])
queueTableRows(organization, [
{ networkPolicySettings: { ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } } },
])
const response = await GET(createMockRequest('GET'), routeContext)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.data).toEqual({
isEnterprise: true,
configured: { enabled: true, cidrs: ['10.0.0.0/16'] },
callerIp: '10.0.5.5',
})
})
})

describe('PUT', () => {
function putRequest(body: unknown) {
return createMockRequest('PUT', body)
}

it('rejects non-admin members', async () => {
queueTableRows(member, [{ role: 'member' }])
const response = await PUT(
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } }),
routeContext
)
expect(response.status).toBe(403)
})

it('rejects malformed CIDR entries at the contract boundary', async () => {
queueTableRows(member, [{ role: 'owner' }])
const response = await PUT(
putRequest({ ipAllowlist: { enabled: true, cidrs: ['banana'] } }),
routeContext
)
expect(response.status).toBe(400)
})

it('rejects a list that would lock the caller out', async () => {
queueTableRows(member, [{ role: 'owner' }])
mockGetTrustedClientIp.mockReturnValue('203.0.113.7')
const response = await PUT(
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } }),
routeContext
)
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toContain('203.0.113.7')
})

it('rejects enabling when the caller IP is unresolvable (fail-closed)', async () => {
queueTableRows(member, [{ role: 'owner' }])
mockGetTrustedClientIp.mockReturnValue(null)
const response = await PUT(
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } }),
routeContext
)
expect(response.status).toBe(400)
})

it('saves an allowlist containing the caller IP and bumps the version', async () => {
queueTableRows(member, [{ role: 'owner' }])
queueTableRows(organization, [{ name: 'Acme' }])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])

const response = await PUT(
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16', '203.0.113.7'] } }),
routeContext
)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.data.configured).toEqual({
enabled: true,
cidrs: ['10.0.0.0/16', '203.0.113.7'],
})
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ securityPolicyVersion: expect.anything() })
)
expect(mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'organization.network_policy.updated' })
)
})

it('disabling skips the lockout guard entirely', async () => {
queueTableRows(member, [{ role: 'owner' }])
queueTableRows(organization, [{ name: 'Acme' }])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
mockGetTrustedClientIp.mockReturnValue(null)

const response = await PUT(
putRequest({ ipAllowlist: { enabled: false, cidrs: [] } }),
routeContext
)
expect(response.status).toBe(200)
})
})
})
Loading
Loading