Skip to content

Commit 2ac4d8a

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 2ac4d8a

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)