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
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
56 changes: 56 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,61 @@ 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 re-home that commits in
* between leaves us locking the organization the parent has already left
* and inserting the child there, which is 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. Locking it here makes those wait, and the
* comparison below then sees their committed result.
*
* Scope, stated plainly. This closes the ordering the admin move
* introduces: a re-home that commits first can no longer be forked
* against a stale policy. It does NOT close the reverse ordering, where
* a fork commits while a bulk attach or detach is already waiting on
* this row with a workspace list snapshotted before the child existed.
* That batch would then re-home the parent alone. It is a pre-existing
* gap in `attachOwnedWorkspacesToOrganizationTx` and
* `detachOrganizationWorkspacesTx`, not one the move creates, and
* closing it needs the descendant closure, the disclosure set, and the
* advisory-lock plan to move together. Tracked separately rather than
* half-fixed here, because a partial repair at commit time is strictly
* worse than a documented gap.
*
* 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')
Comment thread
mzxchandra marked this conversation as resolved.
Comment thread
mzxchandra marked this conversation as resolved.
Comment thread
mzxchandra marked this conversation as resolved.
.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
.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
.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),
byokKeyCount: z.number().int().min(0),
/** Rows omitted to stay within the bounds above. */
truncatedCredentials: z.number().int().min(0),
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({
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),
Comment thread
mzxchandra marked this conversation as resolved.
/** 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)
}

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