improvement(perms): member removal reassignment policies (#4906)

* improvement(perms): member removal reassignment policies

* improve UI notif

* address comments
This commit is contained in:
Vikhyath Mondreti
2026-06-08 11:54:08 -07:00
committed by GitHub
parent c90a1eb4a6
commit 2c7b1ca54d
9 changed files with 587 additions and 90 deletions
@@ -13,6 +13,7 @@ import { getUserUsageData } from '@/lib/billing/core/usage'
import {
removeExternalUserFromOrganizationWorkspaces,
removeUserFromOrganization,
WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR,
} from '@/lib/billing/organizations/membership'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -340,7 +341,9 @@ export const DELETE = withRouteHandler(
? 404
: error === 'User is an organization member'
? 409
: 500
: error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR
? 400
: 500
return NextResponse.json({ error }, { status })
}
@@ -406,6 +409,9 @@ export const DELETE = withRouteHandler(
if (result.error === 'Member not found') {
return NextResponse.json({ error: result.error }, { status: 404 })
}
if (result.error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ error: result.error }, { status: 500 })
}
@@ -36,7 +36,10 @@ import {
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
import {
removeUserFromOrganization,
WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR,
} from '@/lib/billing/organizations/membership'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
@@ -261,6 +264,9 @@ export const DELETE = withRouteHandler(
if (result.error === 'Member not found') {
return notFoundResponse('Member')
}
if (result.error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR) {
return badRequestResponse(result.error)
}
return internalErrorResponse(result.error || 'Failed to remove member')
}
@@ -22,7 +22,7 @@
*/
import { db } from '@sim/db'
import { permissions, user } from '@sim/db/schema'
import { permissionGroupMember, permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import {
@@ -32,10 +32,16 @@ import {
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
@@ -147,6 +153,19 @@ export const PATCH = withRouteHandler(
return notFoundResponse('Workspace member')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (
workspaceBilling?.billedAccountUserId === existingMember.userId &&
permissionLevel !== 'admin'
) {
return badRequestResponse('Workspace billing account must retain admin permissions')
}
const now = new Date()
await db
@@ -218,9 +237,48 @@ export const DELETE = withRouteHandler(
return notFoundResponse('Workspace member')
}
await db.delete(permissions).where(eq(permissions.id, memberId))
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId)
if (workspaceBilling?.billedAccountUserId === existingMember.userId) {
return badRequestResponse(
'Cannot remove the workspace billing account. Please reassign billing first.'
)
}
await db.transaction(async (tx) => {
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId: existingMember.userId,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds: [workspaceId],
departingUserId: existingMember.userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx.delete(permissions).where(eq(permissions.id, memberId))
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId)
await tx
.delete(permissionGroupMember)
.where(
and(
eq(permissionGroupMember.userId, existingMember.userId),
eq(permissionGroupMember.workspaceId, workspaceId)
)
)
})
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
userId: existingMember.userId,
@@ -233,6 +291,9 @@ export const DELETE = withRouteHandler(
workspaceId,
})
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to remove workspace member')
}
@@ -31,7 +31,13 @@
*/
import { db } from '@sim/db'
import { permissions, user, workspaceEnvironment } from '@sim/db/schema'
import {
permissionGroupMember,
permissions,
user,
workspace,
workspaceEnvironment,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, count, eq } from 'drizzle-orm'
@@ -42,11 +48,18 @@ import {
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
@@ -143,6 +156,16 @@ export const POST = withRouteHandler(
return notFoundResponse('Workspace')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === userId && permissionLevel !== 'admin') {
return badRequestResponse('Workspace billing account must retain admin permissions')
}
const [userData] = await db
.select({ id: user.id, name: user.name, email: user.email, image: user.image })
.from(user)
@@ -282,6 +305,18 @@ export const DELETE = withRouteHandler(
return notFoundResponse('Workspace')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === userId) {
return badRequestResponse(
'Cannot remove the workspace billing account. Please reassign billing first.'
)
}
const [existingPermission] = await db
.select({ id: permissions.id })
.from(permissions)
@@ -298,12 +333,44 @@ export const DELETE = withRouteHandler(
return notFoundResponse('Workspace member')
}
await db.delete(permissions).where(eq(permissions.id, existingPermission.id))
await db.transaction(async (tx) => {
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId: userId,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds: [workspaceId],
departingUserId: userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx.delete(permissions).where(eq(permissions.id, existingPermission.id))
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
await tx
.delete(permissionGroupMember)
.where(
and(
eq(permissionGroupMember.userId, userId),
eq(permissionGroupMember.workspaceId, workspaceId)
)
)
})
logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`)
return singleResponse({ removed: true, userId, workspaceId })
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to remove workspace member', {
error,
workspaceId,
@@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, permissionGroupMember, permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { removeWorkspaceMemberContract } from '@/lib/api/contracts/invitations'
@@ -14,6 +13,11 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { captureServerEvent } from '@/lib/posthog/server'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
const logger = createLogger('WorkspaceMemberAPI')
@@ -116,78 +120,49 @@ export const DELETE = withRouteHandler(
const organizationId = workspaceRow[0].organizationId
const ownershipTransferred = await db.transaction(async (tx) => {
let didTransferOwnership = false
const { ownershipTransferred, workflowOwnershipReassignment } = await db.transaction(
async (tx) => {
const didTransferOwnership =
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId: userId,
})
if (isRemovingWorkspaceOwner) {
/**
* Invariant: the billed account is the org owner for org workspaces,
* the owner for personal workspaces, and a workspace admin for
* grandfathered shared workspaces.
*/
const newOwnerId = workspaceRow[0].billedAccountUserId
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds: [workspaceId],
departingUserId: userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx
.update(workspace)
.set({ ownerId: newOwnerId, updatedAt: new Date() })
.where(eq(workspace.id, workspaceId))
const [existingNewOwnerPermission] = await tx
.select({ id: permissions.id })
.from(permissions)
.delete(permissions)
.where(
and(
eq(permissions.userId, newOwnerId),
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (existingNewOwnerPermission) {
await tx
.update(permissions)
.set({ permissionType: 'admin', updatedAt: new Date() })
.where(eq(permissions.id, existingNewOwnerPermission.id))
} else {
const now = new Date()
await tx.insert(permissions).values({
id: generateId(),
userId: newOwnerId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'admin',
createdAt: now,
updatedAt: now,
})
}
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
didTransferOwnership = true
await tx
.delete(permissionGroupMember)
.where(
and(
eq(permissionGroupMember.userId, userId),
eq(permissionGroupMember.workspaceId, workspaceId)
)
)
return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment }
}
await tx
.delete(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
await tx
.delete(permissionGroupMember)
.where(
and(
eq(permissionGroupMember.userId, userId),
eq(permissionGroupMember.workspaceId, workspaceId)
)
)
return didTransferOwnership
})
)
/**
* Seats are tied to organization membership (one per member), so a
@@ -275,6 +250,7 @@ export const DELETE = withRouteHandler(
removedUserRole: userPermission?.permissionType ?? 'owner',
selfRemoval: isSelf,
ownershipTransferred,
workflowOwnershipReassignment,
organizationRemoval,
seatReduction,
},
@@ -283,6 +259,9 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error removing workspace member:', error)
return NextResponse.json({ error: 'Failed to remove workspace member' }, { status: 500 })
}
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import {
@@ -15,6 +16,7 @@ import {
MoreHorizontal,
Plus,
Search,
toast,
} from '@/components/emcn'
import type { WorkspacePermission } from '@/lib/api/contracts/workspaces'
import {
@@ -284,7 +286,19 @@ export function Teammates() {
className='text-[var(--text-error)]'
onSelect={() => {
if (teammate.userId) {
removeMember.mutate({ userId: teammate.userId, workspaceId })
removeMember.mutate(
{ userId: teammate.userId, workspaceId },
{
onError: (error) => {
toast.error("Couldn't remove teammate", {
description: getErrorMessage(
error,
'Please try again in a moment.'
),
})
},
}
)
}
}}
>
@@ -31,6 +31,12 @@ import { validateSeatAvailability } from '@/lib/billing/validation/seat-manageme
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import type { DbOrTx } from '@/lib/db/types'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
export { WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR } from '@/lib/workspaces/utils'
const logger = createLogger('OrganizationMembership')
@@ -343,6 +349,8 @@ async function reassignOwnedOrganizationWorkspacesTx({
organizationId: string
workspaceIds: string[]
}) {
if (workspaceIds.length === 0) return 0
const [ownerMembership] = await tx
.select({ userId: member.userId })
.from(member)
@@ -350,7 +358,7 @@ async function reassignOwnedOrganizationWorkspacesTx({
.limit(1)
const ownerId = ownerMembership?.userId
if (!ownerId || ownerId === userId || workspaceIds.length === 0) return 0
if (!ownerId || ownerId === userId) return 0
const reassignedWorkspaces = await tx
.update(workspace)
@@ -362,25 +370,15 @@ async function reassignOwnedOrganizationWorkspacesTx({
inArray(workspace.id, workspaceIds)
)
)
.returning({ id: workspace.id })
.returning({
id: workspace.id,
})
if (reassignedWorkspaces.length === 0) return 0
if (reassignedWorkspaces.length === 0) {
return 0
}
const now = new Date()
await tx
.update(permissions)
.set({ permissionType: 'admin', updatedAt: now })
.where(
and(
eq(permissions.userId, ownerId),
eq(permissions.entityType, 'workspace'),
inArray(
permissions.entityId,
reassignedWorkspaces.map((row) => row.id)
)
)
)
await tx
.insert(permissions)
.values(
@@ -394,7 +392,10 @@ async function reassignOwnedOrganizationWorkspacesTx({
updatedAt: now,
}))
)
.onConflictDoNothing()
.onConflictDoUpdate({
target: [permissions.userId, permissions.entityType, permissions.entityId],
set: { permissionType: 'admin', updatedAt: now },
})
return reassignedWorkspaces.length
}
@@ -1075,6 +1076,16 @@ export async function removeUserFromOrganization(
workspaceIds,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds,
departingUserId: userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
const deletedPerms = await tx
.delete(permissions)
.where(
@@ -1182,6 +1193,10 @@ export async function removeUserFromOrganization(
return { success: true, removed: true, billingActions }
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return { success: false, error: error.message, billingActions }
}
logger.error('Failed to remove user from organization', {
userId,
organizationId,
@@ -1254,6 +1269,16 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: {
workspaceIds,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds,
departingUserId: userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
const deletedPermissions = await tx
.delete(permissions)
.where(
@@ -1337,6 +1362,17 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: {
pendingInvitationsCancelled,
}
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return {
success: false,
error: error.message,
workspaceAccessRevoked: 0,
permissionGroupsRevoked: 0,
credentialMembershipsRevoked: 0,
pendingInvitationsCancelled: 0,
}
}
logger.error('Failed to remove external workspace member from organization workspaces', {
organizationId,
userId,
+134
View File
@@ -0,0 +1,134 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({
db: {},
}))
import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx } from '@/lib/workspaces/utils'
function createSelectChain(result: unknown) {
const limit = vi.fn().mockResolvedValue(result)
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(result),
limit,
})
return { from, where, limit }
}
function createGroupedSelectChain(result: unknown) {
const groupBy = vi.fn().mockResolvedValue(result)
const where = vi.fn().mockReturnValue({ groupBy })
const from = vi.fn().mockReturnValue({ where })
return { from, where, groupBy }
}
function createUpdateChain(result: unknown) {
const returning = vi.fn().mockResolvedValue(result)
const where = vi.fn().mockReturnValue({ returning })
const set = vi.fn().mockReturnValue({ where })
return { set, where, returning }
}
describe('reassignWorkflowOwnershipForWorkspaceMemberRemovalTx', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reassigns departing member workflows to the workspace billed account', async () => {
const workspaceSelect = createSelectChain([
{ id: 'workspace-1', billedAccountUserId: 'billed-1' },
])
const workflowCountSelect = createGroupedSelectChain([
{ workspaceId: 'workspace-1', workflowCount: 2 },
])
const workflowUpdate = createUpdateChain([])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn().mockReturnValue(workflowUpdate),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).toHaveBeenCalledTimes(1)
expect(workflowUpdate.set).toHaveBeenCalledWith(
expect.objectContaining({ updatedAt: expect.any(Date) })
)
expect(result).toEqual({
reassigned: [{ workspaceId: 'workspace-1', newWorkflowUserId: 'billed-1', workflowCount: 2 }],
unresolved: [],
})
})
it('marks a workspace unresolved when the departing member is the billed account', async () => {
const workspaceSelect = createSelectChain([
{ id: 'workspace-1', billedAccountUserId: 'departing-1' },
])
const workflowCountSelect = createGroupedSelectChain([
{ workspaceId: 'workspace-1', workflowCount: 1 },
])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn(),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).not.toHaveBeenCalled()
expect(result).toEqual({ reassigned: [], unresolved: ['workspace-1'] })
})
it('does not mark a workspace unresolved when the departing billing account owns no workflows', async () => {
const workspaceSelect = createSelectChain([
{ id: 'workspace-1', billedAccountUserId: 'departing-1' },
])
const workflowCountSelect = createGroupedSelectChain([])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn(),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).not.toHaveBeenCalled()
expect(result).toEqual({ reassigned: [], unresolved: [] })
})
it('marks a workspace unresolved when no billed account is configured', async () => {
const workspaceSelect = createSelectChain([{ id: 'workspace-1', billedAccountUserId: null }])
const workflowCountSelect = createGroupedSelectChain([
{ workspaceId: 'workspace-1', workflowCount: 1 },
])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn(),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).not.toHaveBeenCalled()
expect(result).toEqual({ reassigned: [], unresolved: ['workspace-1'] })
})
})
+196 -2
View File
@@ -1,7 +1,9 @@
import { db } from '@sim/db'
import { permissions, workspace as workspaceTable } from '@sim/db/schema'
import { permissions, workflow, workspace as workspaceTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull, ne, sql } from 'drizzle-orm'
import { generateId } from '@sim/utils/id'
import { and, count, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('WorkspaceUtils')
@@ -82,6 +84,198 @@ export interface ReassignBilledAccountResult {
unresolved: string[]
}
export interface ReassignWorkflowOwnershipResult {
reassigned: Array<{ workspaceId: string; newWorkflowUserId: string; workflowCount: number }>
unresolved: string[]
}
export const WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR =
'Cannot remove the workspace billing account. Please reassign billing first.'
export class WorkspaceBillingAccountRemovalError extends Error {
constructor() {
super(WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR)
this.name = 'WorkspaceBillingAccountRemovalError'
}
}
export async function transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId,
}: {
tx: DbOrTx
workspaceId: string
departingUserId: string
}): Promise<boolean> {
const [workspaceRow] = await tx
.select({
ownerId: workspaceTable.ownerId,
billedAccountUserId: workspaceTable.billedAccountUserId,
})
.from(workspaceTable)
.where(eq(workspaceTable.id, workspaceId))
.limit(1)
if (!workspaceRow || workspaceRow.ownerId !== departingUserId) {
return false
}
const newOwnerId = workspaceRow.billedAccountUserId
if (!newOwnerId || newOwnerId === departingUserId) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx
.update(workspaceTable)
.set({ ownerId: newOwnerId, updatedAt: new Date() })
.where(eq(workspaceTable.id, workspaceId))
const [existingNewOwnerPermission] = await tx
.select({ id: permissions.id })
.from(permissions)
.where(
and(
eq(permissions.userId, newOwnerId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (existingNewOwnerPermission) {
await tx
.update(permissions)
.set({ permissionType: 'admin', updatedAt: new Date() })
.where(eq(permissions.id, existingNewOwnerPermission.id))
return true
}
const now = new Date()
await tx.insert(permissions).values({
id: generateId(),
userId: newOwnerId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'admin',
createdAt: now,
updatedAt: now,
})
return true
}
/**
* Reassigns workflows owned by a user who is about to lose access to one or more workspaces.
*
* Workflow execution, webhook provider config, and environment variables intentionally resolve
* through `workflow.userId`, so that identity must remain an active workspace identity. The
* replacement is the workspace billed account: the same stable identity used for server-side
* billing/permission actor resolution, and one the member-removal routes protect from removal.
*/
export async function reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds,
departingUserId,
}: {
tx: DbOrTx
workspaceIds: string[]
departingUserId: string
}): Promise<ReassignWorkflowOwnershipResult> {
const uniqueWorkspaceIds = Array.from(new Set(workspaceIds.filter(Boolean)))
if (uniqueWorkspaceIds.length === 0) {
return { reassigned: [], unresolved: [] }
}
const workspaceRows = await tx
.select({
id: workspaceTable.id,
billedAccountUserId: workspaceTable.billedAccountUserId,
})
.from(workspaceTable)
.where(inArray(workspaceTable.id, uniqueWorkspaceIds))
const reassigned: ReassignWorkflowOwnershipResult['reassigned'] = []
const unresolved: string[] = []
const reassignmentWorkspaceIds: string[] = []
const workflowCounts = await tx
.select({
workspaceId: workflow.workspaceId,
workflowCount: count(workflow.id),
})
.from(workflow)
.where(
and(eq(workflow.userId, departingUserId), inArray(workflow.workspaceId, uniqueWorkspaceIds))
)
.groupBy(workflow.workspaceId)
const workflowCountsByWorkspaceId = new Map<string, number>()
for (const { workspaceId, workflowCount } of workflowCounts) {
if (!workspaceId || workflowCount === 0) continue
workflowCountsByWorkspaceId.set(workspaceId, workflowCount)
}
for (const ws of workspaceRows) {
const workflowCount = workflowCountsByWorkspaceId.get(ws.id) ?? 0
if (workflowCount === 0) {
continue
}
const replacementUserId =
ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null
if (!replacementUserId) {
unresolved.push(ws.id)
continue
}
reassignmentWorkspaceIds.push(ws.id)
}
if (reassignmentWorkspaceIds.length > 0) {
await tx
.update(workflow)
.set({
userId: sql<string>`(
select ${workspaceTable.billedAccountUserId}
from ${workspaceTable}
where ${workspaceTable.id} = ${workflow.workspaceId}
)`,
updatedAt: new Date(),
})
.where(
and(
eq(workflow.userId, departingUserId),
inArray(workflow.workspaceId, reassignmentWorkspaceIds)
)
)
const billedAccountByWorkspaceId = new Map(
workspaceRows.map((ws) => [ws.id, ws.billedAccountUserId])
)
for (const workspaceId of reassignmentWorkspaceIds) {
const workflowCount = workflowCountsByWorkspaceId.get(workspaceId) ?? 0
const newWorkflowUserId = billedAccountByWorkspaceId.get(workspaceId)
if (!newWorkflowUserId) continue
reassigned.push({
workspaceId,
newWorkflowUserId,
workflowCount,
})
}
}
if (reassigned.length > 0 || unresolved.length > 0) {
logger.info('Reassigned workflow ownership for removed workspace member', {
departingUserId,
reassigned,
unresolved,
})
}
return { reassigned, unresolved }
}
/**
* Reassigns `billedAccountUserId` on every workspace that points to `departingUserId` to
* another eligible user, so the user can be deleted without violating the `workspace.billed_account_user_id`