Skip to content

Commit 9f64dfd

Browse files
fix(credentials): serialize invitation lifecycle
1 parent 4a2a6e7 commit 9f64dfd

2 files changed

Lines changed: 143 additions & 40 deletions

File tree

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ vi.mock('@/components/emails/render', () => ({
1010

1111
vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: vi.fn() }))
1212

13-
import { listCredentialGroupEnrollments } from '@/lib/credential-groups/enrollments'
13+
import {
14+
listCredentialGroupEnrollments,
15+
resendCredentialGroupEnrollment,
16+
} from '@/lib/credential-groups/enrollments'
1417
import { CREDENTIAL_GROUP_PROVIDER_IDS } from '@/lib/credential-groups/providers'
18+
import { sendEmail } from '@/lib/messaging/email/mailer'
1519

1620
const MAX_CONNECTION_SUMMARIES = CREDENTIAL_GROUP_PROVIDER_IDS.length * 3
1721

@@ -109,3 +113,36 @@ describe('listCredentialGroupEnrollments', () => {
109113
expect(dbChainMockFns.select).not.toHaveBeenCalled()
110114
})
111115
})
116+
117+
describe('resendCredentialGroupEnrollment', () => {
118+
beforeEach(() => {
119+
vi.clearAllMocks()
120+
resetDbChainMock()
121+
})
122+
123+
it('does not reactivate an enrollment revoked while resend waits for its lifecycle lock', async () => {
124+
dbChainMockFns.limit
125+
.mockResolvedValueOnce([
126+
{
127+
workspaceId: 'workspace-1',
128+
workspaceName: 'Workspace',
129+
groupId: 'group-1',
130+
groupName: 'Group',
131+
groupStatus: 'active',
132+
options: [{ id: 'option-1', status: 'active' }],
133+
},
134+
])
135+
.mockResolvedValueOnce([{ enrollment: { ...ENROLLMENT, status: 'invited' } }])
136+
.mockResolvedValueOnce([{ ...ENROLLMENT, status: 'invited' }])
137+
.mockResolvedValueOnce([{ ...ENROLLMENT, status: 'revoked' }])
138+
139+
await expect(
140+
resendCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id, 'user-1', 'Inviter')
141+
).rejects.toThrow('Revoked enrollment cannot be resent')
142+
143+
expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2)
144+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
145+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
146+
expect(sendEmail).not.toHaveBeenCalled()
147+
})
148+
})

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

Lines changed: 105 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createHash } from 'crypto'
21
import { db } from '@sim/db'
32
import {
43
type CredentialGroupOptionConfig,
@@ -8,6 +7,7 @@ import {
87
user,
98
workspace,
109
} from '@sim/db/schema'
10+
import { sha256Hex } from '@sim/security/hash'
1111
import { getErrorMessage } from '@sim/utils/errors'
1212
import { generateId } from '@sim/utils/id'
1313
import { normalizeEmail, truncate } from '@sim/utils/string'
@@ -55,6 +55,11 @@ interface InvitationContext {
5555
groupName: string
5656
}
5757

58+
interface SendInvitationOptions {
59+
expectedEnrollmentId?: string
60+
revokedEnrollment: 'reactivate' | 'reject'
61+
}
62+
5863
export interface PublicCredentialGroupEnrollment {
5964
inviterName: string
6065
workspaceName: string
@@ -97,6 +102,19 @@ export async function lockCredentialGroupEnrollmentLifecycle(
97102
)
98103
}
99104

105+
/** Serializes invitation issuance before an enrollment row is known or locked. */
106+
async function lockCredentialGroupInvitationTarget(
107+
executor: DbOrTx,
108+
groupId: string,
109+
email: string
110+
): Promise<void> {
111+
if (!groupId.trim()) throw new Error('Credential group ID is required')
112+
if (!email.trim()) throw new Error('Credential group enrollment email is required')
113+
await executor.execute(
114+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-invitation:${groupId}:${email}`}, 0))`
115+
)
116+
}
117+
100118
export class CredentialGroupEnrollmentError extends Error {
101119
constructor(
102120
message: string,
@@ -108,7 +126,7 @@ export class CredentialGroupEnrollmentError extends Error {
108126
}
109127

110128
function hashInvitationToken(token: string): string {
111-
return createHash('sha256').update(token).digest('hex')
129+
return sha256Hex(token)
112130
}
113131

114132
function metadataString(metadata: object | null, key: string): string | null {
@@ -209,20 +227,53 @@ async function sendInvitation(
209227
context: InvitationContext,
210228
userId: string,
211229
inviterName: string,
212-
email: string
230+
email: string,
231+
options: SendInvitationOptions
213232
): Promise<CredentialGroupEnrollment> {
214233
const now = new Date()
215234
const token = generateId()
216235
const tokenHash = hashInvitationToken(token)
217236
const expiresAt = new Date(now.getTime() + INVITATION_TTL_MS)
218237

219-
const [issued] = await db
220-
.insert(credentialGroupEnrollment)
221-
.values({
222-
id: generateId(),
223-
credentialGroupId: context.groupId,
224-
email,
225-
status: 'invited',
238+
const issued = await db.transaction(async (tx) => {
239+
await lockCredentialGroupInvitationTarget(tx, context.groupId, email)
240+
const [existing] = await tx
241+
.select()
242+
.from(credentialGroupEnrollment)
243+
.where(
244+
and(
245+
eq(credentialGroupEnrollment.credentialGroupId, context.groupId),
246+
eq(credentialGroupEnrollment.email, email)
247+
)
248+
)
249+
.limit(1)
250+
251+
let current = existing
252+
if (existing) {
253+
await lockCredentialGroupEnrollmentLifecycle(tx, existing.id)
254+
const [locked] = await tx
255+
.select()
256+
.from(credentialGroupEnrollment)
257+
.where(
258+
and(
259+
eq(credentialGroupEnrollment.id, existing.id),
260+
eq(credentialGroupEnrollment.credentialGroupId, context.groupId),
261+
eq(credentialGroupEnrollment.email, email)
262+
)
263+
)
264+
.limit(1)
265+
current = locked
266+
}
267+
268+
if (options.expectedEnrollmentId && current?.id !== options.expectedEnrollmentId) {
269+
throw new CredentialGroupEnrollmentError('Enrollment not found', 404)
270+
}
271+
if (current?.status === 'revoked' && options.revokedEnrollment === 'reject') {
272+
throw new CredentialGroupEnrollmentError('Revoked enrollment cannot be resent', 409)
273+
}
274+
275+
const mutableValues = {
276+
status: 'invited' as const,
226277
invitationTokenHash: tokenHash,
227278
invitationExpiresAt: expiresAt,
228279
invitedAt: now,
@@ -231,26 +282,27 @@ async function sendInvitation(
231282
revokedAt: null,
232283
lastDeliveryError: null,
233284
createdBy: userId,
234-
createdAt: now,
235285
updatedAt: now,
236-
})
237-
.onConflictDoUpdate({
238-
target: [credentialGroupEnrollment.credentialGroupId, credentialGroupEnrollment.email],
239-
set: {
240-
status: 'invited',
241-
invitationTokenHash: tokenHash,
242-
invitationExpiresAt: expiresAt,
243-
invitedAt: now,
244-
sentAt: null,
245-
completedAt: null,
246-
revokedAt: null,
247-
lastDeliveryError: null,
248-
createdBy: userId,
249-
updatedAt: now,
250-
},
251-
})
252-
.returning()
253-
if (!issued) throw new Error('Credential group enrollment upsert returned no row')
286+
}
287+
const [next] = current
288+
? await tx
289+
.update(credentialGroupEnrollment)
290+
.set(mutableValues)
291+
.where(eq(credentialGroupEnrollment.id, current.id))
292+
.returning()
293+
: await tx
294+
.insert(credentialGroupEnrollment)
295+
.values({
296+
id: generateId(),
297+
credentialGroupId: context.groupId,
298+
email,
299+
...mutableValues,
300+
createdAt: now,
301+
})
302+
.returning()
303+
if (!next) throw new Error('Credential group enrollment write returned no row')
304+
return next
305+
})
254306

255307
const invitationLink = `${getBaseUrl()}/credential-groups/enroll/${token}`
256308
const html = await renderCredentialGroupInvitationEmail({
@@ -269,7 +321,7 @@ async function sendInvitation(
269321
})
270322

271323
if (!result.success) {
272-
await db
324+
const [failed] = await db
273325
.update(credentialGroupEnrollment)
274326
.set({
275327
status: 'delivery_failed',
@@ -279,9 +331,17 @@ async function sendInvitation(
279331
.where(
280332
and(
281333
eq(credentialGroupEnrollment.id, issued.id),
282-
eq(credentialGroupEnrollment.invitationTokenHash, tokenHash)
334+
eq(credentialGroupEnrollment.invitationTokenHash, tokenHash),
335+
eq(credentialGroupEnrollment.status, 'invited')
283336
)
284337
)
338+
.returning({ id: credentialGroupEnrollment.id })
339+
if (!failed) {
340+
throw new CredentialGroupEnrollmentError(
341+
'Invitation was superseded by another enrollment action',
342+
409
343+
)
344+
}
285345
throw new CredentialGroupEnrollmentError(result.message, 502)
286346
}
287347

@@ -291,7 +351,8 @@ async function sendInvitation(
291351
.where(
292352
and(
293353
eq(credentialGroupEnrollment.id, issued.id),
294-
eq(credentialGroupEnrollment.invitationTokenHash, tokenHash)
354+
eq(credentialGroupEnrollment.invitationTokenHash, tokenHash),
355+
eq(credentialGroupEnrollment.status, 'invited')
295356
)
296357
)
297358
.returning()
@@ -449,7 +510,9 @@ export async function inviteCredentialGroupEnrollments(
449510
const chunkResults = await Promise.all(
450511
chunk.map(async (email) => {
451512
try {
452-
const enrollment = await sendInvitation(context, userId, inviterName, email)
513+
const enrollment = await sendInvitation(context, userId, inviterName, email, {
514+
revokedEnrollment: 'reactivate',
515+
})
453516
return { email, success: true as const, enrollment }
454517
} catch (error) {
455518
return {
@@ -486,7 +549,9 @@ export async function inviteCredentialGroupEnrollment(
486549
email: string
487550
): Promise<CredentialGroupEnrollment> {
488551
const context = await getInvitationContext(workspaceId, groupId)
489-
return sendInvitation(context, userId, inviterName, normalizeEmail(email))
552+
return sendInvitation(context, userId, inviterName, normalizeEmail(email), {
553+
revokedEnrollment: 'reactivate',
554+
})
490555
}
491556

492557
export async function resendCredentialGroupEnrollment(
@@ -510,10 +575,10 @@ export async function resendCredentialGroupEnrollment(
510575
)
511576
.limit(1)
512577
if (!row) throw new CredentialGroupEnrollmentError('Enrollment not found', 404)
513-
if (row.enrollment.status === 'revoked') {
514-
throw new CredentialGroupEnrollmentError('Revoked enrollment cannot be resent', 409)
515-
}
516-
return sendInvitation(context, userId, inviterName, row.enrollment.email)
578+
return sendInvitation(context, userId, inviterName, row.enrollment.email, {
579+
expectedEnrollmentId: enrollmentId,
580+
revokedEnrollment: 'reject',
581+
})
517582
}
518583

519584
export async function revokeCredentialGroupEnrollment(
@@ -522,7 +587,7 @@ export async function revokeCredentialGroupEnrollment(
522587
enrollmentId: string
523588
): Promise<CredentialGroupEnrollment> {
524589
const [existing] = await db
525-
.select({ enrollmentId: credentialGroupEnrollment.id })
590+
.select({ email: credentialGroupEnrollment.email })
526591
.from(credentialGroupEnrollment)
527592
.innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId))
528593
.where(
@@ -536,6 +601,7 @@ export async function revokeCredentialGroupEnrollment(
536601
if (!existing) throw new CredentialGroupEnrollmentError('Enrollment not found', 404)
537602

538603
return db.transaction(async (tx) => {
604+
await lockCredentialGroupInvitationTarget(tx, groupId, existing.email)
539605
await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId)
540606
const now = new Date()
541607
const [revoked] = await tx

0 commit comments

Comments
 (0)