Skip to content

Commit 8244ef2

Browse files
committed
fix(lifecycle): lock Project workspaces before successor grants
1 parent f2c4d32 commit 8244ef2

3 files changed

Lines changed: 170 additions & 28 deletions

File tree

‎apps/sim/lib/projects/account-deletion.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ export async function getProjectAccountDeletionBlockers(
179179
export async function prepareProjectsForAccountDeletion(
180180
tx: DbTransaction,
181181
userId: string,
182-
doomedWorkspaceIds: string[]
182+
doomedWorkspaceIds: string[],
183+
additionalWorkspaceIds: string[] = []
183184
): Promise<string[]> {
184185
const ownedEnvironments = await tx
185186
.select({ id: workspace.id })
@@ -205,6 +206,22 @@ export async function prepareProjectsForAccountDeletion(
205206
tx,
206207
records.map((record) => record.id)
207208
)
209+
/** Acquire every workspace before successor grants; member removal uses the same order. */
210+
const workspaceIds = [
211+
...new Set([
212+
...doomedWorkspaceIds,
213+
...additionalWorkspaceIds,
214+
...[...environments.values()].flatMap((rows) => rows.map((row) => row.id)),
215+
]),
216+
]
217+
if (workspaceIds.length) {
218+
await tx
219+
.select({ id: workspace.id })
220+
.from(workspace)
221+
.where(inArray(workspace.id, workspaceIds))
222+
.orderBy(asc(workspace.id))
223+
.for('update')
224+
}
208225
const doomed = new Set(doomedWorkspaceIds)
209226
const projectsToDelete: string[] = []
210227
const now = new Date()

‎apps/sim/lib/users/account-deletion.ts‎

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,7 @@ import {
2020
import { createLogger } from '@sim/logger'
2121
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
2222
import { formatQuotedNameList } from '@sim/utils/string'
23-
import {
24-
and,
25-
asc,
26-
eq,
27-
gt,
28-
inArray,
29-
isNotNull,
30-
isNull,
31-
lte,
32-
ne,
33-
notExists,
34-
or,
35-
sql,
36-
} from 'drizzle-orm'
23+
import { and, eq, gt, inArray, isNotNull, isNull, lte, ne, notExists, or, sql } from 'drizzle-orm'
3724
import type {
3825
AccountDeletionBlocker,
3926
AccountDeletionPlan,
@@ -795,24 +782,17 @@ export async function deleteUserAccount(userId: string): Promise<AccountDeletion
795782
for (const organizationId of sharedOrganizationIds) {
796783
await acquireOrganizationMutationLock(tx, organizationId)
797784
}
798-
const projectIdsToDelete = await prepareProjectsForAccountDeletion(
799-
tx,
800-
userId,
801-
doomedWorkspaceIds
802-
)
803785
const workspaceIds = new Set([
804786
...doomedWorkspaceIds,
805787
...(await loadRelatedWorkspaces(userId, tx)).map((row) => row.id),
806788
...(await listSharedResourceWorkspaceIdsForUser(tx, userId)),
807789
])
808-
if (workspaceIds.size) {
809-
await tx
810-
.select({ id: workspaceTable.id })
811-
.from(workspaceTable)
812-
.where(inArray(workspaceTable.id, [...workspaceIds]))
813-
.orderBy(asc(workspaceTable.id))
814-
.for('update')
815-
}
790+
const projectIdsToDelete = await prepareProjectsForAccountDeletion(
791+
tx,
792+
userId,
793+
doomedWorkspaceIds,
794+
[...workspaceIds]
795+
)
816796
/**
817797
* An admitted insert may hold the user FK lock while waiting for a workspace above.
818798
* Never wait in that cycle: refuse erasure atomically so the writer can finish. Keep

‎apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,151 @@ describe('shared resource retention across workspace departure and account erasu
438438
}
439439
)
440440

441+
it.each(['removal', 'deletion'] as const)(
442+
'serializes Project successor removal when %s holds the workspace first',
443+
async (first) => {
444+
const fixture = await seedResources(false, false)
445+
const successorId = generateId()
446+
const grantId = generateId()
447+
userIds.push(successorId)
448+
await db.insert(user).values({
449+
id: successorId,
450+
name: 'Project successor',
451+
email: `${successorId}@example.test`,
452+
emailVerified: true,
453+
createdAt: new Date(),
454+
updatedAt: new Date(),
455+
})
456+
await db
457+
.update(project)
458+
.set({ ownerId: fixture.departingId })
459+
.where(eq(project.id, fixture.projectId))
460+
await db
461+
.update(permissions)
462+
.set({ permissionType: 'write' })
463+
.where(eq(permissions.entityId, fixture.workspaceId))
464+
await db.insert(permissions).values({
465+
id: grantId,
466+
userId: successorId,
467+
entityId: fixture.workspaceId,
468+
entityType: 'workspace',
469+
permissionType: 'admin',
470+
})
471+
expect((await getAccountDeletionPlan(fixture.departingId)).blockers).toEqual([])
472+
const ready = createDeferred<number>()
473+
const release = createDeferred<void>()
474+
const holding = db.transaction(async (tx) => {
475+
if (first === 'removal') {
476+
await tx
477+
.select()
478+
.from(workspace)
479+
.where(eq(workspace.id, fixture.workspaceId))
480+
.for('no key update')
481+
} else {
482+
await tx
483+
.select()
484+
.from(knowledgeBase)
485+
.where(eq(knowledgeBase.id, fixture.kbId))
486+
.for('update')
487+
}
488+
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
489+
ready.resolve(connection.pid)
490+
await release.promise
491+
if (first === 'removal') {
492+
return revokeWorkspaceAccessTx(tx, {
493+
workspaceId: fixture.workspaceId,
494+
userId: successorId,
495+
expectedPermissionId: grantId,
496+
})
497+
}
498+
})
499+
const holderPid = await ready.promise
500+
const holderOutcome = Promise.allSettled([holding])
501+
const deleting = Promise.allSettled([deleteUserAccount(fixture.departingId)])
502+
let removal: Promise<PromiseSettledResult<unknown>[]> | undefined
503+
try {
504+
await expect
505+
.poll(
506+
async () =>
507+
(
508+
await db.execute(
509+
sql`SELECT pid FROM pg_stat_activity WHERE ${holderPid} = ANY(pg_blocking_pids(pid))`
510+
)
511+
).length,
512+
{ timeout: 5000 }
513+
)
514+
.toBeGreaterThan(0)
515+
if (first === 'deletion') {
516+
removal = Promise.allSettled([
517+
db.transaction((tx) =>
518+
revokeWorkspaceAccessTx(tx, {
519+
workspaceId: fixture.workspaceId,
520+
userId: successorId,
521+
expectedPermissionId: grantId,
522+
})
523+
),
524+
])
525+
await expect
526+
.poll(
527+
async () =>
528+
(
529+
await db.execute(
530+
sql`SELECT waiter.pid FROM pg_stat_activity waiter WHERE EXISTS (SELECT 1 FROM pg_stat_activity deleter WHERE ${holderPid} = ANY(pg_blocking_pids(deleter.pid)) AND deleter.pid = ANY(pg_blocking_pids(waiter.pid)))`
531+
)
532+
).length,
533+
{ timeout: 5000 }
534+
)
535+
.toBeGreaterThan(0)
536+
}
537+
} finally {
538+
release.resolve()
539+
}
540+
expect((await holderOutcome).filter((result) => result.status === 'rejected')).toEqual([])
541+
const deleted = await deleting
542+
if (first === 'removal') {
543+
expect(deleted).toMatchObject([
544+
{
545+
status: 'rejected',
546+
reason: {
547+
name: 'ProjectConflictError',
548+
message: expect.stringContaining('Give a teammate admin access'),
549+
},
550+
},
551+
])
552+
expect(
553+
await db
554+
.select({ ownerId: project.ownerId })
555+
.from(project)
556+
.where(eq(project.id, fixture.projectId))
557+
).toEqual([{ ownerId: fixture.departingId }])
558+
expect(
559+
await db
560+
.select({ userId: knowledgeBase.userId })
561+
.from(knowledgeBase)
562+
.where(eq(knowledgeBase.id, fixture.kbId))
563+
).toEqual([{ userId: fixture.departingId }])
564+
await db.insert(permissions).values({
565+
id: generateId(),
566+
userId: successorId,
567+
entityId: fixture.workspaceId,
568+
entityType: 'workspace',
569+
permissionType: 'admin',
570+
})
571+
await deleteUserAccount(fixture.departingId)
572+
} else {
573+
expect(deleted.filter((result) => result.status === 'rejected')).toEqual([])
574+
expect((await removal)?.filter((result) => result.status === 'rejected')).toEqual([])
575+
}
576+
expect(
577+
await db
578+
.select({ ownerId: project.ownerId })
579+
.from(project)
580+
.where(eq(project.id, fixture.projectId))
581+
).toEqual([{ ownerId: successorId }])
582+
await assertRetained(fixture)
583+
}
584+
)
585+
441586
it.each([
442587
['shared', 'user'],
443588
['shared', 'workspace'],

0 commit comments

Comments
 (0)