Skip to content

Commit caad5d0

Browse files
fix(credentials): serialize enrollment revocation
1 parent 8663a42 commit caad5d0

3 files changed

Lines changed: 138 additions & 5 deletions

File tree

apps/sim/lib/credential-groups/enrollments.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { getErrorMessage } from '@sim/utils/errors'
1212
import { generateId } from '@sim/utils/id'
1313
import { normalizeEmail, truncate } from '@sim/utils/string'
14-
import { and, count, desc, eq, inArray, lt, or } from 'drizzle-orm'
14+
import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm'
1515
import { renderCredentialGroupInvitationEmail } from '@/components/emails/render'
1616
import type {
1717
CredentialGroupEnrollment,
@@ -29,6 +29,7 @@ import {
2929
getCredentialGroupProviderFromProviderId,
3030
isCredentialGroupProvider,
3131
} from '@/lib/credential-groups/providers'
32+
import type { DbOrTx } from '@/lib/db/types'
3233
import { sendEmail } from '@/lib/messaging/email/mailer'
3334
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
3435
import { getBrandConfig } from '@/ee/whitelabeling'
@@ -85,6 +86,17 @@ export interface CredentialGroupOAuthContext {
8586
options: CredentialGroupOptionConfig[]
8687
}
8788

89+
/** Serializes OAuth grant persistence and administrative revocation for one enrollment. */
90+
export async function lockCredentialGroupEnrollmentLifecycle(
91+
executor: DbOrTx,
92+
enrollmentId: string
93+
): Promise<void> {
94+
if (!enrollmentId.trim()) throw new Error('Credential group enrollment ID is required')
95+
await executor.execute(
96+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-enrollment:${enrollmentId}`}, 0))`
97+
)
98+
}
99+
88100
export class CredentialGroupEnrollmentError extends Error {
89101
constructor(
90102
message: string,
@@ -524,6 +536,7 @@ export async function revokeCredentialGroupEnrollment(
524536
if (!existing) throw new CredentialGroupEnrollmentError('Enrollment not found', 404)
525537

526538
return db.transaction(async (tx) => {
539+
await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId)
527540
const now = new Date()
528541
const [revoked] = await tx
529542
.update(credentialGroupEnrollment)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { adapter } = vi.hoisted(() => ({
8+
adapter: {
9+
provider: 'gmail' as const,
10+
requiresRefreshToken: true,
11+
getPolicy: vi.fn(),
12+
prepareAuthorization: vi.fn(),
13+
exchangeAndVerify: vi.fn(),
14+
hasRequiredScopes: vi.fn(),
15+
refreshToken: vi.fn(),
16+
isTerminalRefreshError: vi.fn(),
17+
},
18+
}))
19+
20+
vi.mock('@/lib/credential-groups/provider-registry', () => ({
21+
getCredentialGroupProviderAdapter: () => adapter,
22+
}))
23+
24+
import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth'
25+
26+
const POLICY = {
27+
provider: 'gmail' as const,
28+
providerId: 'google-email',
29+
authorizationAppId: 'google:client',
30+
requiredScopes: ['openid', 'https://www.googleapis.com/auth/gmail.modify'],
31+
scopeVersion: 1,
32+
}
33+
34+
const CONTEXT = {
35+
enrollmentId: 'enrollment-1',
36+
credentialGroupId: 'group-1',
37+
workspaceId: 'workspace-1',
38+
workspaceName: 'Workspace',
39+
workspaceOwnerId: 'owner-1',
40+
email: 'person@example.com',
41+
enrollmentStatus: 'in_progress' as const,
42+
option: {
43+
id: 'option-1',
44+
provider: 'gmail' as const,
45+
label: 'Gmail',
46+
required: true,
47+
status: 'active' as const,
48+
},
49+
options: [],
50+
}
51+
52+
describe('credential group OAuth persistence', () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks()
55+
resetDbChainMock()
56+
adapter.getPolicy.mockResolvedValue(POLICY)
57+
adapter.exchangeAndVerify.mockResolvedValue({
58+
providerId: POLICY.providerId,
59+
providerSubjectId: 'google-subject-1',
60+
providerTenantId: null,
61+
displayName: 'person@example.com',
62+
metadata: { email: 'person@example.com' },
63+
accessToken: 'access-token',
64+
refreshToken: 'refresh-token',
65+
grantedScopes: POLICY.requiredScopes,
66+
accessTokenExpiresAt: new Date('2026-08-14T00:00:00Z'),
67+
refreshTokenExpiresAt: null,
68+
})
69+
})
70+
71+
it('does not reactivate a credential after its enrollment is revoked', async () => {
72+
dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'revoked' }])
73+
74+
await expect(
75+
completeCredentialGroupOAuth(
76+
CONTEXT,
77+
{
78+
state: 'state-1',
79+
provider: 'gmail',
80+
nonceHash: 'nonce-hash',
81+
enrollmentId: CONTEXT.enrollmentId,
82+
credentialGroupId: CONTEXT.credentialGroupId,
83+
optionId: CONTEXT.option.id,
84+
authorizationAppId: POLICY.authorizationAppId,
85+
scopeVersion: POLICY.scopeVersion,
86+
requiredScopes: POLICY.requiredScopes,
87+
redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback',
88+
codeVerifier: 'verifier',
89+
invitationToken: 'invitation-token',
90+
createdAt: Date.now(),
91+
},
92+
'authorization-code'
93+
)
94+
).rejects.toThrow('This account invitation was revoked.')
95+
96+
expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2)
97+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
98+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
99+
})
100+
})

apps/sim/lib/credential-groups/oauth.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { db } from '@sim/db'
22
import { credential, credentialGroupEnrollment } from '@sim/db/schema'
33
import { generateId } from '@sim/utils/id'
4-
import { and, eq, sql } from 'drizzle-orm'
5-
import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments'
4+
import { and, eq, ne, sql } from 'drizzle-orm'
5+
import {
6+
type CredentialGroupOAuthContext,
7+
lockCredentialGroupEnrollmentLifecycle,
8+
} from '@/lib/credential-groups/enrollments'
69
import {
710
type CredentialGroupOAuthAttempt,
811
createCredentialGroupOAuthAttempt,
@@ -98,9 +101,19 @@ async function persistGrant(
98101
}
99102

100103
await db.transaction(async (tx) => {
104+
await lockCredentialGroupEnrollmentLifecycle(tx, context.enrollmentId)
101105
await tx.execute(
102106
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-oauth:${context.enrollmentId}:${context.option.id}`}, 0))`
103107
)
108+
const [enrollment] = await tx
109+
.select({ status: credentialGroupEnrollment.status })
110+
.from(credentialGroupEnrollment)
111+
.where(eq(credentialGroupEnrollment.id, context.enrollmentId))
112+
.limit(1)
113+
if (!enrollment || enrollment.status === 'revoked') {
114+
throw new CredentialGroupOAuthError('This account invitation was revoked.', 409)
115+
}
116+
104117
const [existing] = await tx
105118
.select({
106119
id: credential.id,
@@ -193,9 +206,16 @@ async function persistGrant(
193206
completedAt: null,
194207
updatedAt: now,
195208
})
196-
.where(eq(credentialGroupEnrollment.id, context.enrollmentId))
209+
.where(
210+
and(
211+
eq(credentialGroupEnrollment.id, context.enrollmentId),
212+
ne(credentialGroupEnrollment.status, 'revoked')
213+
)
214+
)
197215
.returning({ id: credentialGroupEnrollment.id })
198-
if (!updatedEnrollment) throw new Error('Credential group enrollment update returned no row')
216+
if (!updatedEnrollment) {
217+
throw new CredentialGroupOAuthError('This account invitation was revoked.', 409)
218+
}
199219
})
200220
}
201221

0 commit comments

Comments
 (0)