Skip to content

Commit 7ceebd1

Browse files
committed
feat(admin): move a workspace between organizations
The admin workspace move was restricted to personal/grandfathered sources; `assertWorkspaceMovable` refused anything already owned by an organization, so support could only re-home a workspace with manual SQL. Relax that guard to a drift-only check and handle the source organization. `changeWorkspaceStoragePayerInTx` already accepted an arbitrary source payer, so the storage-ledger rebalance needed no change. Moving a workspace between organizations is the first operation capable of separating an artifact from the organization that owns it, so two invariants nothing has ever had to defend are enforced here: - A custom block and its bound workflow always share an organization. `getCustomBlockAuthority` resolves by the consumer's org and `admitCustomBlockChildExecution` skips its concurrency reservation on the strength of that, so a stranded row would run a foreign tenant's workflow under its owner's credentials, billed to the wrong payer. The move unpublishes those blocks through the product's own `deleteCustomBlock` and records the loss in the source organization's audit view. - A fork parent and child always share an organization. `resolveForkEdge` has no org check at all, so the move refuses while a cross-org edge would result. Enforcing them at move time is not enough on its own: `publishCustomBlock` and fork creation wrote without the organization mutation lock, so either could commit after the move's scans and produce exactly the artifact the move refused to create. Both now take that lock, which is what actually makes the invariants hold under concurrency. Pending invitations block too. Re-stamping an org-scoped invitation would convert a pending membership in the source org into one in the destination, consuming a seat for an invitation the destination never issued. An entitlement downgrade blocks: `isOrganizationOnEnterprisePlan` gates permission groups, SSO domains, data retention, session revocation, forking, and custom blocks, and losing them silently is not recoverable. That check resolves before the transaction — it reads through the global client with no executor seam, and a plan lapsing in the intervening seconds is recoverable by moving the workspace back, unlike a cross-organization artifact. Both organizations are locked, ascending by id, mirroring `acquireOrganizationUserMutationLocks`. The source id is read optimistically before the transaction and re-verified under the locks, retrying through the existing loop when it moved.
1 parent e4c0a9f commit 7ceebd1

6 files changed

Lines changed: 1766 additions & 78 deletions

File tree

apps/sim/ee/workspace-forking/lib/create-fork.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
77
import { and, eq } from 'drizzle-orm'
88
import type { Workspace } from '@/lib/api/contracts/workspaces'
9+
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
910
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
1011
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
1112
import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
@@ -161,6 +162,17 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
161162
}
162163
const { result, blobTasks, contentPlan, contentRefMaps } = await db.transaction(async (tx) => {
163164
await setForkLockTimeout(tx)
165+
/**
166+
* A fork parent and child must always share an organization. An admin
167+
* workspace move holds the organization mutation lock while it re-homes a
168+
* workspace and refuses to run when a cross-organization edge would result;
169+
* without taking the same lock here, a fork created concurrently with a
170+
* move of its parent commits after that check and produces exactly the edge
171+
* the move refused to create.
172+
*/
173+
if (policy.organizationId) {
174+
await acquireOrganizationMutationLock(tx, policy.organizationId)
175+
}
164176
const now = new Date()
165177

166178
await tx.insert(workspace).values({

apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,97 @@ const adminDashboardWorkspaceCandidateSchema = z.object({
5959
ownerEmail: z.string(),
6060
workspaceMode: z.string(),
6161
organizationId: z.string().nullable(),
62+
/** Name of the organization that currently owns the workspace, if any. */
63+
organizationName: z.string().nullable(),
6264
billedAccountUserId: z.string(),
6365
/** Archived workspaces are movable; the flag lets admin UIs label them. */
6466
archived: z.boolean(),
67+
/**
68+
* Non-null when the workspace cannot be moved. Ineligible rows are returned
69+
* rather than filtered out so the admin learns the workspace exists and why
70+
* it is stuck, instead of an empty result they cannot act on.
71+
*/
72+
ineligibleReason: z.string().nullable().optional(),
73+
})
74+
75+
/** Usage split so the UI can separate what leaves from what breaks behind. */
76+
const adminDashboardCustomBlockUsageSchema = z.object({
77+
live: z.number().int().min(0),
78+
deployed: z.number().int().min(0),
79+
})
80+
81+
const adminDashboardWorkspaceSourceImpactSchema = z.object({
82+
unpublishedCustomBlocks: z
83+
.array(
84+
z.object({
85+
id: z.string(),
86+
type: z.string(),
87+
name: z.string(),
88+
movingWorkspaceUsage: adminDashboardCustomBlockUsageSchema,
89+
sourceOrgElsewhereUsage: adminDashboardCustomBlockUsageSchema,
90+
})
91+
)
92+
.max(500),
93+
/** Non-empty means the move is blocked until the fork is disconnected. */
94+
blockingForkEdges: z
95+
.array(
96+
z.object({
97+
workspaceId: z.string(),
98+
name: z.string(),
99+
organizationId: z.string().nullable(),
100+
direction: z.enum(['parent', 'child']),
101+
})
102+
)
103+
.max(500),
104+
detachedPermissionGroups: z
105+
.array(z.object({ permissionGroupId: z.string(), name: z.string() }))
106+
.max(500),
107+
strippedRetentionRules: z.object({
108+
piiRedactionRules: z.number().int().min(0),
109+
retentionOverrides: z.number().int().min(0),
110+
}),
111+
retainedCollaboratorCaps: z
112+
.array(
113+
z.object({
114+
userId: z.string(),
115+
email: z.string(),
116+
sourceOrgLimitDollars: z.number().nullable(),
117+
})
118+
)
119+
.max(1000),
120+
brandingChanges: z.boolean(),
121+
})
122+
123+
/** Secrets that travel with the workspace. Never carries secret material. */
124+
const adminDashboardWorkspaceCredentialsSchema = z.object({
125+
items: z
126+
.array(
127+
z.object({
128+
id: z.string(),
129+
displayName: z.string(),
130+
type: z.string(),
131+
backedBySourceOrgMember: z.boolean(),
132+
})
133+
)
134+
.max(1000),
135+
credentialGroupCount: z.number().int().min(0),
136+
/** Variable names only — values are never sent. */
137+
environmentVariableKeys: z.array(z.string()).max(1000),
138+
byokKeyCount: z.number().int().min(0),
65139
})
66140

67141
const adminDashboardWorkspacePreflightSchema = z.object({
68142
workspace: adminDashboardWorkspaceCandidateSchema,
143+
/** `null` for a personal or grandfathered source. */
144+
sourceOrganization: z
145+
.object({
146+
id: z.string(),
147+
name: z.string(),
148+
ownerId: z.string().nullable(),
149+
ownerName: z.string().nullable(),
150+
ownerEmail: z.string().nullable(),
151+
})
152+
.nullable(),
69153
destinationOrganization: z.object({
70154
id: z.string(),
71155
name: z.string(),
@@ -80,6 +164,8 @@ const adminDashboardWorkspacePreflightSchema = z.object({
80164
email: z.string(),
81165
permission: z.enum(['admin', 'write', 'read']),
82166
organizationMember: z.boolean(),
167+
/** Retains access after the move, as an external collaborator. */
168+
sourceOrganizationMember: z.boolean(),
83169
})
84170
),
85171
invitations: z.array(
@@ -91,6 +177,17 @@ const adminDashboardWorkspacePreflightSchema = z.object({
91177
workspaceGrantCount: z.number().int().min(1),
92178
})
93179
),
180+
sourceOrganizationImpact: adminDashboardWorkspaceSourceImpactSchema,
181+
credentials: adminDashboardWorkspaceCredentialsSchema,
182+
entitlements: z.object({
183+
sourceIsEnterprise: z.boolean(),
184+
destinationIsEnterprise: z.boolean(),
185+
capabilitiesLost: z.array(z.string()).max(50),
186+
}),
187+
/** Non-empty means the move will throw; the UI must not offer a confirm. */
188+
blockers: z.array(z.string()).max(20),
189+
/** Advisory consequences worth reading, which never block. */
190+
notices: z.array(z.string()).max(20),
94191
warning: z.string().nullable(),
95192
})
96193

apps/sim/lib/workflows/custom-blocks/operations.ts

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import { createLogger } from '@sim/logger'
1010
import { generateId, generateShortId } from '@sim/utils/id'
1111
import { and, eq, isNull, sql } from 'drizzle-orm'
1212
import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
13+
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
1314
import { isBillingEnabled, isCustomBlocksEnabled } from '@/lib/core/config/env-flags'
1415
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
16+
import type { DbOrTx } from '@/lib/db/types'
1517
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
1618
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
1719
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
@@ -495,41 +497,59 @@ export async function publishCustomBlock(params: {
495497
throw new CustomBlockValidationError('You can only publish a workflow from its own workspace')
496498
}
497499

498-
const ws = wf.workspaceId ? await getWorkspaceWithOwner(wf.workspaceId) : null
499-
if (!ws?.organizationId || ws.organizationId !== organizationId) {
500-
throw new CustomBlockValidationError('Workflow does not belong to this organization')
501-
}
502-
503-
// One block per workflow: the (org, type) unique index doesn't prevent the same
504-
// workflow being published under a fresh `custom_block_*` type, so guard here.
505-
const [existing] = await db
506-
.select({ id: customBlock.id })
507-
.from(customBlock)
508-
.where(eq(customBlock.workflowId, workflowId))
509-
.limit(1)
510-
if (existing) {
511-
throw new CustomBlockValidationError('This workflow is already published as a block')
512-
}
513-
514500
const id = generateId()
515501
const type = `${CUSTOM_BLOCK_TYPE_PREFIX}${generateShortId(10).toLowerCase()}`
516502
const now = new Date()
517503

518-
await db.insert(customBlock).values({
519-
id,
520-
organizationId,
521-
workflowId,
522-
type,
523-
name,
524-
description,
525-
iconUrl: iconUrl ?? null,
526-
inputs: inputs ?? [],
527-
outputs: exposedOutputs ?? [],
528-
enabled: true,
529-
traceChildRuns,
530-
createdBy: userId,
531-
createdAt: now,
532-
updatedAt: now,
504+
/**
505+
* The org-belongs check and the insert run under the organization mutation
506+
* lock, together, because an admin workspace move holds that same lock while
507+
* it re-homes a workspace and unpublishes the blocks bound to its workflows.
508+
* Reading the workspace's organization outside the lock lets a publish that
509+
* validated against the OLD organization commit after the move's cleanup
510+
* scan, leaving a source-organization block bound to a workflow that now
511+
* lives in another tenant — which `getCustomBlockAuthority` would resolve and
512+
* execute under the wrong owner's credentials and billing.
513+
*/
514+
const ws = await db.transaction(async (tx) => {
515+
await acquireOrganizationMutationLock(tx, organizationId)
516+
517+
const workspaceRow = wf.workspaceId
518+
? await getWorkspaceWithOwner(wf.workspaceId, { executor: tx })
519+
: null
520+
if (!workspaceRow?.organizationId || workspaceRow.organizationId !== organizationId) {
521+
throw new CustomBlockValidationError('Workflow does not belong to this organization')
522+
}
523+
524+
// One block per workflow: the (org, type) unique index doesn't prevent the same
525+
// workflow being published under a fresh `custom_block_*` type, so guard here.
526+
const [existing] = await tx
527+
.select({ id: customBlock.id })
528+
.from(customBlock)
529+
.where(eq(customBlock.workflowId, workflowId))
530+
.limit(1)
531+
if (existing) {
532+
throw new CustomBlockValidationError('This workflow is already published as a block')
533+
}
534+
535+
await tx.insert(customBlock).values({
536+
id,
537+
organizationId,
538+
workflowId,
539+
type,
540+
name,
541+
description,
542+
iconUrl: iconUrl ?? null,
543+
inputs: inputs ?? [],
544+
outputs: exposedOutputs ?? [],
545+
enabled: true,
546+
traceChildRuns,
547+
createdBy: userId,
548+
createdAt: now,
549+
updatedAt: now,
550+
})
551+
552+
return workspaceRow
533553
})
534554

535555
logger.info('Published custom block', { id, type, organizationId, workflowId })
@@ -588,9 +608,16 @@ export async function updateCustomBlock(
588608
await db.update(customBlock).set(patch).where(eq(customBlock.id, id))
589609
}
590610

591-
/** Unpublish (hard-delete) a custom block. */
592-
export async function deleteCustomBlock(id: string): Promise<void> {
593-
await db.delete(customBlock).where(eq(customBlock.id, id))
611+
/**
612+
* Unpublish (hard-delete) a custom block.
613+
*
614+
* Accepts an executor so a caller that must unpublish atomically with something
615+
* else can enlist it — the admin workspace move unpublishes blocks in the same
616+
* transaction that re-homes their bound workflow, keeping a block and its
617+
* workflow from ever being visible in two different organizations.
618+
*/
619+
export async function deleteCustomBlock(id: string, executor: DbOrTx = db): Promise<void> {
620+
await executor.delete(customBlock).where(eq(customBlock.id, id))
594621
}
595622

596623
/**

0 commit comments

Comments
 (0)