Skip to content

Commit c4b580d

Browse files
committed
fix(session-policy): atomic policy save + eager clamp, asymmetric membership TTL, admin-add cache invalidation
1 parent 458804e commit c4b580d

5 files changed

Lines changed: 48 additions & 24 deletions

File tree

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,11 @@ describe('session policy route', () => {
138138
expect(response.status).toBe(200)
139139
const body = await response.json()
140140
expect(body.data.configured).toEqual({ maxSessionHours: 72, idleTimeoutHours: 48 })
141-
expect(mockEagerClamp).toHaveBeenCalledWith(ORG_ID, {
142-
maxSessionHours: 72,
143-
idleTimeoutHours: 48,
144-
})
141+
expect(mockEagerClamp).toHaveBeenCalledWith(
142+
ORG_ID,
143+
{ maxSessionHours: 72, idleTimeoutHours: 48 },
144+
expect.anything()
145+
)
145146
// The version bump rides the settings UPDATE (single round trip).
146147
expect(dbChainMockFns.set).toHaveBeenCalledWith(
147148
expect.objectContaining({ securityPolicyVersion: expect.anything() })
@@ -161,10 +162,11 @@ describe('session policy route', () => {
161162
routeContext
162163
)
163164
expect(response.status).toBe(200)
164-
expect(mockEagerClamp).toHaveBeenCalledWith(ORG_ID, {
165-
maxSessionHours: null,
166-
idleTimeoutHours: null,
167-
})
165+
expect(mockEagerClamp).toHaveBeenCalledWith(
166+
ORG_ID,
167+
{ maxSessionHours: null, idleTimeoutHours: null },
168+
expect.anything()
169+
)
168170
})
169171
})
170172
})

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,23 @@ export const PUT = withRouteHandler(
138138
idleTimeoutHours: body.idleTimeoutHours,
139139
}
140140

141-
// The version bump rides the settings UPDATE (same row, one round trip).
142-
const [updated] = await db
143-
.update(organization)
144-
.set({
145-
sessionPolicySettings: merged,
146-
securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`,
147-
updatedAt: new Date(),
148-
})
149-
.where(eq(organization.id, organizationId))
150-
.returning({ id: organization.id })
141+
// Settings write (with the version bump riding the same row) and the
142+
// eager clamp of existing sessions commit atomically — a stored policy is
143+
// never left unenforced by a partial failure.
144+
const updated = await db.transaction(async (tx) => {
145+
const [row] = await tx
146+
.update(organization)
147+
.set({
148+
sessionPolicySettings: merged,
149+
securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`,
150+
updatedAt: new Date(),
151+
})
152+
.where(eq(organization.id, organizationId))
153+
.returning({ id: organization.id })
154+
if (!row) return null
155+
await eagerClampOrgSessions(organizationId, merged, tx)
156+
return row
157+
})
151158

152159
if (!updated) {
153160
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
@@ -156,8 +163,6 @@ export const PUT = withRouteHandler(
156163
invalidateSessionPolicyCache(organizationId)
157164
invalidateSecurityPolicyVersionCache(organizationId)
158165

159-
await eagerClampOrgSessions(organizationId, merged)
160-
161166
logger.info('Updated organization session policy', { organizationId })
162167

163168
recordAudit({

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ interface MembershipCacheEntry {
7171

7272
const membershipCache = new Map<string, MembershipCacheEntry>()
7373

74+
/**
75+
* Negative (non-member) membership results use a much shorter TTL than
76+
* positive ones: a user's cached `null` would otherwise let them dodge a new
77+
* org's policy for the full TTL after joining through ANY path — including
78+
* ones outside this codebase (Better Auth SSO JIT provisioning). Positive
79+
* results change only through leave/transfer, which invalidate explicitly.
80+
*/
81+
const NEGATIVE_MEMBERSHIP_CACHE_TTL_MS = 15 * 1000
82+
7483
/** Drops the cached membership for a user (call when they join/leave an org). */
7584
export function invalidateMembershipCache(userId: string): void {
7685
membershipCache.delete(userId)
@@ -90,8 +99,11 @@ export async function getMemberOrganizationId(
9099
if (!userId) return null
91100

92101
const cached = membershipCache.get(userId)
93-
if (cached && Date.now() - cached.fetchedAt < SECURITY_POLICY_VERSION_CACHE_TTL_MS) {
94-
return cached.organizationId
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
95107
}
96108

97109
try {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,13 @@ export async function clampExpiryForSession(
156156
*/
157157
export async function eagerClampOrgSessions(
158158
organizationId: string,
159-
policy: ResolvedSessionPolicy
159+
policy: ResolvedSessionPolicy,
160+
executor: Pick<typeof db, 'execute'> = db
160161
): Promise<void> {
161162
const bounds = clampBoundsSql(policy)
162163
if (!bounds) return
163164

164-
await db.execute(sql`
165+
await executor.execute(sql`
165166
UPDATE "session" SET expires_at = LEAST(${bounds})
166167
WHERE impersonated_by IS NULL
167168
AND user_id IN (

apps/sim/lib/billing/organizations/membership.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,10 @@ export async function ensureUserInOrganization(
631631

632632
const result = await addUserToOrganization(params)
633633

634+
if (result.success) {
635+
invalidateMembershipCache(params.userId)
636+
}
637+
634638
return {
635639
...result,
636640
alreadyMember: false,

0 commit comments

Comments
 (0)