Skip to content

Commit 8cf3b11

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. Move-time checks alone cannot hold either invariant, because the writers can commit after them. `publishCustomBlock` now validates and inserts under the organization mutation lock the move holds, and `createFork` row-locks its parent so the move and the organization-attach path — both of which take `FOR NO KEY UPDATE` on that row — serialize against it. Pending invitations block: 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. When entitlement is subscription-backed, both organizations are re-evaluated under the locks rather than trusting a pre-transaction read that is stale in both directions. The two modes where `resolveOrganizationEnterprisePlan` grants entitlement by deployment configuration are excluded through a shared `isSubscriptionBackedEntitlement` predicate, so a missing subscription row is never misread as a lapse. 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 5bb8977 commit 8cf3b11

8 files changed

Lines changed: 2224 additions & 88 deletions

File tree

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
4+
import { workspace } from '@sim/db/schema'
5+
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
56
import { beforeEach, describe, expect, it, vi } from 'vitest'
67

78
const {
@@ -125,6 +126,12 @@ describe('createFork storage headroom gate', () => {
125126
beforeEach(() => {
126127
vi.clearAllMocks()
127128
resetDbChainMock()
129+
/**
130+
* The fork transaction re-reads the parent's organization under the lock to
131+
* confirm it has not moved since `assertCanFork` captured the policy.
132+
* Matches POLICY.organizationId, so the fork proceeds.
133+
*/
134+
queueTableRows(workspace, [{ organizationId: null }])
128135
mockSumForkCopyBytes.mockResolvedValue(0)
129136
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
130137
mockLoadSourceDeployedStates.mockResolvedValue({
@@ -186,6 +193,24 @@ describe('createFork storage headroom gate', () => {
186193
expect(mockStartBackgroundWork).not.toHaveBeenCalled()
187194
})
188195

196+
it('refuses when the parent changed organizations after the policy was captured', async () => {
197+
resetDbChainMock()
198+
/**
199+
* `assertCanFork` captures `policy.organizationId` before this transaction,
200+
* so an admin workspace move committing in between would otherwise leave
201+
* the fork locking the organization the parent has already left and
202+
* inserting the child there — the cross-organization edge the lock exists
203+
* to prevent. The parent is re-read under the lock to catch exactly this.
204+
*/
205+
queueTableRows(workspace, [{ organizationId: 'org-moved-away' }])
206+
mockSumForkCopyBytes.mockResolvedValue(0)
207+
208+
await expect(createFork(forkParams())).rejects.toThrow(
209+
'changed organizations while this fork was being created'
210+
)
211+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
212+
})
213+
189214
it('proceeds under quota, summing exactly the selected files + knowledge bases', async () => {
190215
mockSumForkCopyBytes.mockResolvedValue(500)
191216

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from '@/ee/workspace-forking/lib/copy/storage-quota'
4040
import { buildForkWorkflowIdMap } from '@/ee/workspace-forking/lib/copy/workflow-id-map'
4141
import { copyForkWorkflowMcpAttachments } from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments'
42+
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
4243
import { setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
4344
import {
4445
type ForkBlockPair,
@@ -161,6 +162,47 @@ 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+
* The lock alone is not enough: `policy.organizationId` was captured by
167+
* `assertCanFork` BEFORE this transaction, so a move that commits in
168+
* between leaves us locking the organization the parent has already left
169+
* and inserting the child there — the exact cross-organization edge the
170+
* lock was added to prevent. Re-read the parent under the lock and refuse
171+
* if it moved; the caller can retry against the new organization.
172+
*/
173+
const [currentSource] = await tx
174+
.select({ organizationId: workspace.organizationId })
175+
.from(workspace)
176+
.where(eq(workspace.id, source.id))
177+
/**
178+
* The row lock IS the serialization, and deliberately the only one.
179+
*
180+
* A fork parent and child must always share an organization. Every writer
181+
* that can re-home the parent takes `FOR NO KEY UPDATE` on its row — the
182+
* admin workspace move, and `lockWorkspaceRowsForPayerChanges` on the
183+
* organization-attach path — so locking it here makes those wait, and the
184+
* comparison below then sees their committed result.
185+
*
186+
* An organization mutation lock was tried here and removed: it bought
187+
* nothing the row lock does not already provide, could not cover a null
188+
* policy organization at all, and cost three real problems — a lock-order
189+
* inversion against invitation acceptance (which takes the workspace row
190+
* before the organization lock), a 5s timeout overwriting this
191+
* transaction's 10s one, and an organization-wide lock held across the
192+
* whole content copy.
193+
*/
194+
.for('no key update')
195+
.limit(1)
196+
if (!currentSource) {
197+
throw new ForkError('Source workspace no longer exists', 404)
198+
}
199+
if ((currentSource.organizationId ?? null) !== (policy.organizationId ?? null)) {
200+
throw new ForkError(
201+
'The source workspace changed organizations while this fork was being created. Try again.',
202+
409
203+
)
204+
}
205+
164206
const now = new Date()
165207

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

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,114 @@ 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+
* Rows omitted to keep the response inside the array bounds above. Non-null
123+
* means the lists are incomplete and the notice says so.
124+
*/
125+
truncated: z
126+
.object({
127+
customBlocks: z.number().int().min(0),
128+
permissionGroups: z.number().int().min(0),
129+
collaboratorCaps: z.number().int().min(0),
130+
forkEdges: z.number().int().min(0),
131+
credentials: z.number().int().min(0),
132+
environmentVariableKeys: z.number().int().min(0),
133+
})
134+
.nullable(),
135+
})
136+
137+
/** Secrets that travel with the workspace. Never carries secret material. */
138+
const adminDashboardWorkspaceCredentialsSchema = z.object({
139+
items: z
140+
.array(
141+
z.object({
142+
id: z.string(),
143+
displayName: z.string(),
144+
type: z.string(),
145+
backedBySourceOrgMember: z.boolean(),
146+
})
147+
)
148+
.max(1000),
149+
credentialGroupCount: z.number().int().min(0),
150+
/** Variable names only — values are never sent. */
151+
environmentVariableKeys: z.array(z.string()).max(1000),
152+
byokKeyCount: z.number().int().min(0),
153+
/** Rows omitted to stay within the bounds above. */
154+
truncatedCredentials: z.number().int().min(0),
155+
truncatedEnvironmentVariableKeys: z.number().int().min(0),
65156
})
66157

67158
const adminDashboardWorkspacePreflightSchema = z.object({
68159
workspace: adminDashboardWorkspaceCandidateSchema,
160+
/** `null` for a personal or grandfathered source. */
161+
sourceOrganization: z
162+
.object({
163+
id: z.string(),
164+
name: z.string(),
165+
ownerId: z.string().nullable(),
166+
ownerName: z.string().nullable(),
167+
ownerEmail: z.string().nullable(),
168+
})
169+
.nullable(),
69170
destinationOrganization: z.object({
70171
id: z.string(),
71172
name: z.string(),
@@ -80,6 +181,8 @@ const adminDashboardWorkspacePreflightSchema = z.object({
80181
email: z.string(),
81182
permission: z.enum(['admin', 'write', 'read']),
82183
organizationMember: z.boolean(),
184+
/** Retains access after the move, as an external collaborator. */
185+
sourceOrganizationMember: z.boolean(),
83186
})
84187
),
85188
invitations: z.array(
@@ -91,6 +194,17 @@ const adminDashboardWorkspacePreflightSchema = z.object({
91194
workspaceGrantCount: z.number().int().min(1),
92195
})
93196
),
197+
sourceOrganizationImpact: adminDashboardWorkspaceSourceImpactSchema,
198+
credentials: adminDashboardWorkspaceCredentialsSchema,
199+
entitlements: z.object({
200+
sourceIsEnterprise: z.boolean(),
201+
destinationIsEnterprise: z.boolean(),
202+
capabilitiesLost: z.array(z.string()).max(50),
203+
}),
204+
/** Non-empty means the move will throw; the UI must not offer a confirm. */
205+
blockers: z.array(z.string()).max(20),
206+
/** Advisory consequences worth reading, which never block. */
207+
notices: z.array(z.string()).max(20),
94208
warning: z.string().nullable(),
95209
})
96210

apps/sim/lib/billing/core/subscription.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,21 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole
431431
}
432432
}
433433

434+
/**
435+
* Whether an organization's entitlement actually comes from its subscription
436+
* row, as opposed to being granted by deployment configuration.
437+
*
438+
* `resolveOrganizationEnterprisePlan` short-circuits to `true` in two modes —
439+
* billing disabled, and self-hosted with access control enabled — where no
440+
* `subscription` row need exist at all. Anything that wants to re-verify an
441+
* entitlement against the subscription table must consult this first, or it
442+
* will read a missing row as a lapse and refuse work that should proceed.
443+
* Exported so those callers cannot drift from the short-circuits below.
444+
*/
445+
export function isSubscriptionBackedEntitlement(): boolean {
446+
return isBillingEnabled && !(isAccessControlEnabled && !isHosted)
447+
}
448+
434449
async function resolveOrganizationEnterprisePlan(organizationId: string): Promise<boolean> {
435450
try {
436451
if (!isBillingEnabled) {

0 commit comments

Comments
 (0)