Skip to content

Commit 385bca2

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

4 files changed

Lines changed: 59 additions & 10 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: 48 additions & 10 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

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

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)