Skip to content

Commit c17d5a1

Browse files
committed
fix(session-policy): invalidate membership cache on removal/transfer, spare impersonator sessions in revoke-all, raise idle floor to 2x cookie window
1 parent 7902c2d commit c17d5a1

4 files changed

Lines changed: 27 additions & 10 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/session-policies.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Use this to enforce periodic re-authentication — for example, a value of `168`
2929

3030
Signs a member out after this many hours without activity. Activity extends the session, so members who use Sim regularly stay signed in; dormant sessions expire.
3131

32-
Accepts 24 to 8760 hours. The 24-hour minimum exists because session activity is recorded at most once per day — a shorter window would sign out members who are actively working.
32+
Accepts 48 to 8760 hours. The 48-hour minimum exists because session activity is recorded at most once per day — a shorter window could sign out members who are actively working.
3333

3434
### Sign out all members
3535

@@ -68,9 +68,9 @@ Use this after a security incident, an offboarding wave, or before tightening a
6868
'No. It revokes browser sign-in sessions only. API keys, deployed workflows, webhooks, and schedules keep working — manage those separately from the API keys settings.',
6969
},
7070
{
71-
question: 'Why is the minimum idle timeout 24 hours?',
71+
question: 'Why is the minimum idle timeout 48 hours?',
7272
answer:
73-
'Session activity is recorded at most once per 24 hours for performance. An idle timeout below that window could sign out members who are actively using Sim, so shorter values are not accepted.',
73+
'Session activity is recorded at most once per 24 hours for performance. The minimum is twice that window so a member who stays active always registers activity before the idle limit can expire their session.',
7474
},
7575
]}
7676
/>

apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ export const POST = withRouteHandler(
7373
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
7474
}
7575

76+
// When the caller is a platform admin impersonating an org member, the
77+
// current token is the impersonation session — also spare the admin's own
78+
// real sessions so ending impersonation doesn't leave them signed out.
79+
const impersonatorId =
80+
(session.session as { impersonatedBy?: string | null }).impersonatedBy ?? null
7681
const revoked = await db
7782
.delete(sessionTable)
7883
.where(
@@ -85,7 +90,8 @@ export const POST = withRouteHandler(
8590
.where(eq(member.organizationId, organizationId))
8691
),
8792
isNull(sessionTable.impersonatedBy),
88-
ne(sessionTable.token, session.session.token)
93+
ne(sessionTable.token, session.session.token),
94+
...(impersonatorId ? [ne(sessionTable.userId, impersonatorId)] : [])
8995
)
9096
)
9197
.returning({ id: sessionTable.id })

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,14 @@ export const organizationDataRetentionResponseSchema = z.object({
145145
/**
146146
* Session-policy bounds — the single source for the contract validation, the
147147
* server-side clamp (`@/lib/auth/session-policy`), and the settings UI.
148-
* `MIN_IDLE_TIMEOUT_HOURS` matches the session cookie-cache window: activity
149-
* is only recorded on DB-path refreshes, so a shorter idle timeout would sign
150-
* out demonstrably active users.
148+
* `MIN_IDLE_TIMEOUT_HOURS` is twice the session cookie-cache window (24h):
149+
* cached reads never record activity, so a continuously active user only
150+
* refreshes their session when the cookie cache expires. A floor of one
151+
* window would sign out active users exactly at the cache boundary; two
152+
* windows guarantees a DB-path refresh lands before the idle limit can.
151153
*/
152154
export const MIN_SESSION_LIFETIME_HOURS = 1
153-
export const MIN_IDLE_TIMEOUT_HOURS = 24
155+
export const MIN_IDLE_TIMEOUT_HOURS = 48
154156
export const MAX_SESSION_POLICY_HOURS = 8760
155157

156158
export const updateOrganizationSessionPolicyBodySchema = z.object({
@@ -165,7 +167,7 @@ export const updateOrganizationSessionPolicyBodySchema = z.object({
165167
.int()
166168
.min(
167169
MIN_IDLE_TIMEOUT_HOURS,
168-
'Idle timeout must be at least 24 hours — session activity is only recorded once per cookie-cache window'
170+
'Idle timeout must be at least 48 hours — session activity is recorded at most once per 24h cookie-cache window'
169171
)
170172
.max(MAX_SESSION_POLICY_HOURS, 'Idle timeout cannot exceed 8760 hours (1 year)')
171173
.nullable(),

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { getErrorMessage } from '@sim/utils/errors'
2424
import { generateId } from '@sim/utils/id'
2525
import { normalizeEmail } from '@sim/utils/string'
2626
import { and, count, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
27+
import { invalidateMembershipCache } from '@/lib/auth/security-policy'
2728
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
2829
import {
2930
assertNoUnresolvedEnterpriseIssuance,
@@ -1343,7 +1344,7 @@ export async function transferUserBetweenOrganizations(
13431344
}
13441345

13451346
try {
1346-
return await withInvitationSafeOrganizationAccessMutation(
1347+
const transferResult = await withInvitationSafeOrganizationAccessMutation(
13471348
{
13481349
userId: params.userId,
13491350
organizationId: params.sourceOrganizationId,
@@ -1513,6 +1514,10 @@ export async function transferUserBetweenOrganizations(
15131514
}
15141515
}
15151516
)
1517+
// The transferred member's cookie-version/hook-clamp fallbacks must
1518+
// resolve to the destination org immediately, not after the cache TTL.
1519+
invalidateMembershipCache(params.userId)
1520+
return transferResult
15161521
} catch (error) {
15171522
logger.error('Failed to transfer organization member', { ...params, error })
15181523
return { success: false, error: getErrorMessage(error), ...emptyResult }
@@ -1733,6 +1738,10 @@ export async function removeUserFromOrganization(
17331738
billingActions.workspaceAccessRevoked = result.workspaceIdsToRevoke.length
17341739
billingActions.pendingInvitationsCancelled = result.pendingInvitationsCancelled
17351740

1741+
// The departed member's cookie-version/hook-clamp fallbacks must stop
1742+
// resolving to this org immediately, not after the membership-cache TTL.
1743+
invalidateMembershipCache(userId)
1744+
17361745
if (result.usageCaptured > 0) {
17371746
logger.info('Captured departed member usage', {
17381747
organizationId,

0 commit comments

Comments
 (0)