Skip to content
Closed
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
27 changes: 26 additions & 1 deletion apps/sim/ee/workspace-forking/lib/create-fork.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* @vitest-environment node
*/
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { workspace } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
Expand Down Expand Up @@ -125,6 +126,12 @@ describe('createFork storage headroom gate', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
/**
* The fork transaction re-reads the parent's organization under the lock to
* confirm it has not moved since `assertCanFork` captured the policy.
* Matches POLICY.organizationId, so the fork proceeds.
*/
queueTableRows(workspace, [{ organizationId: null }])
mockSumForkCopyBytes.mockResolvedValue(0)
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
mockLoadSourceDeployedStates.mockResolvedValue({
Expand Down Expand Up @@ -186,6 +193,24 @@ describe('createFork storage headroom gate', () => {
expect(mockStartBackgroundWork).not.toHaveBeenCalled()
})

it('refuses when the parent changed organizations after the policy was captured', async () => {
resetDbChainMock()
/**
* `assertCanFork` captures `policy.organizationId` before this transaction,
* so an admin workspace move committing in between would otherwise leave
* the fork locking the organization the parent has already left and
* inserting the child there — the cross-organization edge the lock exists
* to prevent. The parent is re-read under the lock to catch exactly this.
*/
queueTableRows(workspace, [{ organizationId: 'org-moved-away' }])
mockSumForkCopyBytes.mockResolvedValue(0)

await expect(createFork(forkParams())).rejects.toThrow(
'changed organizations while this fork was being created'
)
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})

it('proceeds under quota, summing exactly the selected files + knowledge bases', async () => {
mockSumForkCopyBytes.mockResolvedValue(500)

Expand Down
42 changes: 42 additions & 0 deletions apps/sim/ee/workspace-forking/lib/create-fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from '@/ee/workspace-forking/lib/copy/storage-quota'
import { buildForkWorkflowIdMap } from '@/ee/workspace-forking/lib/copy/workflow-id-map'
import { copyForkWorkflowMcpAttachments } from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import { setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
import {
type ForkBlockPair,
Expand Down Expand Up @@ -161,6 +162,47 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
}
const { result, blobTasks, contentPlan, contentRefMaps } = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
/**
* The lock alone is not enough: `policy.organizationId` was captured by
* `assertCanFork` BEFORE this transaction, so a move that commits in
* between leaves us locking the organization the parent has already left
* and inserting the child there — the exact cross-organization edge the
* lock was added to prevent. Re-read the parent under the lock and refuse
* if it moved; the caller can retry against the new organization.
*/
const [currentSource] = await tx
.select({ organizationId: workspace.organizationId })
.from(workspace)
.where(eq(workspace.id, source.id))
/**
* The row lock IS the serialization, and deliberately the only one.
*
* A fork parent and child must always share an organization. Every writer
* that can re-home the parent takes `FOR NO KEY UPDATE` on its row — the
* admin workspace move, and `lockWorkspaceRowsForPayerChanges` on the
* organization-attach path — so locking it here makes those wait, and the
* comparison below then sees their committed result.
*
* An organization mutation lock was tried here and removed: it bought
* nothing the row lock does not already provide, could not cover a null
* policy organization at all, and cost three real problems — a lock-order
* inversion against invitation acceptance (which takes the workspace row
* before the organization lock), a 5s timeout overwriting this
* transaction's 10s one, and an organization-wide lock held across the
* whole content copy.
*/
.for('no key update')
.limit(1)
if (!currentSource) {
throw new ForkError('Source workspace no longer exists', 404)
}
if ((currentSource.organizationId ?? null) !== (policy.organizationId ?? null)) {
throw new ForkError(
'The source workspace changed organizations while this fork was being created. Try again.',
409
)
}

const now = new Date()

await tx.insert(workspace).values({
Expand Down
114 changes: 114 additions & 0 deletions apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,114 @@ const adminDashboardWorkspaceCandidateSchema = z.object({
ownerEmail: z.string(),
workspaceMode: z.string(),
organizationId: z.string().nullable(),
/** Name of the organization that currently owns the workspace, if any. */
organizationName: z.string().nullable(),
billedAccountUserId: z.string(),
/** Archived workspaces are movable; the flag lets admin UIs label them. */
archived: z.boolean(),
/**
* Non-null when the workspace cannot be moved. Ineligible rows are returned
* rather than filtered out so the admin learns the workspace exists and why
* it is stuck, instead of an empty result they cannot act on.
*/
ineligibleReason: z.string().nullable().optional(),
})

/** Usage split so the UI can separate what leaves from what breaks behind. */
const adminDashboardCustomBlockUsageSchema = z.object({
live: z.number().int().min(0),
deployed: z.number().int().min(0),
})

const adminDashboardWorkspaceSourceImpactSchema = z.object({
unpublishedCustomBlocks: z
Comment thread
mzxchandra marked this conversation as resolved.
Comment thread
mzxchandra marked this conversation as resolved.
.array(
z.object({
id: z.string(),
type: z.string(),
name: z.string(),
movingWorkspaceUsage: adminDashboardCustomBlockUsageSchema,
sourceOrgElsewhereUsage: adminDashboardCustomBlockUsageSchema,
})
)
.max(500),
/** Non-empty means the move is blocked until the fork is disconnected. */
blockingForkEdges: z
Comment thread
mzxchandra marked this conversation as resolved.
Comment thread
mzxchandra marked this conversation as resolved.
.array(
z.object({
workspaceId: z.string(),
name: z.string(),
organizationId: z.string().nullable(),
direction: z.enum(['parent', 'child']),
})
)
.max(500),
detachedPermissionGroups: z
.array(z.object({ permissionGroupId: z.string(), name: z.string() }))
.max(500),
strippedRetentionRules: z.object({
piiRedactionRules: z.number().int().min(0),
retentionOverrides: z.number().int().min(0),
}),
retainedCollaboratorCaps: z
.array(
z.object({
userId: z.string(),
email: z.string(),
sourceOrgLimitDollars: z.number().nullable(),
})
)
.max(1000),
brandingChanges: z.boolean(),
/**
* Rows omitted to keep the response inside the array bounds above. Non-null
* means the lists are incomplete and the notice says so.
*/
truncated: z
.object({
customBlocks: z.number().int().min(0),
permissionGroups: z.number().int().min(0),
collaboratorCaps: z.number().int().min(0),
forkEdges: z.number().int().min(0),
credentials: z.number().int().min(0),
environmentVariableKeys: z.number().int().min(0),
})
.nullable(),
})

/** Secrets that travel with the workspace. Never carries secret material. */
const adminDashboardWorkspaceCredentialsSchema = z.object({
items: z
.array(
z.object({
id: z.string(),
displayName: z.string(),
type: z.string(),
backedBySourceOrgMember: z.boolean(),
})
)
.max(1000),
credentialGroupCount: z.number().int().min(0),
/** Variable names only — values are never sent. */
environmentVariableKeys: z.array(z.string()).max(1000),
Comment thread
mzxchandra marked this conversation as resolved.
byokKeyCount: z.number().int().min(0),
/** Rows omitted to stay within the bounds above. */
truncatedCredentials: z.number().int().min(0),
Comment thread
mzxchandra marked this conversation as resolved.
truncatedEnvironmentVariableKeys: z.number().int().min(0),
})

const adminDashboardWorkspacePreflightSchema = z.object({
workspace: adminDashboardWorkspaceCandidateSchema,
/** `null` for a personal or grandfathered source. */
sourceOrganization: z
.object({
id: z.string(),
name: z.string(),
ownerId: z.string().nullable(),
ownerName: z.string().nullable(),
ownerEmail: z.string().nullable(),
})
.nullable(),
destinationOrganization: z.object({
id: z.string(),
name: z.string(),
Expand All @@ -80,6 +181,8 @@ const adminDashboardWorkspacePreflightSchema = z.object({
email: z.string(),
permission: z.enum(['admin', 'write', 'read']),
organizationMember: z.boolean(),
/** Retains access after the move, as an external collaborator. */
sourceOrganizationMember: z.boolean(),
})
),
invitations: z.array(
Expand All @@ -91,6 +194,17 @@ const adminDashboardWorkspacePreflightSchema = z.object({
workspaceGrantCount: z.number().int().min(1),
})
),
sourceOrganizationImpact: adminDashboardWorkspaceSourceImpactSchema,
credentials: adminDashboardWorkspaceCredentialsSchema,
entitlements: z.object({

@cubic-dev-ai cubic-dev-ai Bot Aug 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: After a completed move is reloaded through the move-operation endpoint, these required entitlement fields report both organizations as non-Enterprise unconditionally. Persist or reconstruct the actual entitlement values before exposing them in the operation response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts, line 199:

<comment>After a completed move is reloaded through the move-operation endpoint, these required entitlement fields report both organizations as non-Enterprise unconditionally. Persist or reconstruct the actual entitlement values before exposing them in the operation response.</comment>

<file context>
@@ -91,6 +194,17 @@ const adminDashboardWorkspacePreflightSchema = z.object({
   ),
+  sourceOrganizationImpact: adminDashboardWorkspaceSourceImpactSchema,
+  credentials: adminDashboardWorkspaceCredentialsSchema,
+  entitlements: z.object({
+    sourceIsEnterprise: z.boolean(),
+    destinationIsEnterprise: z.boolean(),
</file context>
Fix with cubic

sourceIsEnterprise: z.boolean(),
destinationIsEnterprise: z.boolean(),
capabilitiesLost: z.array(z.string()).max(50),
}),
/** Non-empty means the move will throw; the UI must not offer a confirm. */
blockers: z.array(z.string()).max(20),
/** Advisory consequences worth reading, which never block. */
notices: z.array(z.string()).max(20),
warning: z.string().nullable(),
})

Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/billing/core/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,21 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole
}
}

/**
* Whether an organization's entitlement actually comes from its subscription
* row, as opposed to being granted by deployment configuration.
*
* `resolveOrganizationEnterprisePlan` short-circuits to `true` in two modes —
* billing disabled, and self-hosted with access control enabled — where no
* `subscription` row need exist at all. Anything that wants to re-verify an
* entitlement against the subscription table must consult this first, or it
* will read a missing row as a lapse and refuse work that should proceed.
* Exported so those callers cannot drift from the short-circuits below.
*/
export function isSubscriptionBackedEntitlement(): boolean {
return isBillingEnabled && !(isAccessControlEnabled && !isHosted)
Comment thread
mzxchandra marked this conversation as resolved.

@cubic-dev-ai cubic-dev-ai Bot Aug 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When billing is enabled outside self-hosted access-control mode, this helper makes the locked move check bypass the entitlement result from resolveMoveEntitlements. An active-but-billing-blocked source can therefore be rejected as entitled, while a destination that is only past_due can be accepted as entitled despite the preflight resolver treating it as unavailable; make the locked recheck use the same billing-block and usable-status semantics as resolveOrganizationEnterprisePlan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/billing/core/subscription.ts, line 446:

<comment>When billing is enabled outside self-hosted access-control mode, this helper makes the locked move check bypass the entitlement result from `resolveMoveEntitlements`. An active-but-billing-blocked source can therefore be rejected as entitled, while a destination that is only `past_due` can be accepted as entitled despite the preflight resolver treating it as unavailable; make the locked recheck use the same billing-block and usable-status semantics as `resolveOrganizationEnterprisePlan`.</comment>

<file context>
@@ -431,6 +431,21 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole
+ * Exported so those callers cannot drift from the short-circuits below.
+ */
+export function isSubscriptionBackedEntitlement(): boolean {
+  return isBillingEnabled && !(isAccessControlEnabled && !isHosted)
+}
+
</file context>
Fix with cubic

}

async function resolveOrganizationEnterprisePlan(organizationId: string): Promise<boolean> {
try {
if (!isBillingEnabled) {
Expand Down
Loading
Loading