Skip to content

Commit 9a67fc8

Browse files
committed
fix(session-policy): org-scoped cookie version string, atomic revoke delete+bump
1 parent c4b580d commit 9a67fc8

2 files changed

Lines changed: 34 additions & 21 deletions

File tree

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

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { db } from '@sim/db'
33
import { member, organization, session as sessionTable } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
6-
import { and, eq, inArray, isNull, ne } from 'drizzle-orm'
6+
import { and, eq, inArray, isNull, ne, sql } from 'drizzle-orm'
77
import { type NextRequest, NextResponse } from 'next/server'
88
import { revokeOrganizationSessionsContract } from '@/lib/api/contracts/organization'
99
import { parseRequest } from '@/lib/api/server'
1010
import { getSession } from '@/lib/auth'
11-
import { bumpSecurityPolicyVersion } from '@/lib/auth/security-policy'
11+
import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy'
1212
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
1313
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -78,25 +78,34 @@ export const POST = withRouteHandler(
7878
// real sessions so ending impersonation doesn't leave them signed out.
7979
const impersonatorId =
8080
(session.session as { impersonatedBy?: string | null }).impersonatedBy ?? null
81-
const revoked = await db
82-
.delete(sessionTable)
83-
.where(
84-
and(
85-
inArray(
86-
sessionTable.userId,
87-
db
88-
.select({ userId: member.userId })
89-
.from(member)
90-
.where(eq(member.organizationId, organizationId))
91-
),
92-
isNull(sessionTable.impersonatedBy),
93-
ne(sessionTable.token, session.session.token),
94-
...(impersonatorId ? [ne(sessionTable.userId, impersonatorId)] : [])
81+
// Delete and version bump commit atomically: a bump failure must roll the
82+
// delete back, or members would stay authenticated from the cookie cache
83+
// for up to 24h with their DB sessions already gone.
84+
const revoked = await db.transaction(async (tx) => {
85+
const deleted = await tx
86+
.delete(sessionTable)
87+
.where(
88+
and(
89+
inArray(
90+
sessionTable.userId,
91+
tx
92+
.select({ userId: member.userId })
93+
.from(member)
94+
.where(eq(member.organizationId, organizationId))
95+
),
96+
isNull(sessionTable.impersonatedBy),
97+
ne(sessionTable.token, session.session.token),
98+
...(impersonatorId ? [ne(sessionTable.userId, impersonatorId)] : [])
99+
)
95100
)
96-
)
97-
.returning({ id: sessionTable.id })
98-
99-
await bumpSecurityPolicyVersion(organizationId)
101+
.returning({ id: sessionTable.id })
102+
await tx
103+
.update(organization)
104+
.set({ securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1` })
105+
.where(eq(organization.id, organizationId))
106+
return deleted
107+
})
108+
invalidateSecurityPolicyVersionCache(organizationId)
100109

101110
logger.info('Revoked organization sessions', {
102111
organizationId,

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,5 +158,9 @@ export async function getSessionCookieCacheVersion(session: {
158158
userId?: string | null
159159
}): Promise<string> {
160160
const organizationId = await getMemberOrganizationId(session.userId)
161-
return String(await getSecurityPolicyVersion(organizationId))
161+
if (!organizationId) return 'none'
162+
// The org id is part of the version so moving between orgs always changes
163+
// the string — two orgs whose counters happen to hold the same number must
164+
// not produce interchangeable cookie versions.
165+
return `${organizationId}:${await getSecurityPolicyVersion(organizationId)}`
162166
}

0 commit comments

Comments
 (0)