Skip to content

Commit 5b86cf3

Browse files
committed
fix(auth): bound the three unbounded session-policy caches
security-policy.ts and session-policy.ts are read from Better Auth's session create and update hooks, so they run on every session validation. All three caches were plain Maps with a hand-rolled 'Date.now() - fetchedAt < TTL' read and no ceiling: entries were released only by an explicit invalidate, so they grew for the life of the process. membershipCache is the sharpest of the three because it is keyed by user, not organization — one entry per user who ever authenticated on that instance. Move all three to LRUCache, already a direct dependency and the pattern copilot/entitlements.ts and providers/client-cache.ts use. The library owns the TTL and the ceiling; every existing invalidate* keeps working unchanged. Two things to preserve, both now pinned by tests: - membership results keep their asymmetric TTL (a non-member result expires far sooner, so a user who joins through a path this codebase never sees cannot dodge the new org's policy). Expressed as membershipCacheTtlMs rather than an inline ternary, since it is a security property and not a tuning knob. - reads test '!== undefined', because a version is a number and a membership is nullable — a truthiness check would treat both as a miss. security-policy.ts had no test file; adds one covering caching, invalidation, failure fallbacks and the TTL asymmetry.
1 parent ea70f8d commit 5b86cf3

3 files changed

Lines changed: 172 additions & 30 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import {
7+
getMemberOrganizationId,
8+
getSecurityPolicyVersion,
9+
invalidateMembershipCache,
10+
invalidateSecurityPolicyVersionCache,
11+
membershipCacheTtlMs,
12+
NEGATIVE_MEMBERSHIP_CACHE_TTL_MS,
13+
SECURITY_POLICY_VERSION_CACHE_TTL_MS,
14+
} from '@/lib/auth/security-policy'
15+
16+
afterAll(resetDbChainMock)
17+
18+
describe('membershipCacheTtlMs', () => {
19+
/**
20+
* The asymmetry is a security property: a cached `null` lets a user dodge a
21+
* new org's policy until it expires, and they can join through paths this
22+
* codebase never sees (Better Auth SSO JIT provisioning). A positive result
23+
* only changes through leave/transfer, which invalidate explicitly.
24+
*/
25+
it('expires a non-member result far sooner than a member one', () => {
26+
expect(membershipCacheTtlMs('org-1')).toBe(SECURITY_POLICY_VERSION_CACHE_TTL_MS)
27+
expect(membershipCacheTtlMs(null)).toBe(NEGATIVE_MEMBERSHIP_CACHE_TTL_MS)
28+
expect(membershipCacheTtlMs(null)).toBeLessThan(membershipCacheTtlMs('org-1'))
29+
})
30+
})
31+
32+
describe('getMemberOrganizationId', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
resetDbChainMock()
36+
invalidateMembershipCache('user-1')
37+
})
38+
39+
it('serves a repeat lookup from cache instead of re-reading membership', async () => {
40+
dbChainMockFns.limit.mockResolvedValue([{ organizationId: 'org-1' }])
41+
42+
await expect(getMemberOrganizationId('user-1')).resolves.toBe('org-1')
43+
await expect(getMemberOrganizationId('user-1')).resolves.toBe('org-1')
44+
45+
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1)
46+
})
47+
48+
/** `null` is a real answer, not a miss — re-querying every time would defeat the cache. */
49+
it('caches a non-member result too', async () => {
50+
dbChainMockFns.limit.mockResolvedValue([])
51+
52+
await expect(getMemberOrganizationId('user-1')).resolves.toBeNull()
53+
await expect(getMemberOrganizationId('user-1')).resolves.toBeNull()
54+
55+
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1)
56+
})
57+
58+
it('re-reads after an explicit invalidation', async () => {
59+
dbChainMockFns.limit.mockResolvedValue([{ organizationId: 'org-1' }])
60+
await expect(getMemberOrganizationId('user-1')).resolves.toBe('org-1')
61+
62+
invalidateMembershipCache('user-1')
63+
dbChainMockFns.limit.mockResolvedValue([{ organizationId: 'org-2' }])
64+
65+
await expect(getMemberOrganizationId('user-1')).resolves.toBe('org-2')
66+
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2)
67+
})
68+
69+
it('treats a failed read as org-less without caching it', async () => {
70+
dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable'))
71+
await expect(getMemberOrganizationId('user-1')).resolves.toBeNull()
72+
73+
dbChainMockFns.limit.mockResolvedValue([{ organizationId: 'org-1' }])
74+
await expect(getMemberOrganizationId('user-1')).resolves.toBe('org-1')
75+
})
76+
77+
it('returns null for an absent user without touching the database', async () => {
78+
await expect(getMemberOrganizationId(null)).resolves.toBeNull()
79+
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
80+
})
81+
})
82+
83+
describe('getSecurityPolicyVersion', () => {
84+
beforeEach(() => {
85+
vi.clearAllMocks()
86+
resetDbChainMock()
87+
invalidateSecurityPolicyVersionCache('org-1')
88+
})
89+
90+
/** The value is a number, so a truthiness check would re-query on every read. */
91+
it('serves a repeat lookup from cache', async () => {
92+
dbChainMockFns.limit.mockResolvedValue([{ version: 7 }])
93+
94+
await expect(getSecurityPolicyVersion('org-1')).resolves.toBe(7)
95+
await expect(getSecurityPolicyVersion('org-1')).resolves.toBe(7)
96+
97+
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1)
98+
})
99+
100+
it('re-reads after an explicit invalidation', async () => {
101+
dbChainMockFns.limit.mockResolvedValue([{ version: 7 }])
102+
await expect(getSecurityPolicyVersion('org-1')).resolves.toBe(7)
103+
104+
invalidateSecurityPolicyVersionCache('org-1')
105+
dbChainMockFns.limit.mockResolvedValue([{ version: 8 }])
106+
107+
await expect(getSecurityPolicyVersion('org-1')).resolves.toBe(8)
108+
})
109+
110+
it('falls back to the default version without caching a failed read', async () => {
111+
dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable'))
112+
await expect(getSecurityPolicyVersion('org-1')).resolves.toBe(1)
113+
114+
dbChainMockFns.limit.mockResolvedValue([{ version: 9 }])
115+
await expect(getSecurityPolicyVersion('org-1')).resolves.toBe(9)
116+
})
117+
118+
it('returns the default for an org-less session without touching the database', async () => {
119+
await expect(getSecurityPolicyVersion(null)).resolves.toBe(1)
120+
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
121+
})
122+
})

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

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { member, organization } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { eq } from 'drizzle-orm'
5+
import { LRUCache } from 'lru-cache'
56

67
const logger = createLogger('SecurityPolicy')
78

@@ -16,12 +17,16 @@ export const SECURITY_POLICY_VERSION_CACHE_TTL_MS = 60 * 1000
1617

1718
const DEFAULT_VERSION = 1
1819

19-
interface VersionCacheEntry {
20-
version: number
21-
fetchedAt: number
22-
}
23-
24-
const versionCache = new Map<string, VersionCacheEntry>()
20+
/**
21+
* Read on every session create and refresh, keyed by organization, so an
22+
* unbounded `Map` grew for the life of the process. `LRUCache` supplies both
23+
* the TTL and a ceiling; `invalidateSecurityPolicyVersionCache` still evicts
24+
* by key. Reads must test `!== undefined`, since the value is a number.
25+
*/
26+
const versionCache = new LRUCache<string, number>({
27+
max: 1000,
28+
ttl: SECURITY_POLICY_VERSION_CACHE_TTL_MS,
29+
})
2530

2631
/**
2732
* Resolves the org's security-policy version — the shared monotonic counter
@@ -36,9 +41,7 @@ export async function getSecurityPolicyVersion(
3641
if (!organizationId) return DEFAULT_VERSION
3742

3843
const cached = versionCache.get(organizationId)
39-
if (cached && Date.now() - cached.fetchedAt < SECURITY_POLICY_VERSION_CACHE_TTL_MS) {
40-
return cached.version
41-
}
44+
if (cached !== undefined) return cached
4245

4346
try {
4447
const [row] = await db
@@ -48,7 +51,7 @@ export async function getSecurityPolicyVersion(
4851
.limit(1)
4952

5053
const version = row?.version ?? DEFAULT_VERSION
51-
versionCache.set(organizationId, { version, fetchedAt: Date.now() })
54+
versionCache.set(organizationId, version)
5255
return version
5356
} catch (error) {
5457
logger.error('Failed to resolve security policy version; using default', {
@@ -64,12 +67,22 @@ export function invalidateSecurityPolicyVersionCache(organizationId: string): vo
6467
versionCache.delete(organizationId)
6568
}
6669

70+
/**
71+
* Wraps the value in an object because `LRUCache` cannot store `null`, and a
72+
* non-member is exactly what `null` means here.
73+
*
74+
* Keyed by user rather than organization, so this was the least bounded cache
75+
* of the three: one entry per user who ever authenticated on the process,
76+
* released only by an explicit join/leave invalidation.
77+
*/
6778
interface MembershipCacheEntry {
6879
organizationId: string | null
69-
fetchedAt: number
7080
}
7181

72-
const membershipCache = new Map<string, MembershipCacheEntry>()
82+
const membershipCache = new LRUCache<string, MembershipCacheEntry>({
83+
max: 10_000,
84+
ttl: SECURITY_POLICY_VERSION_CACHE_TTL_MS,
85+
})
7386

7487
/**
7588
* Negative (non-member) membership results use a much shorter TTL than
@@ -78,7 +91,17 @@ const membershipCache = new Map<string, MembershipCacheEntry>()
7891
* ones outside this codebase (Better Auth SSO JIT provisioning). Positive
7992
* results change only through leave/transfer, which invalidate explicitly.
8093
*/
81-
const NEGATIVE_MEMBERSHIP_CACHE_TTL_MS = 15 * 1000
94+
export const NEGATIVE_MEMBERSHIP_CACHE_TTL_MS = 15 * 1000
95+
96+
/**
97+
* The TTL a membership result is cached under. Named rather than inlined at the
98+
* `set` call because the asymmetry is a security property, not a tuning knob:
99+
* collapsing it to one value would silently restore the dodge the short
100+
* negative TTL exists to close.
101+
*/
102+
export function membershipCacheTtlMs(organizationId: string | null): number {
103+
return organizationId ? SECURITY_POLICY_VERSION_CACHE_TTL_MS : NEGATIVE_MEMBERSHIP_CACHE_TTL_MS
104+
}
82105

83106
/** Drops the cached membership for a user (call when they join/leave an org). */
84107
export function invalidateMembershipCache(userId: string): void {
@@ -99,12 +122,7 @@ export async function getMemberOrganizationId(
99122
if (!userId) return null
100123

101124
const cached = membershipCache.get(userId)
102-
if (cached) {
103-
const ttl = cached.organizationId
104-
? SECURITY_POLICY_VERSION_CACHE_TTL_MS
105-
: NEGATIVE_MEMBERSHIP_CACHE_TTL_MS
106-
if (Date.now() - cached.fetchedAt < ttl) return cached.organizationId
107-
}
125+
if (cached) return cached.organizationId
108126

109127
try {
110128
const [row] = await db
@@ -114,7 +132,7 @@ export async function getMemberOrganizationId(
114132
.limit(1)
115133

116134
const organizationId = row?.organizationId ?? null
117-
membershipCache.set(userId, { organizationId, fetchedAt: Date.now() })
135+
membershipCache.set(userId, { organizationId }, { ttl: membershipCacheTtlMs(organizationId) })
118136
return organizationId
119137
} catch (error) {
120138
logger.error('Failed to resolve org membership; treating session as org-less', {

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SessionPolicySettings } from '@sim/db/schema'
33
import { organization } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { eq, sql } from 'drizzle-orm'
6+
import { LRUCache } from 'lru-cache'
67
import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization'
78
import { getMemberOrganizationId, invalidateMembershipCache } from '@/lib/auth/security-policy'
89
import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
@@ -20,12 +21,15 @@ export interface ResolvedSessionPolicy {
2021
idleTimeoutHours: number | null
2122
}
2223

23-
interface PolicyCacheEntry {
24-
policy: ResolvedSessionPolicy
25-
fetchedAt: number
26-
}
27-
28-
const policyCache = new Map<string, PolicyCacheEntry>()
24+
/**
25+
* Read on every session create and refresh, keyed by organization, so an
26+
* unbounded `Map` grew for the life of the process. `LRUCache` supplies both
27+
* the TTL and a ceiling; `invalidateSessionPolicyCache` still evicts by key.
28+
*/
29+
const policyCache = new LRUCache<string, ResolvedSessionPolicy>({
30+
max: 1000,
31+
ttl: SESSION_POLICY_CACHE_TTL_MS,
32+
})
2933

3034
const NO_POLICY: ResolvedSessionPolicy = {
3135
maxSessionHours: null,
@@ -46,9 +50,7 @@ export async function getSessionPolicy(
4650
if (!organizationId) return NO_POLICY
4751

4852
const cached = policyCache.get(organizationId)
49-
if (cached && Date.now() - cached.fetchedAt < SESSION_POLICY_CACHE_TTL_MS) {
50-
return cached.policy
51-
}
53+
if (cached) return cached
5254

5355
try {
5456
const [row] = await db
@@ -67,7 +69,7 @@ export async function getSessionPolicy(
6769
idleTimeoutHours: settings.idleTimeoutHours ?? null,
6870
}
6971
: NO_POLICY
70-
policyCache.set(organizationId, { policy, fetchedAt: Date.now() })
72+
policyCache.set(organizationId, policy)
7173
return policy
7274
} catch (error) {
7375
logger.error('Failed to resolve session policy; applying no policy', {

0 commit comments

Comments
 (0)