Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
167 changes: 167 additions & 0 deletions apps/realtime/src/middleware/network-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { getIp } from '@better-auth/core/utils/ip'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
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>
}

/**
* Per-member throttle for socket-denial audit events (this is a separate
* process from the app, so it keeps its own map) — one event per member per
* window is enough to show who is blocked without a reconnect loop flooding
* the log.
*/
const DENIAL_AUDIT_WINDOW_MS = 5 * 60 * 1000
const lastDenialAuditAt = new Map<string, number>()

function recordSocketDenial(userId: string, organizationId: string, clientIp: string | null): void {
const last = lastDenialAuditAt.get(userId)
if (last && Date.now() - last < DENIAL_AUDIT_WINDOW_MS) return
lastDenialAuditAt.set(userId, Date.now())
recordAudit({
workspaceId: null,
actorId: userId,
action: AuditAction.ORG_IP_ACCESS_DENIED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: clientIp
? `Denied realtime connection from ${clientIp} by the IP allowlist`
: 'Denied realtime connection by the IP allowlist (client IP unresolvable)',
metadata: { clientIp, surface: 'realtime' },
})
}
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, then checks the member org's allowlist. Shares the
* app's posture: fail-closed on an unresolvable client IP (active policy +
* no derivable trusted IP → deny), fail-open on an unexpected/DB error.
*
* Plan gating is intentionally absent here — `apps/realtime` cannot import
* billing code. 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), which is the safe direction for a security control.
*/
export async function isSocketAllowedByNetworkPolicy(
userId: string,
handshake: HandshakeLike
): Promise<boolean> {
// Read at call time so the break-glass works without a realtime restart.
// Mirror the app's isTruthy() string semantics ('true'/'1') so the same env
// value disables enforcement identically across both processes.
const disableFlag = process.env.DISABLE_ORG_IP_ALLOWLIST
if (disableFlag === 'true' || disableFlag === '1') return true

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(', '))
}
// Trust only the trusted-proxy-resolved address — the same resolution the
// app uses. No fallback to the raw socket peer: matching the app's
// fail-closed-on-unresolvable-IP behavior is more important than salvaging
// a direct-connect address the app-side check would never have accepted.
const clientIp = getIp(
new Request('http://socket.internal/', { headers }),
IP_RESOLUTION_OPTIONS
)

if (!clientIp) {
logger.warn('Denying socket: network policy active but client IP unresolvable', {
userId,
Comment thread
waleedlatif1 marked this conversation as resolved.
organizationId,
})
recordSocketDenial(userId, organizationId, null)
return false
}
if (!isAddressAllowed(clientIp, allowlist)) {
recordSocketDenial(userId, organizationId, clientIp)
return false
}
return true
} catch (error) {
// Fail OPEN on an unexpected/DB error, matching the app-side network-policy
// loader — a transient blip must not lock members out of collaboration.
// The primary boundary (policy loaded, IP not allowed) stays fail-closed.
logger.error('Network policy check failed; allowing socket', { userId, error })
return true
}
}
Loading
Loading