From 463be5879e6555801e2c8664eb48e2f794a486b6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:33:31 -0800 Subject: [PATCH 1/4] fix(sim-links): hand off a departing member's links to the successor --- .../billing/organizations/resource-handoff.ts | 5 ++ .../shared-resource-handoff.integration.ts | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/apps/sim/lib/billing/organizations/resource-handoff.ts b/apps/sim/lib/billing/organizations/resource-handoff.ts index 6baada8240f..cb78ff949f3 100644 --- a/apps/sim/lib/billing/organizations/resource-handoff.ts +++ b/apps/sim/lib/billing/organizations/resource-handoff.ts @@ -2,6 +2,7 @@ import { knowledgeBase, member, permissionGroup, + simLink, ssoProvider, user, workspaceFiles, @@ -103,6 +104,10 @@ export async function reassignOrganizationSharedResourcesTx( eq(permissionGroup.createdBy, departingUserId) ) ) + await tx + .update(simLink) + .set({ createdBy: userId, updatedAt }) + .where(and(eq(simLink.organizationId, organizationId), eq(simLink.createdBy, departingUserId))) await tx .update(ssoProvider) .set({ userId }) diff --git a/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts b/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts index 1d5dfbfeefc..5ad544bab3d 100644 --- a/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts +++ b/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts @@ -13,6 +13,7 @@ import { permissionGroup, permissions, project, + simLink, ssoProvider, user, userStats, @@ -1264,6 +1265,75 @@ describe('shared resource retention across workspace departure and account erasu await assertRetained(fixture) }) + it("hands a removed member's Sim links to the owner and leaves another organization's links", async () => { + const fixture = await seedResources(false, false) + const [organizationId, otherOrganizationId] = [generateId(), generateId()] + const memberId = generateId() + const [linkId, otherLinkId] = [generateId(), generateId()] + organizationIds.push(organizationId, otherOrganizationId) + await db.insert(organization).values( + [organizationId, otherOrganizationId].map((id) => ({ + id, + name: 'Sim link organization', + slug: id, + createdAt: new Date(), + })) + ) + await db.insert(member).values([ + { + id: generateId(), + organizationId, + userId: fixture.ownerId, + role: 'owner', + createdAt: new Date(), + }, + { + id: memberId, + organizationId, + userId: fixture.departingId, + role: 'member', + createdAt: new Date(), + }, + ]) + await db.insert(simLink).values( + [ + { id: linkId, organizationId }, + { id: otherLinkId, organizationId: otherOrganizationId }, + ].map(({ id, organizationId: linkOrganizationId }) => ({ + id, + organizationId: linkOrganizationId, + name: 'docs', + normalizedName: 'docs', + url: 'https://example.test/docs', + unlisted: true, + restrictEdit: true, + createdBy: fixture.departingId, + })) + ) + expect( + ( + await removeUserFromOrganization({ + organizationId, + userId: fixture.departingId, + memberId, + skipBillingLogic: true, + onError: 'throw', + }) + ).success + ).toBe(true) + expect( + await db + .select({ id: simLink.id, createdBy: simLink.createdBy }) + .from(simLink) + .where(inArray(simLink.id, [linkId, otherLinkId])) + ).toEqual( + expect.arrayContaining([ + { id: linkId, createdBy: fixture.ownerId }, + { id: otherLinkId, createdBy: fixture.departingId }, + ]) + ) + }) + it('refuses organization-only retention without an owner successor', async () => { const fixture = await seedResources(false, false) const organizationId = generateId() From 13c1cd4bf064a596273bbebcd8aaf6dad83c510e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:52:23 -0800 Subject: [PATCH 2/4] fix(sim-links): repair leftover links on account deletion --- .../billing/organizations/resource-handoff.ts | 52 +++++++++++++++++-- apps/sim/lib/users/account-deletion.ts | 2 + .../shared-resource-handoff.integration.ts | 34 ++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/billing/organizations/resource-handoff.ts b/apps/sim/lib/billing/organizations/resource-handoff.ts index cb78ff949f3..ec294bc5bfd 100644 --- a/apps/sim/lib/billing/organizations/resource-handoff.ts +++ b/apps/sim/lib/billing/organizations/resource-handoff.ts @@ -7,7 +7,7 @@ import { user, workspaceFiles, } from '@sim/db/schema' -import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm' +import { and, asc, eq, isNull, ne, notInArray, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { handoffFileCreatorsInTx } from '@/lib/uploads/contexts/workspace/creator-handoff' @@ -104,10 +104,7 @@ export async function reassignOrganizationSharedResourcesTx( eq(permissionGroup.createdBy, departingUserId) ) ) - await tx - .update(simLink) - .set({ createdBy: userId, updatedAt }) - .where(and(eq(simLink.organizationId, organizationId), eq(simLink.createdBy, departingUserId))) + await reassignSimLinksTx(tx, organizationId, departingUserId, userId) await tx .update(ssoProvider) .set({ userId }) @@ -116,3 +113,48 @@ export async function reassignOrganizationSharedResourcesTx( ) return userId } + +async function reassignSimLinksTx( + tx: DbTransaction, + organizationId: string, + fromUserId: string, + toUserId: string +) { + await tx + .update(simLink) + .set({ createdBy: toUserId, updatedAt: new Date() }) + .where(and(eq(simLink.organizationId, organizationId), eq(simLink.createdBy, fromUserId))) +} + +/** + * Repairs Sim links a former member kept after leaving before departures handed them off. Never + * blocks erasure: an organization without an owner successor keeps the FK's `SET NULL`. Takes + * only the successor's row locks, so it is safe after the deleting account's user barrier. + */ +export async function handoffLeftoverSimLinksTx( + tx: DbTransaction, + departingUserId: string, + handledOrganizationIds: string[] +) { + const rows = await tx + .selectDistinct({ organizationId: simLink.organizationId }) + .from(simLink) + .where( + and( + eq(simLink.createdBy, departingUserId), + handledOrganizationIds.length > 0 + ? notInArray(simLink.organizationId, handledOrganizationIds) + : undefined + ) + ) + .orderBy(asc(simLink.organizationId)) + for (const { organizationId } of rows) { + const successorId = await findOrganizationSharedResourceSuccessor( + tx, + organizationId, + departingUserId, + true + ) + if (successorId) await reassignSimLinksTx(tx, organizationId, departingUserId, successorId) + } +} diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index 79234ad18ad..c25d897180b 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -34,6 +34,7 @@ import { } from '@/lib/billing/organizations/membership' import { findOrganizationSharedResourceSuccessor, + handoffLeftoverSimLinksTx, listSharedResourceOrganizationIdsForUser, reassignOrganizationSharedResourcesTx, } from '@/lib/billing/organizations/resource-handoff' @@ -965,6 +966,7 @@ export async function deleteUserAccount( for (const organizationId of sharedOrganizationIds) { await reassignOrganizationSharedResourcesTx(tx, organizationId, userId) } + await handoffLeftoverSimLinksTx(tx, userId, sharedOrganizationIds) const sharedWorkspaceIds = await listSharedResourceWorkspaceIdsForUser(tx, userId) const { unresolved: resourceUnresolved } = diff --git a/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts b/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts index 5ad544bab3d..83d23a3dffc 100644 --- a/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts +++ b/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts @@ -1334,6 +1334,40 @@ describe('shared resource retention across workspace departure and account erasu ) }) + it("repairs a former member's leftover Sim link to the owner on account deletion", async () => { + const fixture = await seedResources(false, false) + const organizationId = generateId() + const linkId = generateId() + organizationIds.push(organizationId) + await db.insert(organization).values({ + id: organizationId, + name: 'Former organization', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values({ + id: generateId(), + organizationId, + userId: fixture.ownerId, + role: 'owner', + createdAt: new Date(), + }) + await db.insert(simLink).values({ + id: linkId, + organizationId, + name: 'handbook', + normalizedName: 'handbook', + url: 'https://example.test/handbook', + restrictEdit: true, + createdBy: fixture.departingId, + }) + await deleteUserAccount(fixture.departingId, { recordIdentity: false }) + expect( + await db.select({ createdBy: simLink.createdBy }).from(simLink).where(eq(simLink.id, linkId)) + ).toEqual([{ createdBy: fixture.ownerId }]) + await assertRetained(fixture) + }) + it('refuses organization-only retention without an owner successor', async () => { const fixture = await seedResources(false, false) const organizationId = generateId() From 69741c4c6d904e0229a5805a058f783d10a2ae44 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 19:34:03 -0800 Subject: [PATCH 3/4] fix(sim-links): lock leftover-link organizations before deletion barriers --- .../billing/organizations/resource-handoff.ts | 36 ++++++++++++------- apps/sim/lib/users/account-deletion.ts | 24 ++++++++++--- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/billing/organizations/resource-handoff.ts b/apps/sim/lib/billing/organizations/resource-handoff.ts index ec294bc5bfd..a5565dfd00d 100644 --- a/apps/sim/lib/billing/organizations/resource-handoff.ts +++ b/apps/sim/lib/billing/organizations/resource-handoff.ts @@ -127,28 +127,40 @@ async function reassignSimLinksTx( } /** - * Repairs Sim links a former member kept after leaving before departures handed them off. Never - * blocks erasure: an organization without an owner successor keeps the FK's `SET NULL`. Takes - * only the successor's row locks, so it is safe after the deleting account's user barrier. + * Organizations where a former member still created Sim links outside `excludedOrganizationIds`. + * Account deletion locks these with the shared organizations, but they never block erasure. */ -export async function handoffLeftoverSimLinksTx( - tx: DbTransaction, - departingUserId: string, - handledOrganizationIds: string[] +export async function listLeftoverSimLinkOrganizationIdsForUser( + executor: DbOrTx, + userId: string, + excludedOrganizationIds: string[] ) { - const rows = await tx + const rows = await executor .selectDistinct({ organizationId: simLink.organizationId }) .from(simLink) .where( and( - eq(simLink.createdBy, departingUserId), - handledOrganizationIds.length > 0 - ? notInArray(simLink.organizationId, handledOrganizationIds) + eq(simLink.createdBy, userId), + excludedOrganizationIds.length > 0 + ? notInArray(simLink.organizationId, excludedOrganizationIds) : undefined ) ) .orderBy(asc(simLink.organizationId)) - for (const { organizationId } of rows) { + return rows.map((row) => row.organizationId) +} + +/** + * Repairs Sim links a former member kept after leaving before departures handed them off. The + * caller holds each organization's mutation lock. Never blocks erasure: an organization without + * an owner successor keeps the FK's `SET NULL`. + */ +export async function handoffLeftoverSimLinksTx( + tx: DbTransaction, + departingUserId: string, + organizationIds: string[] +) { + for (const organizationId of organizationIds) { const successorId = await findOrganizationSharedResourceSuccessor( tx, organizationId, diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index c25d897180b..5b0ec536d83 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -19,7 +19,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' -import { formatQuotedNameList } from '@sim/utils/string' +import { compareStrings, formatQuotedNameList } from '@sim/utils/string' import { and, eq, gt, inArray, isNotNull, isNull, lte, ne, notExists, or, sql } from 'drizzle-orm' import type { AccountDeletionBlocker, @@ -35,6 +35,7 @@ import { import { findOrganizationSharedResourceSuccessor, handoffLeftoverSimLinksTx, + listLeftoverSimLinkOrganizationIdsForUser, listSharedResourceOrganizationIdsForUser, reassignOrganizationSharedResourcesTx, } from '@/lib/billing/organizations/resource-handoff' @@ -813,7 +814,16 @@ export async function deleteUserAccount( await db.transaction(async (tx) => { /** Organization and Project gates precede workspace rows, which precede user FK barriers. */ const sharedOrganizationIds = await listSharedResourceOrganizationIdsForUser(tx, userId) - for (const organizationId of sharedOrganizationIds) { + const leftoverSimLinkOrganizationIds = await listLeftoverSimLinkOrganizationIdsForUser( + tx, + userId, + sharedOrganizationIds + ) + const lockedOrganizationIds = [ + ...sharedOrganizationIds, + ...leftoverSimLinkOrganizationIds, + ].sort(compareStrings) + for (const organizationId of lockedOrganizationIds) { await acquireOrganizationMutationLock(tx, organizationId) } const workspaceIds = new Set([ @@ -853,9 +863,15 @@ export async function deleteUserAccount( ...(await listSharedResourceWorkspaceIdsForUser(tx, userId)), ] const currentOrganizationIds = await listSharedResourceOrganizationIdsForUser(tx, userId) + const currentSimLinkOrganizationIds = await listLeftoverSimLinkOrganizationIdsForUser( + tx, + userId, + currentOrganizationIds + ) if ( currentWorkspaceIds.some((id) => !workspaceIds.has(id)) || - currentOrganizationIds.some((id) => !sharedOrganizationIds.includes(id)) + currentOrganizationIds.some((id) => !sharedOrganizationIds.includes(id)) || + currentSimLinkOrganizationIds.some((id) => !lockedOrganizationIds.includes(id)) ) { throw new AccountDeletionBlockedError([ { @@ -966,7 +982,7 @@ export async function deleteUserAccount( for (const organizationId of sharedOrganizationIds) { await reassignOrganizationSharedResourcesTx(tx, organizationId, userId) } - await handoffLeftoverSimLinksTx(tx, userId, sharedOrganizationIds) + await handoffLeftoverSimLinksTx(tx, userId, leftoverSimLinkOrganizationIds) const sharedWorkspaceIds = await listSharedResourceWorkspaceIdsForUser(tx, userId) const { unresolved: resourceUnresolved } = From 2db7e0f75f89d82bec71571c6bb4a0c1e4e69b23 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 19:58:50 -0800 Subject: [PATCH 4/4] fix(sim-links): hold the creator's membership while creating a link --- .../application/organization-authorization.ts | 8 +- .../create-membership.integration.ts | 101 ++++++++++++++++++ .../lib/sim-links/application/use-cases.ts | 17 ++- 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/sim-links/application/create-membership.integration.ts diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index f65538cd71e..acf57cd109a 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -47,6 +47,8 @@ export interface OrganizationMembershipContext extends OrganizationAuthorization export interface OrganizationAuthorizationOptions { executor?: DbOrTx forUpdate?: boolean + /** Holds the membership row until commit, so a concurrent removal waits for this write. */ + forShare?: boolean } /** Rechecks routed organization membership; owning a workspace is irrelevant to this grant. */ @@ -83,7 +85,11 @@ export async function requireOrganizationSubjectMembership( .select({ role: member.role }) .from(member) .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) - const [membership] = options.forUpdate ? await query.for('update').limit(1) : await query.limit(1) + const [membership] = options.forUpdate + ? await query.for('update').limit(1) + : options.forShare + ? await query.for('share').limit(1) + : await query.limit(1) const parsedRole = organizationRoleSchema.safeParse(membership?.role) if (!parsedRole.success) throw new OrganizationMembershipNotFoundError() if (minimumRole === 'admin' && !isOrgAdminRole(parsedRole.data)) { diff --git a/apps/sim/lib/sim-links/application/create-membership.integration.ts b/apps/sim/lib/sim-links/application/create-membership.integration.ts new file mode 100644 index 00000000000..34a8e0259af --- /dev/null +++ b/apps/sim/lib/sim-links/application/create-membership.integration.ts @@ -0,0 +1,101 @@ +import { db } from '@sim/db' +import { member, organization, simLink, user } from '@sim/db/schema' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, expect, it, vi } from 'vitest' +import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { createSimLink } from '@/lib/sim-links/application/use-cases' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +readTestDatabaseUrl() + +const ownerId = generateId() +const creatorId = generateId() +const organizationId = generateId() +const creatorMemberId = generateId() + +afterAll(async () => { + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [ownerId, creatorId])) +}) + +it('refuses a link whose creator is removed after authorization but before the insert', async () => { + await db.insert(user).values( + [ownerId, creatorId].map((id) => ({ + id, + name: 'Sim link fixture', + email: `${id}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Sim link organization', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, + { + id: creatorMemberId, + organizationId, + userId: creatorId, + role: 'member', + createdAt: new Date(), + }, + ]) + + const authorized = createDeferred() + const proceed = createDeferred() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => { + if (flag !== 'sim-links') return false + authorized.resolve() + await proceed.promise + return true + }) + + const creation = createSimLink.execute({ + principal: createSessionPrincipal({ userId: creatorId }), + input: { + organizationId, + name: 'handbook', + url: 'https://example.test/handbook', + defaultUrl: null, + description: null, + unlisted: true, + restrictEdit: true, + }, + }) + const settled = creation.then( + () => null, + (error: unknown) => error + ) + await authorized.promise + expect( + ( + await removeUserFromOrganization({ + organizationId, + userId: creatorId, + memberId: creatorMemberId, + skipBillingLogic: true, + onError: 'throw', + }) + ).success + ).toBe(true) + proceed.resolve() + + expect(await settled).toMatchObject({ message: 'Organization not found' }) + expect( + await db + .select({ id: simLink.id }) + .from(simLink) + .where(eq(simLink.organizationId, organizationId)) + ).toEqual([]) +}) diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index 8b68bb2dbd0..6afc107385f 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -9,7 +9,10 @@ import { type OrganizationUseCaseContext, } from '@/lib/core/application/authorized-organization-use-case' import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authorized-workspace-use-case' -import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' +import { + type OrganizationMembershipContext, + requireOrganizationSubjectMembership, +} from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import type { DbTransaction } from '@/lib/db/types' @@ -170,6 +173,18 @@ export const createSimLink = defineAuthorizedOrganizationUseCase({ const { organizationId, ...fields } = input return withUniqueName(organizationId, fields.name, () => db.transaction(async (tx) => { + /** A removal deletes this row before handing off the member's links, so it waits for this insert. */ + await requireOrganizationSubjectMembership( + context.userId, + organizationId, + 'member', + 'none', + undefined, + { + executor: tx, + forShare: true, + } + ) const linkId = await repository.insertSimLink(tx, organizationId, context.userId, fields) return readView(tx, organizationId, linkId, viewerOf(context)) })