Skip to content

Commit 7902c2d

Browse files
committed
fix(session-policy): clamp pre-join sessions on invite acceptance, normalize expiresAt, sync unified nav test
1 parent 6817454 commit 7902c2d

4 files changed

Lines changed: 67 additions & 17 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('unified settings navigation', () => {
3939
{ id: 'inbox', label: 'Sim mailer', section: 'system' },
4040
{ id: 'recently-deleted', label: 'Recently deleted', section: 'system' },
4141
{ id: 'sso', label: 'Single sign-on', section: 'enterprise' },
42+
{ id: 'sessions', label: 'Session policies', section: 'enterprise' },
4243
{ id: 'data-retention', label: 'Data retention', section: 'enterprise' },
4344
{ id: 'data-drains', label: 'Data drains', section: 'enterprise' },
4445
{ id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' },

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ interface MembershipCacheEntry {
7171

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

74+
/** Drops the cached membership for a user (call when they join/leave an org). */
75+
export function invalidateMembershipCache(userId: string): void {
76+
membershipCache.delete(userId)
77+
}
78+
7479
/**
7580
* Resolves the org a user belongs to (users belong to at most one org),
7681
* served from a short TTL cache. Org security policies govern MEMBERS, not

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

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { organization } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { eq, sql } from 'drizzle-orm'
66
import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization'
7-
import { getMemberOrganizationId } from '@/lib/auth/security-policy'
7+
import { getMemberOrganizationId, invalidateMembershipCache } from '@/lib/auth/security-policy'
88

99
const logger = createLogger('SessionPolicy')
1010

@@ -108,7 +108,7 @@ interface ClampableSession {
108108
activeOrganizationId?: string | null
109109
impersonatedBy?: string | null
110110
createdAt?: Date | string | null
111-
expiresAt?: Date | null
111+
expiresAt?: Date | string | null
112112
}
113113

114114
/**
@@ -122,18 +122,19 @@ interface ClampableSession {
122122
* sessions have no policy.
123123
*/
124124
export async function clampExpiryForSession(session: ClampableSession): Promise<Date | undefined> {
125-
if (!session.expiresAt || session.impersonatedBy) {
126-
return session.expiresAt ?? undefined
125+
// Better Auth context values can cross a serialization boundary — normalize
126+
// date fields in case they arrive as ISO strings rather than Dates.
127+
const expiresAt = session.expiresAt ? new Date(session.expiresAt) : undefined
128+
if (!expiresAt || session.impersonatedBy) {
129+
return expiresAt
127130
}
128131
const organizationId =
129132
session.activeOrganizationId ?? (await getMemberOrganizationId(session.userId))
130-
if (!organizationId) return session.expiresAt
133+
if (!organizationId) return expiresAt
131134

132135
const policy = await getSessionPolicy(organizationId)
133-
// Better Auth context values can cross a serialization boundary — normalize
134-
// createdAt in case it arrives as an ISO string rather than a Date.
135136
const createdAt = session.createdAt ? new Date(session.createdAt) : new Date()
136-
return clampSessionExpiry(policy, createdAt, session.expiresAt)
137+
return clampSessionExpiry(policy, createdAt, expiresAt)
137138
}
138139

139140
/**
@@ -150,6 +151,51 @@ export async function eagerClampOrgSessions(
150151
organizationId: string,
151152
policy: ResolvedSessionPolicy
152153
): Promise<void> {
154+
const bounds = clampBoundsSql(policy)
155+
if (!bounds) return
156+
157+
await db.execute(sql`
158+
UPDATE "session" SET expires_at = LEAST(${bounds})
159+
WHERE impersonated_by IS NULL
160+
AND user_id IN (
161+
SELECT user_id FROM member WHERE organization_id = ${organizationId}
162+
)
163+
`)
164+
}
165+
166+
/**
167+
* Applies the org's session policy to a user who just JOINED the org:
168+
* invalidates their cached membership (so the cookie-version and hook-clamp
169+
* fallbacks see the new org immediately) and clamps their pre-join sessions,
170+
* which otherwise keep their old expiry until the next sliding refresh.
171+
* Best-effort by design — a failure here must never fail the join; the
172+
* update-hook clamp self-heals within one refresh cycle.
173+
*/
174+
export async function applySessionPolicyToNewMember(
175+
userId: string,
176+
organizationId: string
177+
): Promise<void> {
178+
try {
179+
invalidateMembershipCache(userId)
180+
const policy = await getSessionPolicy(organizationId)
181+
const bounds = clampBoundsSql(policy)
182+
if (!bounds) return
183+
184+
await db.execute(sql`
185+
UPDATE "session" SET expires_at = LEAST(${bounds})
186+
WHERE user_id = ${userId} AND impersonated_by IS NULL
187+
`)
188+
} catch (error) {
189+
logger.error('Failed to apply session policy to new member; next refresh re-clamps', {
190+
userId,
191+
organizationId,
192+
error,
193+
})
194+
}
195+
}
196+
197+
/** SQL argument list for the LEAST() clamp, or null when the policy is empty. */
198+
function clampBoundsSql(policy: ResolvedSessionPolicy) {
153199
const bounds = [sql`expires_at`]
154200
if (policy.maxSessionHours) {
155201
const maxSecs = policy.maxSessionHours * 3600
@@ -159,13 +205,6 @@ export async function eagerClampOrgSessions(
159205
const idleSecs = Math.max(policy.idleTimeoutHours, MIN_IDLE_TIMEOUT_HOURS) * 3600
160206
bounds.push(sql`now() + make_interval(secs => ${idleSecs})`)
161207
}
162-
if (bounds.length === 1) return
163-
164-
await db.execute(sql`
165-
UPDATE "session" SET expires_at = LEAST(${sql.join(bounds, sql`, `)})
166-
WHERE impersonated_by IS NULL
167-
AND user_id IN (
168-
SELECT user_id FROM member WHERE organization_id = ${organizationId}
169-
)
170-
`)
208+
if (bounds.length === 1) return null
209+
return sql.join(bounds, sql`, `)
171210
}

apps/sim/lib/invitations/core.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { generateId } from '@sim/utils/id'
1919
import { normalizeEmail } from '@sim/utils/string'
2020
import { and, eq, inArray, isNull, lte, ne, sql } from 'drizzle-orm'
2121
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
22+
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
2223
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
2324
import {
2425
acquireOrganizationMutationLock,
@@ -646,6 +647,10 @@ async function runInvitationAcceptancePostCommitEffects(
646647
}
647648

648649
if (effects.organizationId && effects.memberRole) {
650+
// Pre-join sessions keep their old expiry until the next sliding refresh;
651+
// apply the org's session policy to them now (best-effort, never throws).
652+
await applySessionPolicyToNewMember(input.userId, effects.organizationId)
653+
649654
recordAudit({
650655
workspaceId: null,
651656
actorId: input.userId,

0 commit comments

Comments
 (0)