-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(auth): org IP allowlisting — member network restrictions #5872
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
waleedlatif1
wants to merge
9
commits into
staging
Choose a base branch
from
feat/org-ip-allowlist
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
91e2e4d
feat(auth): org IP allowlisting — member network restrictions across …
waleedlatif1 ad8211b
improvement(network-policy): getSession chokepoint enforcement, per-e…
waleedlatif1 b50f238
polish(network-policy): fix orphaned TSDoc, trim narration comments, …
waleedlatif1 bad68ac
fix(network-policy): enforce on v1 API path, isTruthy break-glass, ma…
waleedlatif1 83ba26e
fix(network-policy): enforce at customSession get-session chokepoint …
waleedlatif1 2ca9a67
fix(network-policy): gate socket-token minting via enforcing getSessi…
waleedlatif1 a989e3b
fix(network): canonicalize IPv4-mapped IPv6 on numeric value so all t…
waleedlatif1 a073439
fix(realtime): bound socket denial-audit throttle map to match app-si…
waleedlatif1 88c0b15
fix(network): resolve IPv6 at /128 for allowlist matching (was masked…
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
60 changes: 60 additions & 0 deletions
60
apps/docs/content/docs/en/platform/enterprise/ip-access.mdx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.', | ||
| }, | ||
| ]} | ||
| /> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| "index", | ||
| "sso", | ||
| "session-policies", | ||
| "ip-access", | ||
| "access-control", | ||
| "custom-blocks", | ||
| "whitelabeling", | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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 | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| interface HandshakeLike { | ||
| headers: Record<string, string | string[] | undefined> | ||
| address: string | ||
| } | ||
|
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 | ||
|
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) | ||
|
waleedlatif1 marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (!clientIp) { | ||
| logger.warn('Denying socket: network policy active but client IP unresolvable', { | ||
| userId, | ||
|
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
175
apps/sim/app/api/organizations/[id]/network-policy/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.