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
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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 })
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

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,
})

await revokeWorkspaceCredentialMemberships(workspaceId, 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,
Expand All @@ -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')
}
Expand Down
71 changes: 69 additions & 2 deletions apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
Loading
Loading