Skip to content

Commit 04ec2df

Browse files
committed
fix(auth): recheck OAuth entitlement under organization lock
1 parent 1f0be39 commit 04ec2df

5 files changed

Lines changed: 190 additions & 121 deletions

File tree

apps/sim/lib/auth/oauth-provider-lifecycle.postgres.test.ts

Lines changed: 144 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ async function loadRuntime() {
3535
{ runCleanupOAuthTokens, OAUTH_TOKEN_RETENTION_DAYS },
3636
{ isCapabilityWithheldForUser },
3737
{ acquirePermissionGroupOrgLock },
38+
{ acquireOrganizationMutationLock },
3839
] = await Promise.all([
3940
import('@sim/db'),
4041
import('@sim/db/schema'),
@@ -50,6 +51,7 @@ async function loadRuntime() {
5051
import('@/background/cleanup-oauth-tokens'),
5152
import('@/lib/permission-groups/user-scope.server'),
5253
import('@/lib/permission-groups/locks'),
54+
import('@/lib/billing/organizations/membership'),
5355
])
5456
const adapter = createSimAuthAdapter({
5557
plugins: [
@@ -81,6 +83,7 @@ async function loadRuntime() {
8183
OAUTH_TOKEN_RETENTION_DAYS,
8284
isCapabilityWithheldForUser,
8385
acquirePermissionGroupOrgLock,
86+
acquireOrganizationMutationLock,
8487
}
8588
}
8689

@@ -298,111 +301,157 @@ describe.skipIf(!databaseUrl)('OAuth lifecycle on the provisioned PostgreSQL sch
298301
}
299302
)
300303

301-
it('serializes refresh against a pending policy restriction without consuming the token', async () => {
302-
const { db, schema, eq, sql, statement, acquirePermissionGroupOrgLock } = runtime
303-
const groupId = await createDefaultGroup()
304-
await grantConsent()
305-
const family = await issueFamily()
306-
const credentials = { clientId, method: 'none' as const }
307-
const writerReady = Promise.withResolvers<number>()
308-
const releaseWriter = Promise.withResolvers<void>()
309-
const writer = db.transaction(async (tx) => {
310-
await acquirePermissionGroupOrgLock(tx, organizationId!)
311-
await tx
312-
.update(schema.permissionGroup)
313-
.set({ config: { disableOAuthAppAccess: true } })
314-
.where(eq(schema.permissionGroup.id, groupId))
315-
const [connection] = await tx.execute<{ pid: number }>(
316-
statement`select pg_backend_pid() as pid`
317-
)
318-
writerReady.resolve(connection.pid)
319-
await releaseWriter.promise
320-
})
321-
let refresh: ReturnType<typeof runtime.rotateOAuthRefreshToken> | undefined
322-
323-
try {
324-
const writerPid = await Promise.race([
325-
writerReady.promise,
326-
writer.then(() => {
327-
throw new Error('Policy writer finished before the concurrency check')
328-
}),
329-
])
330-
let refreshSettled = false
331-
refresh = runtime.rotateOAuthRefreshToken({ credentials, refreshToken: family.refreshToken })
332-
void refresh.then(
333-
() => {
334-
refreshSettled = true
335-
},
336-
() => {
337-
refreshSettled = true
304+
it.each(['policy restriction', 'enterprise activation'] as const)(
305+
'serializes refresh against a pending %s without consuming the token',
306+
async (change) => {
307+
const {
308+
db,
309+
schema,
310+
eq,
311+
sql,
312+
statement,
313+
acquirePermissionGroupOrgLock,
314+
acquireOrganizationMutationLock,
315+
} = runtime
316+
const groupId = await createDefaultGroup()
317+
await grantConsent()
318+
const family = await issueFamily()
319+
if (change === 'enterprise activation') {
320+
await db
321+
.update(schema.subscription)
322+
.set({
323+
plan: 'team',
324+
metadata: { plan: 'team', referenceId: organizationId!, seats: 5, monthlyPrice: 100 },
325+
})
326+
.where(eq(schema.subscription.referenceId, organizationId!))
327+
await db
328+
.update(schema.permissionGroup)
329+
.set({ config: { disableOAuthAppAccess: true } })
330+
.where(eq(schema.permissionGroup.id, groupId))
331+
}
332+
expect(await runtime.isCapabilityWithheldForUser(userId, 'oauth_apps.use')).toBe(false)
333+
const credentials = { clientId, method: 'none' as const }
334+
const writerReady = Promise.withResolvers<number>()
335+
const releaseWriter = Promise.withResolvers<void>()
336+
const writer = db.transaction(async (tx) => {
337+
if (change === 'enterprise activation') {
338+
await acquireOrganizationMutationLock(tx, organizationId!)
339+
await tx
340+
.update(schema.subscription)
341+
.set({
342+
plan: 'enterprise',
343+
metadata: {
344+
plan: 'enterprise',
345+
referenceId: organizationId!,
346+
seats: 5,
347+
monthlyPrice: 100,
348+
},
349+
})
350+
.where(eq(schema.subscription.referenceId, organizationId!))
351+
} else {
352+
await acquirePermissionGroupOrgLock(tx, organizationId!)
353+
await tx
354+
.update(schema.permissionGroup)
355+
.set({ config: { disableOAuthAppAccess: true } })
356+
.where(eq(schema.permissionGroup.id, groupId))
338357
}
339-
)
340-
341-
let waitingForPolicy = false
342-
const deadline = Date.now() + 2_000
343-
while (!refreshSettled && Date.now() < deadline) {
344-
const [waiter] = await sql<{ waiting: boolean }[]>`
358+
const [connection] = await tx.execute<{ pid: number }>(
359+
statement`select pg_backend_pid() as pid`
360+
)
361+
writerReady.resolve(connection.pid)
362+
await releaseWriter.promise
363+
})
364+
let refresh: ReturnType<typeof runtime.rotateOAuthRefreshToken> | undefined
365+
366+
try {
367+
const writerPid = await Promise.race([
368+
writerReady.promise,
369+
writer.then(() => {
370+
throw new Error('Policy writer finished before the concurrency check')
371+
}),
372+
])
373+
let refreshSettled = false
374+
refresh = runtime.rotateOAuthRefreshToken({
375+
credentials,
376+
refreshToken: family.refreshToken,
377+
})
378+
void refresh.then(
379+
() => {
380+
refreshSettled = true
381+
},
382+
() => {
383+
refreshSettled = true
384+
}
385+
)
386+
387+
let waitingForPolicy = false
388+
const deadline = Date.now() + 2_000
389+
while (!refreshSettled && Date.now() < deadline) {
390+
const [waiter] = await sql<{ waiting: boolean }[]>`
345391
SELECT EXISTS (
346392
SELECT 1 FROM pg_stat_activity
347393
WHERE ${writerPid} = ANY(pg_blocking_pids(pid))
348394
AND wait_event_type = 'Lock'
349395
AND wait_event = 'advisory'
350396
) AS waiting
351397
`
352-
if (waiter.waiting) {
353-
waitingForPolicy = true
354-
break
398+
if (waiter.waiting) {
399+
waitingForPolicy = true
400+
break
401+
}
402+
await sleep(1)
355403
}
356-
await sleep(1)
404+
expect(waitingForPolicy, 'Refresh must wait for the organization policy writer').toBe(true)
405+
expect(refreshSettled).toBe(false)
406+
releaseWriter.resolve()
407+
await writer
408+
expect(await runtime.isCapabilityWithheldForUser(userId, 'oauth_apps.use')).toBe(true)
409+
await expect(refresh).resolves.toMatchObject({ success: false, error: 'invalid_grant' })
410+
expect(
411+
await db
412+
.select({ generation: schema.oauthTokenFamily.currentGeneration })
413+
.from(schema.oauthTokenFamily)
414+
.where(eq(schema.oauthTokenFamily.id, family.id))
415+
).toEqual([{ generation: 0 }])
416+
expect(
417+
await db
418+
.select({
419+
generation: schema.oauthRefreshToken.generation,
420+
revoked: schema.oauthRefreshToken.revoked,
421+
})
422+
.from(schema.oauthRefreshToken)
423+
.where(eq(schema.oauthRefreshToken.familyId, family.id))
424+
).toEqual([{ generation: 0, revoked: null }])
425+
expect(
426+
await db
427+
.select({ id: schema.oauthAccessToken.id })
428+
.from(schema.oauthAccessToken)
429+
.where(eq(schema.oauthAccessToken.clientId, clientId))
430+
).toHaveLength(1)
431+
432+
await db.transaction(async (tx) => {
433+
await acquirePermissionGroupOrgLock(tx, organizationId!)
434+
await tx
435+
.update(schema.permissionGroup)
436+
.set({ config: {} })
437+
.where(eq(schema.permissionGroup.id, groupId))
438+
})
439+
await expect(
440+
runtime.rotateOAuthRefreshToken({ credentials, refreshToken: family.refreshToken })
441+
).resolves.toMatchObject({ success: true })
442+
expect(
443+
await db
444+
.select({ generation: schema.oauthTokenFamily.currentGeneration })
445+
.from(schema.oauthTokenFamily)
446+
.where(eq(schema.oauthTokenFamily.id, family.id))
447+
).toEqual([{ generation: 1 }])
448+
} finally {
449+
releaseWriter.resolve()
450+
await Promise.allSettled([writer, ...(refresh ? [refresh] : [])])
357451
}
358-
expect(waitingForPolicy, 'Refresh must wait for the organization policy writer').toBe(true)
359-
expect(refreshSettled).toBe(false)
360-
releaseWriter.resolve()
361-
await writer
362-
await expect(refresh).resolves.toMatchObject({ success: false, error: 'invalid_grant' })
363-
expect(
364-
await db
365-
.select({ generation: schema.oauthTokenFamily.currentGeneration })
366-
.from(schema.oauthTokenFamily)
367-
.where(eq(schema.oauthTokenFamily.id, family.id))
368-
).toEqual([{ generation: 0 }])
369-
expect(
370-
await db
371-
.select({
372-
generation: schema.oauthRefreshToken.generation,
373-
revoked: schema.oauthRefreshToken.revoked,
374-
})
375-
.from(schema.oauthRefreshToken)
376-
.where(eq(schema.oauthRefreshToken.familyId, family.id))
377-
).toEqual([{ generation: 0, revoked: null }])
378-
expect(
379-
await db
380-
.select({ id: schema.oauthAccessToken.id })
381-
.from(schema.oauthAccessToken)
382-
.where(eq(schema.oauthAccessToken.clientId, clientId))
383-
).toHaveLength(1)
384-
385-
await db.transaction(async (tx) => {
386-
await acquirePermissionGroupOrgLock(tx, organizationId!)
387-
await tx
388-
.update(schema.permissionGroup)
389-
.set({ config: {} })
390-
.where(eq(schema.permissionGroup.id, groupId))
391-
})
392-
await expect(
393-
runtime.rotateOAuthRefreshToken({ credentials, refreshToken: family.refreshToken })
394-
).resolves.toMatchObject({ success: true })
395-
expect(
396-
await db
397-
.select({ generation: schema.oauthTokenFamily.currentGeneration })
398-
.from(schema.oauthTokenFamily)
399-
.where(eq(schema.oauthTokenFamily.id, family.id))
400-
).toEqual([{ generation: 1 }])
401-
} finally {
402-
releaseWriter.resolve()
403-
await Promise.allSettled([writer, ...(refresh ? [refresh] : [])])
404-
}
405-
}, 10_000)
452+
},
453+
10_000
454+
)
406455

407456
it('atomically converges concurrent consent submissions on one grant', async () => {
408457
const grants = await Promise.all([grantConsent(), grantConsent()])

apps/sim/lib/auth/oauth-token-family.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,6 @@ export async function rotateOAuthRefreshToken(
276276

277277
const membership = await getUserOrganization(provisionalToken.userId, database)
278278
const organizationId = membership?.organizationId ?? null
279-
const permissionRegimeActive =
280-
organizationId !== null && (await isOrganizationPermissionRegimeActive(organizationId))
281279
const nextRefreshBody = generateSecureToken(32)
282280
const nextAccessBody = generateSecureToken(32)
283281
const nextRefreshId = generateId()
@@ -295,6 +293,8 @@ export async function rotateOAuthRefreshToken(
295293
'Organization membership changed. Please sign in again.'
296294
)
297295
}
296+
const permissionRegimeActive =
297+
organizationId !== null && (await isOrganizationPermissionRegimeActive(organizationId, tx))
298298

299299
const [activeUser] = await tx
300300
.select({

apps/sim/lib/permission-groups/resolve.server.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
55
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import type { DbOrTx } from '@/lib/db/types'
67

78
const { mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner } = vi.hoisted(() => ({
89
mockIsOrganizationOnEnterprisePlan: vi.fn(),
@@ -20,6 +21,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
2021
import {
2122
getUserPermissionConfig,
2223
getUserPermissionConfigForOrganization,
24+
isOrganizationPermissionRegimeActive,
2325
resolveVerifiedUserAccessControlContext,
2426
} from '@/lib/permission-groups/resolve.server'
2527

@@ -101,4 +103,33 @@ describe('permission-group resolution under a failed entitlement read', () => {
101103
})
102104
await expect(getUserPermissionConfigForOrganization(ORGANIZATION_ID)).resolves.toBeNull()
103105
})
106+
107+
it('rechecks entitlement on the caller transaction after an unentitled preflight', async () => {
108+
const executor = {} as DbOrTx
109+
mockIsOrganizationOnEnterprisePlan.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
110+
111+
await expect(isOrganizationPermissionRegimeActive(ORGANIZATION_ID)).resolves.toBe(false)
112+
await expect(isOrganizationPermissionRegimeActive(ORGANIZATION_ID, executor)).resolves.toBe(
113+
true
114+
)
115+
expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenLastCalledWith(
116+
ORGANIZATION_ID,
117+
'throw',
118+
executor
119+
)
120+
})
121+
122+
it('propagates a transaction entitlement read failure instead of disabling restrictions', async () => {
123+
const executor = {} as DbOrTx
124+
entitlementReadFails()
125+
126+
await expect(isOrganizationPermissionRegimeActive(ORGANIZATION_ID, executor)).rejects.toThrow(
127+
'billing database unavailable'
128+
)
129+
expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith(
130+
ORGANIZATION_ID,
131+
'throw',
132+
executor
133+
)
134+
})
104135
})

apps/sim/lib/permission-groups/resolve.server.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,24 +310,22 @@ export async function getUserPermissionConfigForOrganization(
310310
* enables Access Control, and the organization holds the Enterprise entitlement
311311
* that turns the regime on.
312312
*
313-
* Split out of {@link getUserPermissionConfigForOrganization} so a caller that
314-
* must re-read the *group* under `acquirePermissionGroupOrgLock` can settle this
315-
* half BEFORE opening its transaction. The entitlement read cannot move into a
316-
* transaction: {@link isOrganizationOnEnterprisePlan} is `cache()`d on its
317-
* argument list, so it admits no executor, and giving it one would both miss the
318-
* memo on every call and — because an unentitled organization resolves to
319-
* `config: null`, meaning every capability ALLOWED — turn a read failure into a
320-
* fail-open. The lock never serialized this half either way: it guards
321-
* permission-group writes, not subscription changes.
313+
* Callers that serialize entitlement changes with an organization mutation
314+
* lock must pass their transaction after acquiring that lock. The executor is
315+
* part of the entitlement cache key, so this read cannot reuse a preflight
316+
* result. A permission-group lock alone only serializes group writes.
322317
*
323318
* `'throw'` for the same reason as in
324319
* {@link resolveUserAccessControlContextForOrganization}.
325320
*/
326321
export async function isOrganizationPermissionRegimeActive(
327-
organizationId: string
322+
organizationId: string,
323+
executor?: DbOrTx
328324
): Promise<boolean> {
329325
if (!isHosted && !isAccessControlEnabled) return false
330-
return isOrganizationOnEnterprisePlan(organizationId, 'throw')
326+
return executor
327+
? isOrganizationOnEnterprisePlan(organizationId, 'throw', executor)
328+
: isOrganizationOnEnterprisePlan(organizationId, 'throw')
331329
}
332330

333331
/**

0 commit comments

Comments
 (0)