Skip to content

Commit 91e2e4d

Browse files
committed
feat(auth): org IP allowlisting — member network restrictions across sign-in, sessions, API keys, realtime
1 parent 2b5a92a commit 91e2e4d

28 files changed

Lines changed: 18765 additions & 6 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: IP Access
3+
description: Restrict sign-in and API access to your organization's allowed IP ranges
4+
---
5+
6+
import { FAQ } from '@/components/ui/faq'
7+
8+
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.
9+
10+
---
11+
12+
## Setup
13+
14+
Go to **Settings → Security → IP access** in your organization settings.
15+
16+
Add one entry per line — individual IPv4/IPv6 addresses or CIDR ranges (for example `203.0.113.7`, `10.0.0.0/16`, `2001:db8::/48`), up to 200 entries — then turn on **Restrict access by IP** and save.
17+
18+
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.
19+
20+
---
21+
22+
## What is enforced
23+
24+
- **Sign-in** — members outside the allowed ranges cannot establish a session.
25+
- **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.
26+
- **API keys** — personal and workspace API keys belonging to organization members are only accepted from allowed addresses.
27+
- **Live collaboration** — realtime canvas connections are checked at connect time.
28+
29+
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.
30+
31+
---
32+
33+
## FAQ
34+
35+
<FAQ
36+
items={[
37+
{
38+
question: 'What happens to a member who is signed in when the policy is enabled?',
39+
answer:
40+
'Their next request from a non-allowed address is rejected and they are effectively signed out. Changes propagate within about a minute.',
41+
},
42+
{
43+
question: 'Can I lock myself out?',
44+
answer:
45+
'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.',
46+
},
47+
{
48+
question: 'Does this affect deployed chats and webhooks?',
49+
answer:
50+
'No. The allowlist governs member access — sign-in, sessions, API keys, and collaboration. Deployed product surfaces and server-side automations keep working from anywhere.',
51+
},
52+
{
53+
question: 'How are client addresses determined behind proxies?',
54+
answer:
55+
'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.',
56+
},
57+
]}
58+
/>

apps/docs/content/docs/en/platform/enterprise/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"index",
55
"sso",
66
"session-policies",
7+
"ip-access",
78
"access-control",
89
"custom-blocks",
910
"whitelabeling",

apps/realtime/src/middleware/auth.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import type { Socket } from 'socket.io'
44
import { ANONYMOUS_USER, ANONYMOUS_USER_ID, auth } from '@/auth'
55
import { isAuthDisabled } from '@/env'
6+
import { isSocketAllowedByNetworkPolicy } from '@/middleware/network-policy'
67

78
const logger = createLogger('SocketAuth')
89

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

70+
const networkAllowed = await isSocketAllowedByNetworkPolicy(session.user.id, socket.handshake)
71+
if (!networkAllowed) {
72+
logger.warn(`Socket ${socket.id} rejected by org network policy`, {
73+
userId: session.user.id,
74+
})
75+
return next(new Error('Access restricted by your organization network policy'))
76+
}
77+
6978
// Store user info in socket for later use
7079
socket.userId = session.user.id
7180
socket.userName = session.user.name || session.user.email || 'Unknown User'
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { getIp } from '@better-auth/core/utils/ip'
2+
import { db } from '@sim/db'
3+
import { member, organization } from '@sim/db/schema'
4+
import { createLogger } from '@sim/logger'
5+
import {
6+
type CompiledAllowlist,
7+
compileAllowlist,
8+
isAddressAllowed,
9+
} from '@sim/platform-authz/network'
10+
import { eq } from 'drizzle-orm'
11+
12+
const logger = createLogger('SocketNetworkPolicy')
13+
14+
/** How long a resolved org network policy is served from process memory. */
15+
const POLICY_CACHE_TTL_MS = 60 * 1000
16+
17+
interface CacheEntry {
18+
allowlist: CompiledAllowlist | null
19+
fetchedAt: number
20+
}
21+
22+
const policyCache = new Map<string, CacheEntry>()
23+
const membershipCache = new Map<string, { organizationId: string | null; fetchedAt: number }>()
24+
25+
const trustedProxies = (process.env.AUTH_TRUSTED_PROXIES ?? '')
26+
.split(',')
27+
.map((entry) => entry.trim())
28+
.filter(Boolean)
29+
30+
const IP_RESOLUTION_OPTIONS = {
31+
advanced: {
32+
ipAddress: trustedProxies.length > 0 ? { trustedProxies } : {},
33+
},
34+
}
35+
36+
const enforcementDisabled = process.env.DISABLE_ORG_IP_ALLOWLIST === 'true'
37+
38+
async function getMemberOrganizationId(userId: string): Promise<string | null> {
39+
const cached = membershipCache.get(userId)
40+
if (cached && Date.now() - cached.fetchedAt < POLICY_CACHE_TTL_MS) {
41+
return cached.organizationId
42+
}
43+
const [row] = await db
44+
.select({ organizationId: member.organizationId })
45+
.from(member)
46+
.where(eq(member.userId, userId))
47+
.limit(1)
48+
const organizationId = row?.organizationId ?? null
49+
membershipCache.set(userId, { organizationId, fetchedAt: Date.now() })
50+
return organizationId
51+
}
52+
53+
async function getOrgAllowlist(organizationId: string): Promise<CompiledAllowlist | null> {
54+
const cached = policyCache.get(organizationId)
55+
if (cached && Date.now() - cached.fetchedAt < POLICY_CACHE_TTL_MS) {
56+
return cached.allowlist
57+
}
58+
const [row] = await db
59+
.select({ settings: organization.networkPolicySettings })
60+
.from(organization)
61+
.where(eq(organization.id, organizationId))
62+
.limit(1)
63+
const ipAllowlist = row?.settings?.ipAllowlist
64+
const allowlist =
65+
ipAllowlist?.enabled && ipAllowlist.cidrs.length > 0
66+
? compileAllowlist(ipAllowlist.cidrs)
67+
: null
68+
policyCache.set(organizationId, { allowlist, fetchedAt: Date.now() })
69+
return allowlist
70+
}
71+
72+
interface HandshakeLike {
73+
headers: Record<string, string | string[] | undefined>
74+
address: string
75+
}
76+
77+
/**
78+
* Socket-handshake counterpart of the app's org IP-allowlist enforcement.
79+
* Resolves the client IP with Better Auth's trusted-proxy resolver from the
80+
* handshake headers (falling back to the socket peer address when no
81+
* forwarding headers are present), then checks the member org's allowlist.
82+
* Fail-closed like the app side: an active policy with an unresolvable
83+
* client IP denies the connection.
84+
*
85+
* Plan gating is intentionally absent here — `apps/realtime` cannot import
86+
* billing code. A downgraded org's stale policy denies sockets for at most
87+
* the policy-cache TTL beyond the app-side gate, and the app is the sole
88+
* writer of the settings.
89+
*/
90+
export async function isSocketAllowedByNetworkPolicy(
91+
userId: string,
92+
handshake: HandshakeLike
93+
): Promise<boolean> {
94+
if (enforcementDisabled) return true
95+
96+
try {
97+
const organizationId = await getMemberOrganizationId(userId)
98+
if (!organizationId) return true
99+
100+
const allowlist = await getOrgAllowlist(organizationId)
101+
if (!allowlist) return true
102+
103+
const headers = new Headers()
104+
for (const [key, value] of Object.entries(handshake.headers)) {
105+
if (typeof value === 'string') headers.set(key, value)
106+
else if (Array.isArray(value)) headers.set(key, value.join(', '))
107+
}
108+
const clientIp =
109+
getIp(new Request('http://socket.internal/', { headers }), IP_RESOLUTION_OPTIONS) ??
110+
(handshake.address || null)
111+
112+
if (!clientIp) {
113+
logger.warn('Denying socket: network policy active but client IP unresolvable', {
114+
userId,
115+
organizationId,
116+
})
117+
return false
118+
}
119+
return isAddressAllowed(clientIp, allowlist)
120+
} catch (error) {
121+
logger.error('Network policy check failed; denying socket', { userId, error })
122+
return false
123+
}
124+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { member, organization } from '@sim/db/schema'
5+
import {
6+
createMockRequest,
7+
dbChainMock,
8+
dbChainMockFns,
9+
queueTableRows,
10+
resetDbChainMock,
11+
} from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockGetSession, mockIsEnterprise, mockGetTrustedClientIp, mockRecordAudit } = vi.hoisted(
15+
() => ({
16+
mockGetSession: vi.fn(),
17+
mockIsEnterprise: vi.fn(),
18+
mockGetTrustedClientIp: vi.fn(),
19+
mockRecordAudit: vi.fn(),
20+
})
21+
)
22+
23+
vi.mock('@sim/db', () => dbChainMock)
24+
25+
vi.mock('@/lib/auth', () => ({
26+
getSession: mockGetSession,
27+
}))
28+
29+
vi.mock('@/lib/auth/network-policy', () => ({
30+
getTrustedClientIp: mockGetTrustedClientIp,
31+
invalidateNetworkPolicyCache: vi.fn(),
32+
}))
33+
34+
vi.mock('@/lib/auth/security-policy', () => ({
35+
invalidateSecurityPolicyVersionCache: vi.fn(),
36+
}))
37+
38+
vi.mock('@/lib/billing/core/subscription', () => ({
39+
isOrganizationOnEnterprisePlan: mockIsEnterprise,
40+
}))
41+
42+
vi.mock('@/lib/core/config/env-flags', () => ({
43+
isBillingEnabled: true,
44+
}))
45+
46+
vi.mock('@sim/audit', () => ({
47+
recordAudit: mockRecordAudit,
48+
AuditAction: {
49+
ORGANIZATION_NETWORK_POLICY_UPDATED: 'organization.network_policy.updated',
50+
},
51+
AuditResourceType: { ORGANIZATION: 'organization' },
52+
}))
53+
54+
import { GET, PUT } from '@/app/api/organizations/[id]/network-policy/route'
55+
56+
const ORG_ID = 'org-1'
57+
const routeContext = { params: Promise.resolve({ id: ORG_ID }) }
58+
59+
describe('network policy route', () => {
60+
beforeEach(() => {
61+
vi.clearAllMocks()
62+
resetDbChainMock()
63+
mockGetSession.mockResolvedValue({
64+
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
65+
session: { token: 'tok-1' },
66+
})
67+
mockIsEnterprise.mockResolvedValue(true)
68+
mockGetTrustedClientIp.mockReturnValue('10.0.5.5')
69+
})
70+
71+
describe('GET', () => {
72+
it('returns 401 when unauthenticated', async () => {
73+
mockGetSession.mockResolvedValue(null)
74+
const response = await GET(createMockRequest('GET'), routeContext)
75+
expect(response.status).toBe(401)
76+
})
77+
78+
it('returns the configured policy and caller IP for members', async () => {
79+
queueTableRows(member, [{ id: 'member-1' }])
80+
queueTableRows(organization, [
81+
{ networkPolicySettings: { ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } } },
82+
])
83+
const response = await GET(createMockRequest('GET'), routeContext)
84+
expect(response.status).toBe(200)
85+
const body = await response.json()
86+
expect(body.data).toEqual({
87+
isEnterprise: true,
88+
configured: { enabled: true, cidrs: ['10.0.0.0/16'] },
89+
callerIp: '10.0.5.5',
90+
})
91+
})
92+
})
93+
94+
describe('PUT', () => {
95+
function putRequest(body: unknown) {
96+
return createMockRequest('PUT', body)
97+
}
98+
99+
it('rejects non-admin members', async () => {
100+
queueTableRows(member, [{ role: 'member' }])
101+
const response = await PUT(
102+
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } }),
103+
routeContext
104+
)
105+
expect(response.status).toBe(403)
106+
})
107+
108+
it('rejects malformed CIDR entries at the contract boundary', async () => {
109+
queueTableRows(member, [{ role: 'owner' }])
110+
const response = await PUT(
111+
putRequest({ ipAllowlist: { enabled: true, cidrs: ['banana'] } }),
112+
routeContext
113+
)
114+
expect(response.status).toBe(400)
115+
})
116+
117+
it('rejects a list that would lock the caller out', async () => {
118+
queueTableRows(member, [{ role: 'owner' }])
119+
mockGetTrustedClientIp.mockReturnValue('203.0.113.7')
120+
const response = await PUT(
121+
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } }),
122+
routeContext
123+
)
124+
expect(response.status).toBe(400)
125+
const body = await response.json()
126+
expect(body.error).toContain('203.0.113.7')
127+
})
128+
129+
it('rejects enabling when the caller IP is unresolvable (fail-closed)', async () => {
130+
queueTableRows(member, [{ role: 'owner' }])
131+
mockGetTrustedClientIp.mockReturnValue(null)
132+
const response = await PUT(
133+
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16'] } }),
134+
routeContext
135+
)
136+
expect(response.status).toBe(400)
137+
})
138+
139+
it('saves an allowlist containing the caller IP and bumps the version', async () => {
140+
queueTableRows(member, [{ role: 'owner' }])
141+
queueTableRows(organization, [{ name: 'Acme' }])
142+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
143+
144+
const response = await PUT(
145+
putRequest({ ipAllowlist: { enabled: true, cidrs: ['10.0.0.0/16', '203.0.113.7'] } }),
146+
routeContext
147+
)
148+
expect(response.status).toBe(200)
149+
const body = await response.json()
150+
expect(body.data.configured).toEqual({
151+
enabled: true,
152+
cidrs: ['10.0.0.0/16', '203.0.113.7'],
153+
})
154+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
155+
expect.objectContaining({ securityPolicyVersion: expect.anything() })
156+
)
157+
expect(mockRecordAudit).toHaveBeenCalledWith(
158+
expect.objectContaining({ action: 'organization.network_policy.updated' })
159+
)
160+
})
161+
162+
it('disabling skips the lockout guard entirely', async () => {
163+
queueTableRows(member, [{ role: 'owner' }])
164+
queueTableRows(organization, [{ name: 'Acme' }])
165+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
166+
mockGetTrustedClientIp.mockReturnValue(null)
167+
168+
const response = await PUT(
169+
putRequest({ ipAllowlist: { enabled: false, cidrs: [] } }),
170+
routeContext
171+
)
172+
expect(response.status).toBe(200)
173+
})
174+
})
175+
})

0 commit comments

Comments
 (0)