-
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.
+19,151
−17
Open
Changes from all 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
77 changes: 77 additions & 0 deletions
77
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,77 @@ | ||
| --- | ||
| title: IP Access | ||
| description: Restrict sign-in and API access to your organization's allowed IP ranges | ||
| --- | ||
|
|
||
| import { Callout } from 'fumadocs-ui/components/callout' | ||
| 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. | ||
|
|
||
| IPv6 addresses are matched at full precision, so you can allow an exact host (`2001:db8::1`) or a subnet (`2001:db8::/64`). Because IPv6 clients often rotate within their `/64`, allowing the `/64` is usually the more robust choice. | ||
|
|
||
| --- | ||
|
|
||
| ## Self-hosting | ||
|
|
||
| <Callout type="warning"> | ||
| IP access fails **closed**: when a policy is active and Sim cannot determine a trustworthy client address, the request is denied. A misconfigured proxy chain will therefore lock out your organization. Configure the two settings below before enabling a policy. | ||
| </Callout> | ||
|
|
||
| - **`AUTH_TRUSTED_PROXIES`** — the reverse-proxy IPs or CIDR ranges in front of Sim (your load balancer / ingress), comma-separated, for example `AUTH_TRUSTED_PROXIES=10.0.0.0/16`. Sim walks the `x-forwarded-for` chain right to left, skips these trusted hops, and uses the first untrusted address as the client IP — so forwarded headers cannot be forged. If this is unset (or wrong) behind a multi-hop proxy, no client address can be derived and every request is denied while a policy is active. The settings page's lockout guard also refuses to *enable* a policy when your own address is unresolvable, which is the first symptom of a missing value. | ||
| - **`NODE_ENV=production`** — outside production, the auth layer resolves every client to `127.0.0.1`, so an allowlist that does not contain `127.0.0.1` would deny everyone. Run production deployments with `NODE_ENV=production`. | ||
| - **`DISABLE_ORG_IP_ALLOWLIST=true`** — the break-glass. Setting it (read at request time, no restart needed) skips IP-allowlist enforcement across the app and realtime processes, restoring access if a policy or proxy misconfiguration locks the organization out. Remove it once the allowlist is corrected. | ||
|
|
||
| Enforcement reads the compiled policy from a short-lived in-process cache, so a policy change (or a plan change) takes effect within about a minute across the fleet. If the database is briefly unreachable the policy resolver **fails open** (allows the request) rather than locking the whole organization out of the product on a transient blip — the primary boundary (an active policy that rejects an address) stays fail-closed. | ||
|
|
||
| --- | ||
|
|
||
| ## 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 can update the list. Self-hosted operators can set DISABLE_ORG_IP_ALLOWLIST=true as a break-glass to restore access (see Self-hosting above).', | ||
| }, | ||
| { | ||
| 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,175 @@ | ||
| 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 | ||
| } | ||
|
|
||
| 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> | ||
| } | ||
|
|
||
| /** | ||
| * 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 DENIAL_AUDIT_MAX_TRACKED = 10_000 | ||
| 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 | ||
| // Bound the map (matching the app-side throttle): on overflow drop the | ||
| // oldest-inserted key. Long-lived realtime process, many distinct denied | ||
| // members — this keeps it a slow ceiling, not an unbounded leak. | ||
| if (lastDenialAuditAt.size >= DENIAL_AUDIT_MAX_TRACKED) { | ||
| const oldest = lastDenialAuditAt.keys().next().value | ||
| if (oldest !== undefined) lastDenialAuditAt.delete(oldest) | ||
| } | ||
| 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' }, | ||
| }) | ||
| } | ||
|
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, | ||
|
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 | ||
| } | ||
| } | ||
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
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.