Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion apps/sim/lib/billing/organizations/resource-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import {
knowledgeBase,
member,
permissionGroup,
simLink,
ssoProvider,
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'
Expand Down Expand Up @@ -103,6 +104,7 @@ export async function reassignOrganizationSharedResourcesTx(
eq(permissionGroup.createdBy, departingUserId)
)
)
await reassignSimLinksTx(tx, organizationId, departingUserId, userId)
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
await tx
.update(ssoProvider)
.set({ userId })
Expand All @@ -111,3 +113,60 @@ 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)))
}

/**
* 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 listLeftoverSimLinkOrganizationIdsForUser(
executor: DbOrTx,
userId: string,
excludedOrganizationIds: string[]
) {
const rows = await executor
.selectDistinct({ organizationId: simLink.organizationId })
.from(simLink)
.where(
and(
eq(simLink.createdBy, userId),
excludedOrganizationIds.length > 0
? notInArray(simLink.organizationId, excludedOrganizationIds)
: undefined
)
)
.orderBy(asc(simLink.organizationId))
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,
departingUserId,
true
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
)
if (successorId) await reassignSimLinksTx(tx, organizationId, departingUserId, successorId)
}
}
8 changes: 7 additions & 1 deletion apps/sim/lib/core/application/organization-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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)) {
Expand Down
101 changes: 101 additions & 0 deletions apps/sim/lib/sim-links/application/create-membership.integration.ts
Original file line number Diff line number Diff line change
@@ -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<void>()
const proceed = createDeferred<void>()
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([])
})
17 changes: 16 additions & 1 deletion apps/sim/lib/sim-links/application/use-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
}
)
const linkId = await repository.insertSimLink(tx, organizationId, context.userId, fields)
return readView(tx, organizationId, linkId, viewerOf(context))
})
Expand Down
24 changes: 21 additions & 3 deletions apps/sim/lib/users/account-deletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +34,8 @@ import {
} from '@/lib/billing/organizations/membership'
import {
findOrganizationSharedResourceSuccessor,
handoffLeftoverSimLinksTx,
listLeftoverSimLinkOrganizationIdsForUser,
listSharedResourceOrganizationIdsForUser,
reassignOrganizationSharedResourcesTx,
} from '@/lib/billing/organizations/resource-handoff'
Expand Down Expand Up @@ -812,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([
Expand Down Expand Up @@ -852,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([
{
Expand Down Expand Up @@ -965,6 +982,7 @@ export async function deleteUserAccount(
for (const organizationId of sharedOrganizationIds) {
await reassignOrganizationSharedResourcesTx(tx, organizationId, userId)
}
await handoffLeftoverSimLinksTx(tx, userId, leftoverSimLinkOrganizationIds)

const sharedWorkspaceIds = await listSharedResourceWorkspaceIdsForUser(tx, userId)
const { unresolved: resourceUnresolved } =
Expand Down
Loading
Loading