Skip to content

Commit ad8211b

Browse files
committed
improvement(network-policy): getSession chokepoint enforcement, per-entry labels, denial audit events, shared proxy helpers, lazy IP resolution
1 parent 91e2e4d commit ad8211b

13 files changed

Lines changed: 197 additions & 83 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/ip-access.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ IP Access lets organization owners and admins on Enterprise plans restrict where
1313

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

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.
16+
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.
1717

1818
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.
1919

@@ -26,7 +26,9 @@ The settings page shows your current IP. Saving a list that would exclude your o
2626
- **API keys** — personal and workspace API keys belonging to organization members are only accepted from allowed addresses.
2727
- **Live collaboration** — realtime canvas connections are checked at connect time.
2828

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.
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. 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.
30+
31+
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.
3032

3133
---
3234

apps/realtime/src/middleware/network-policy.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { db } from '@sim/db'
33
import { member, organization } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import {
6+
buildIpResolutionOptions,
67
type CompiledAllowlist,
78
compileAllowlist,
89
isAddressAllowed,
10+
parseTrustedProxies,
911
} from '@sim/platform-authz/network'
1012
import { eq } from 'drizzle-orm'
1113

@@ -22,23 +24,22 @@ interface CacheEntry {
2224
const policyCache = new Map<string, CacheEntry>()
2325
const membershipCache = new Map<string, { organizationId: string | null; fetchedAt: number }>()
2426

25-
const trustedProxies = (process.env.AUTH_TRUSTED_PROXIES ?? '')
26-
.split(',')
27-
.map((entry) => entry.trim())
28-
.filter(Boolean)
27+
const IP_RESOLUTION_OPTIONS = buildIpResolutionOptions(
28+
parseTrustedProxies(process.env.AUTH_TRUSTED_PROXIES)
29+
)
2930

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'
31+
/**
32+
* Non-member results converge fast (matching the app-side membership cache)
33+
* so a user who just joined an org through any path cannot dodge the policy
34+
* for the full positive TTL.
35+
*/
36+
const NEGATIVE_MEMBERSHIP_CACHE_TTL_MS = 15 * 1000
3737

3838
async function getMemberOrganizationId(userId: string): Promise<string | null> {
3939
const cached = membershipCache.get(userId)
40-
if (cached && Date.now() - cached.fetchedAt < POLICY_CACHE_TTL_MS) {
41-
return cached.organizationId
40+
if (cached) {
41+
const ttl = cached.organizationId ? POLICY_CACHE_TTL_MS : NEGATIVE_MEMBERSHIP_CACHE_TTL_MS
42+
if (Date.now() - cached.fetchedAt < ttl) return cached.organizationId
4243
}
4344
const [row] = await db
4445
.select({ organizationId: member.organizationId })
@@ -83,15 +84,18 @@ interface HandshakeLike {
8384
* client IP denies the connection.
8485
*
8586
* 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.
87+
* billing code, and over-denial is the safe direction for a security
88+
* control (the app-side check makes the opposite, fail-open call on DB
89+
* errors because a blip must not lock an org out of the product). A
90+
* downgraded org's stored-but-enabled policy therefore keeps gating sockets
91+
* until the org disables it; the app is the sole writer of the settings.
8992
*/
9093
export async function isSocketAllowedByNetworkPolicy(
9194
userId: string,
9295
handshake: HandshakeLike
9396
): Promise<boolean> {
94-
if (enforcementDisabled) return true
97+
// Read at call time so the break-glass works without a realtime restart.
98+
if (process.env.DISABLE_ORG_IP_ALLOWLIST === 'true') return true
9599

96100
try {
97101
const organizationId = await getMemberOrganizationId(userId)

apps/sim/app/api/organizations/[id]/network-policy/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export const PUT = withRouteHandler(
208208
success: true,
209209
data: {
210210
isEnterprise: true,
211-
configured: normalizeConfigured(merged),
211+
configured: { enabled: ipAllowlist.enabled, cidrs: ipAllowlist.cidrs },
212212
callerIp,
213213
},
214214
})

apps/sim/ee/network-policy/components/network-policy-settings.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ export function NetworkPolicySettings({ organizationId }: NetworkPolicySettingsP
133133
id='ip-allowlist-cidrs'
134134
value={cidrsText}
135135
onChange={(event) => setCidrsText(event.target.value)}
136-
placeholder={'203.0.113.7\n10.0.0.0/16\n2001:db8::/48'}
136+
placeholder={'203.0.113.7 # Office\n10.0.0.0/16 # Frankfurt VPN\n2001:db8::/48'}
137137
rows={8}
138138
/>
139139
<p className='text-[var(--text-muted)] text-caption'>
140-
One entry per line — IPv4/IPv6 addresses or CIDR ranges, up to{' '}
141-
{MAX_IP_ALLOWLIST_ENTRIES} entries.
140+
One entry per line — IPv4/IPv6 addresses or CIDR ranges with an optional {'# label'}, up
141+
to {MAX_IP_ALLOWLIST_ENTRIES} entries.
142142
{data?.callerIp
143143
? ` Your current IP is ${data.callerIp}; saving a list that excludes it is rejected.`
144144
: ''}

apps/sim/lib/api/contracts/organization.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -205,22 +205,32 @@ export const revokeOrganizationSessionsResponseSchema = z.object({
205205
export const MAX_IP_ALLOWLIST_ENTRIES = 200
206206

207207
export const updateOrganizationNetworkPolicyBodySchema = z.object({
208-
ipAllowlist: z.object({
209-
enabled: z.boolean(),
210-
cidrs: z
211-
.array(
212-
z
213-
.string()
214-
.trim()
215-
.min(1, 'Allowlist entries cannot be empty')
216-
.max(64, 'Allowlist entries cannot exceed 64 characters')
217-
.refine(isValidCidrEntry, {
218-
message:
219-
'Each entry must be a valid IPv4/IPv6 address or CIDR range (e.g. 10.0.0.0/16)',
220-
})
221-
)
222-
.max(MAX_IP_ALLOWLIST_ENTRIES, `At most ${MAX_IP_ALLOWLIST_ENTRIES} allowlist entries`),
223-
}),
208+
ipAllowlist: z
209+
.object({
210+
enabled: z.boolean(),
211+
cidrs: z
212+
.array(
213+
z
214+
.string()
215+
.trim()
216+
.min(1, 'Allowlist entries cannot be empty')
217+
.max(128, 'Allowlist entries cannot exceed 128 characters')
218+
.refine(isValidCidrEntry, {
219+
message:
220+
'Each entry must be a valid IPv4/IPv6 address or CIDR range, optionally labelled (e.g. 10.0.0.0/16 # Frankfurt VPN)',
221+
})
222+
)
223+
.max(MAX_IP_ALLOWLIST_ENTRIES, `At most ${MAX_IP_ALLOWLIST_ENTRIES} allowlist entries`),
224+
})
225+
.superRefine((value, ctx) => {
226+
if (value.enabled && value.cidrs.length === 0) {
227+
ctx.addIssue({
228+
code: 'custom',
229+
path: ['cidrs'],
230+
message: 'Add at least one IP or CIDR range before enabling the allowlist',
231+
})
232+
}
233+
}),
224234
})
225235

226236
export type UpdateOrganizationNetworkPolicyBody = z.input<

apps/sim/lib/auth/auth.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { stripe } from '@better-auth/stripe'
66
import { db } from '@sim/db'
77
import * as schema from '@sim/db/schema'
88
import { createLogger } from '@sim/logger'
9+
import { parseTrustedProxies } from '@sim/platform-authz/network'
910
import { toError } from '@sim/utils/errors'
1011
import { generateId } from '@sim/utils/id'
1112
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
@@ -34,7 +35,7 @@ import {
3435
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
3536
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
3637
import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants'
37-
import { enforceOrgNetworkPolicy } from '@/lib/auth/network-policy'
38+
import { enforceOrgNetworkPolicy, getTrustedClientIp } from '@/lib/auth/network-policy'
3839
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
3940
import { clampExpiryForSession } from '@/lib/auth/session-policy'
4041
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
@@ -202,10 +203,7 @@ if (validStripeKey) {
202203
* hops, and records the first untrusted address as the session client IP —
203204
* preventing header spoofing behind multi-hop proxies.
204205
*/
205-
const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '')
206-
.split(',')
207-
.map((entry) => entry.trim())
208-
.filter(Boolean)
206+
const trustedProxies = parseTrustedProxies(env.AUTH_TRUSTED_PROXIES)
209207

210208
export const auth = betterAuth({
211209
baseURL: getBaseUrl(),
@@ -667,7 +665,7 @@ export const auth = betterAuth({
667665
// trusted-proxy-resolved client address. Thrown APIErrors must
668666
// propagate — deliberately outside the try below.
669667
if (!session.impersonatedBy) {
670-
const network = await enforceOrgNetworkPolicy(session.userId, session.ipAddress)
668+
const network = await enforceOrgNetworkPolicy(session.userId, () => session.ipAddress)
671669
if (!network.allowed) {
672670
logger.warn('Blocking session creation by org network policy', {
673671
userId: session.userId,
@@ -3645,9 +3643,29 @@ async function getSessionImpl() {
36453643
}
36463644

36473645
const hdrs = await headers()
3648-
return await auth.api.getSession({
3646+
const session = await auth.api.getSession({
36493647
headers: hdrs,
36503648
})
3649+
3650+
// Org IP allowlists are enforced at this chokepoint so EVERY
3651+
// session-authenticated request — the 180+ routes and layouts that call
3652+
// getSession directly, not just hybrid-auth routes — re-checks the policy.
3653+
// A denied member is treated as signed out. Impersonation sessions are
3654+
// platform tooling and exempt.
3655+
const impersonatedBy = session
3656+
? (session.session as { impersonatedBy?: string | null }).impersonatedBy
3657+
: null
3658+
if (session?.user?.id && !impersonatedBy) {
3659+
const network = await enforceOrgNetworkPolicy(session.user.id, () =>
3660+
getTrustedClientIp(new Request('http://localhost/', { headers: hdrs }))
3661+
)
3662+
if (!network.allowed) {
3663+
logger.warn('Session denied by org network policy', { userId: session.user.id })
3664+
return null
3665+
}
3666+
}
3667+
3668+
return session
36513669
}
36523670

36533671
export const getSession = cache(getSessionImpl)

apps/sim/lib/auth/hybrid.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,9 @@ export async function checkHybridAuth(
193193
const apiKeyHeader = request.headers.get(API_KEY_HEADER) ?? ''
194194
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
195195
if (result.success) {
196-
const network = await enforceOrgNetworkPolicy(result.userId, getTrustedClientIp(request))
196+
const network = await enforceOrgNetworkPolicy(result.userId, () =>
197+
getTrustedClientIp(request)
198+
)
197199
if (!network.allowed) {
198200
return { success: false, error: network.reason }
199201
}
@@ -213,12 +215,10 @@ export async function checkHybridAuth(
213215
}
214216
}
215217

218+
// Org network policy for sessions is enforced inside getSession itself
219+
// (a denied member resolves as signed out), so no re-check here.
216220
const session = await getSession()
217221
if (session?.user?.id) {
218-
const network = await enforceOrgNetworkPolicy(session.user.id, getTrustedClientIp(request))
219-
if (!network.allowed) {
220-
return { success: false, error: network.reason }
221-
}
222222
return {
223223
success: true,
224224
userId: session.user.id,

apps/sim/lib/auth/network-policy.ts

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { getIp } from '@better-auth/core/utils/ip'
2+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
23
import { db } from '@sim/db'
34
import type { NetworkPolicySettings } from '@sim/db/schema'
45
import { organization } from '@sim/db/schema'
56
import { createLogger } from '@sim/logger'
67
import {
8+
buildIpResolutionOptions,
79
type CompiledAllowlist,
810
compileAllowlist,
911
isAddressAllowed,
12+
parseTrustedProxies,
1013
} from '@sim/platform-authz/network'
1114
import { eq } from 'drizzle-orm'
1215
import { getMemberOrganizationId } from '@/lib/auth/security-policy'
@@ -33,16 +36,9 @@ const policyCache = new Map<string, PolicyCacheEntry>()
3336

3437
const NO_POLICY: ResolvedNetworkPolicy = { allowlist: null }
3538

36-
const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '')
37-
.split(',')
38-
.map((entry) => entry.trim())
39-
.filter(Boolean)
40-
41-
const IP_RESOLUTION_OPTIONS = {
42-
advanced: {
43-
ipAddress: trustedProxies.length > 0 ? { trustedProxies } : {},
44-
},
45-
}
39+
const IP_RESOLUTION_OPTIONS = buildIpResolutionOptions(
40+
parseTrustedProxies(env.AUTH_TRUSTED_PROXIES)
41+
)
4642

4743
/**
4844
* Resolves the trusted client IP for a request using Better Auth's hardened
@@ -58,7 +54,10 @@ export function getTrustedClientIp(request: Request): string | null {
5854
/**
5955
* Resolves the EFFECTIVE network policy for an organization, served from a
6056
* short TTL cache. Mirrors the session policy's plan gating: hosted orgs no
61-
* longer on Enterprise stop enforcing automatically.
57+
* longer on Enterprise stop enforcing automatically. A resolution failure
58+
* deliberately fails OPEN here (no policy) — a transient DB blip must not
59+
* lock an entire org out of the product; the realtime handshake makes the
60+
* opposite call because denying one socket is cheap.
6261
*/
6362
export async function getNetworkPolicy(
6463
organizationId: string | null | undefined
@@ -110,45 +109,75 @@ export interface NetworkPolicyDecision {
110109
}
111110

112111
/**
113-
* Enforces the member org's IP allowlist for a user. `clientIp` is the
114-
* trusted-proxy-resolved address ({@link getTrustedClientIp} for requests,
115-
* Better Auth's `session.ipAddress` at session creation).
112+
* Enforces the member org's IP allowlist for a user. `resolveClientIp`
113+
* returns the trusted-proxy-resolved address ({@link getTrustedClientIp}
114+
* for requests, Better Auth's `session.ipAddress` at session creation).
116115
*
117116
* Fail-closed by design: when a policy is active and no trustworthy client
118117
* IP can be derived, access is denied — a spoofable or absent address must
119118
* not bypass a network restriction. `DISABLE_ORG_IP_ALLOWLIST` is the
120119
* break-glass for misconfigured proxy topologies. Non-members and orgs
121120
* without an active policy are always allowed.
122121
*/
122+
const ALLOWED: NetworkPolicyDecision = { allowed: true }
123+
const DENIED: NetworkPolicyDecision = {
124+
allowed: false,
125+
reason: 'Access restricted by your organization network policy. Contact your administrator.',
126+
}
127+
128+
/**
129+
* Denial audit events are throttled per user so a polling client outside the
130+
* allowlist cannot flood the audit log — one event per user per window is
131+
* enough for a security team to see who is being blocked and from where.
132+
*/
133+
const DENIAL_AUDIT_WINDOW_MS = 5 * 60 * 1000
134+
const lastDenialAuditAt = new Map<string, number>()
135+
136+
function recordDenialAudit(userId: string, organizationId: string, clientIp: string | null): void {
137+
const last = lastDenialAuditAt.get(userId)
138+
if (last && Date.now() - last < DENIAL_AUDIT_WINDOW_MS) return
139+
lastDenialAuditAt.set(userId, Date.now())
140+
recordAudit({
141+
workspaceId: null,
142+
actorId: userId,
143+
action: AuditAction.ORG_IP_ACCESS_DENIED,
144+
resourceType: AuditResourceType.ORGANIZATION,
145+
resourceId: organizationId,
146+
description: clientIp
147+
? `Denied access from ${clientIp} by the IP allowlist`
148+
: 'Denied access by the IP allowlist (client IP unresolvable)',
149+
metadata: { clientIp },
150+
})
151+
}
152+
123153
export async function enforceOrgNetworkPolicy(
124154
userId: string | null | undefined,
125-
clientIp: string | null | undefined
155+
resolveClientIp: () => string | null | undefined
126156
): Promise<NetworkPolicyDecision> {
127-
if (env.DISABLE_ORG_IP_ALLOWLIST) return { allowed: true }
157+
if (env.DISABLE_ORG_IP_ALLOWLIST) return ALLOWED
128158

129159
const organizationId = await getMemberOrganizationId(userId)
130-
if (!organizationId) return { allowed: true }
160+
if (!organizationId) return ALLOWED
131161

132162
const policy = await getNetworkPolicy(organizationId)
133-
if (!policy.allowlist) return { allowed: true }
163+
if (!policy.allowlist) return ALLOWED
134164

165+
// Resolved lazily: the common case (non-member or no active policy) never
166+
// pays for forwarded-header parsing.
167+
const clientIp = resolveClientIp()
135168
if (!clientIp) {
136169
logger.warn('Denying request: network policy active but client IP unresolvable', {
137170
userId,
138171
organizationId,
139172
})
140-
return {
141-
allowed: false,
142-
reason: 'Access restricted by your organization network policy. Contact your administrator.',
143-
}
173+
if (userId) recordDenialAudit(userId, organizationId, null)
174+
return DENIED
144175
}
145176

146177
if (!isAddressAllowed(clientIp, policy.allowlist)) {
147-
return {
148-
allowed: false,
149-
reason: 'Access restricted by your organization network policy. Contact your administrator.',
150-
}
178+
if (userId) recordDenialAudit(userId, organizationId, clientIp)
179+
return DENIED
151180
}
152181

153-
return { allowed: true }
182+
return ALLOWED
154183
}

0 commit comments

Comments
 (0)