From 2ac4d8affdff9755eba9544a64294d3dd6e6848a Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 28 Aug 2026 17:48:36 -0700 Subject: [PATCH] feat(admin): move a workspace between organizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../workspace-forking/lib/create-fork.test.ts | 27 +- .../ee/workspace-forking/lib/create-fork.ts | 42 + .../v1/admin/dashboard-workspaces.ts | 114 ++ apps/sim/lib/billing/core/subscription.ts | 15 + .../lib/workflows/custom-blocks/operations.ts | 104 +- .../workspaces/admin-move-source-impact.ts | 536 +++++++++ apps/sim/lib/workspaces/admin-move.test.ts | 436 ++++++- apps/sim/lib/workspaces/admin-move.ts | 1038 ++++++++++++++++- 8 files changed, 2224 insertions(+), 88 deletions(-) create mode 100644 apps/sim/lib/workspaces/admin-move-source-impact.ts diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index d92b211cdfb..f7317395f5a 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -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 { @@ -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({ @@ -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) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index c7dbfc3d036..ac1e970a4ca 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -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, @@ -161,6 +162,47 @@ export async function createFork(params: CreateForkParams): Promise { 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({ diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts index d5acae1f06c..3f557bc58ab 100644 --- a/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts +++ b/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts @@ -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(), @@ -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( @@ -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), + /** Advisory consequences worth reading, which never block. */ + notices: z.array(z.string()).max(20), warning: z.string().nullable(), }) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index a6162a0d221..1140e654dbe 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -431,6 +431,21 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise { try { if (!isBillingEnabled) { diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 4b9018dfa2e..a27909089f9 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -8,10 +8,12 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { and, eq, isNull, sql } from 'drizzle-orm' +import { and, eq, isNull, ne, sql } from 'drizzle-orm' import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { isBillingEnabled, isCustomBlocksEnabled } from '@/lib/core/config/env-flags' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import type { DbOrTx } from '@/lib/db/types' import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -495,41 +497,59 @@ export async function publishCustomBlock(params: { throw new CustomBlockValidationError('You can only publish a workflow from its own workspace') } - const ws = wf.workspaceId ? await getWorkspaceWithOwner(wf.workspaceId) : null - if (!ws?.organizationId || ws.organizationId !== organizationId) { - throw new CustomBlockValidationError('Workflow does not belong to this organization') - } - - // One block per workflow: the (org, type) unique index doesn't prevent the same - // workflow being published under a fresh `custom_block_*` type, so guard here. - const [existing] = await db - .select({ id: customBlock.id }) - .from(customBlock) - .where(eq(customBlock.workflowId, workflowId)) - .limit(1) - if (existing) { - throw new CustomBlockValidationError('This workflow is already published as a block') - } - const id = generateId() const type = `${CUSTOM_BLOCK_TYPE_PREFIX}${generateShortId(10).toLowerCase()}` const now = new Date() - await db.insert(customBlock).values({ - id, - organizationId, - workflowId, - type, - name, - description, - iconUrl: iconUrl ?? null, - inputs: inputs ?? [], - outputs: exposedOutputs ?? [], - enabled: true, - traceChildRuns, - createdBy: userId, - createdAt: now, - updatedAt: now, + /** + * The org-belongs check and the insert run under the organization mutation + * lock, together, because an admin workspace move holds that same lock while + * it re-homes a workspace and unpublishes the blocks bound to its workflows. + * Reading the workspace's organization outside the lock lets a publish that + * validated against the OLD organization commit after the move's cleanup + * scan, leaving a source-organization block bound to a workflow that now + * lives in another tenant — which `getCustomBlockAuthority` would resolve and + * execute under the wrong owner's credentials and billing. + */ + const ws = await db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, organizationId) + + const workspaceRow = wf.workspaceId + ? await getWorkspaceWithOwner(wf.workspaceId, { executor: tx }) + : null + if (!workspaceRow?.organizationId || workspaceRow.organizationId !== organizationId) { + throw new CustomBlockValidationError('Workflow does not belong to this organization') + } + + // One block per workflow: the (org, type) unique index doesn't prevent the same + // workflow being published under a fresh `custom_block_*` type, so guard here. + const [existing] = await tx + .select({ id: customBlock.id }) + .from(customBlock) + .where(eq(customBlock.workflowId, workflowId)) + .limit(1) + if (existing) { + throw new CustomBlockValidationError('This workflow is already published as a block') + } + + await tx.insert(customBlock).values({ + id, + organizationId, + workflowId, + type, + name, + description, + iconUrl: iconUrl ?? null, + inputs: inputs ?? [], + outputs: exposedOutputs ?? [], + enabled: true, + traceChildRuns, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + + return workspaceRow }) logger.info('Published custom block', { id, type, organizationId, workflowId }) @@ -588,9 +608,16 @@ export async function updateCustomBlock( await db.update(customBlock).set(patch).where(eq(customBlock.id, id)) } -/** Unpublish (hard-delete) a custom block. */ -export async function deleteCustomBlock(id: string): Promise { - await db.delete(customBlock).where(eq(customBlock.id, id)) +/** + * Unpublish (hard-delete) a custom block. + * + * Accepts an executor so a caller that must unpublish atomically with something + * else can enlist it — the admin workspace move unpublishes blocks in the same + * transaction that re-homes their bound workflow, keeping a block and its + * workflow from ever being visible in two different organizations. + */ +export async function deleteCustomBlock(id: string, executor: DbOrTx = db): Promise { + await executor.delete(customBlock).where(eq(customBlock.id, id)) } /** @@ -603,11 +630,14 @@ export async function deleteCustomBlock(id: string): Promise { */ export async function getCustomBlockUsageCounts( organizationId: string, - blockType: string + blockType: string, + scope?: { onlyWorkspaceId?: string; excludeWorkspaceId?: string } ): Promise<{ usageCount: number; deployedUsageCount: number }> { const orgActiveWorkflow = and( eq(workspace.organizationId, organizationId), - isNull(workflow.archivedAt) + isNull(workflow.archivedAt), + scope?.onlyWorkspaceId ? eq(workflow.workspaceId, scope.onlyWorkspaceId) : undefined, + scope?.excludeWorkspaceId ? ne(workflow.workspaceId, scope.excludeWorkspaceId) : undefined ) // Escape LIKE wildcards — the `_`s in `custom_block_` would otherwise match // any character and let unrelated states through to the jsonb parse. diff --git a/apps/sim/lib/workspaces/admin-move-source-impact.ts b/apps/sim/lib/workspaces/admin-move-source-impact.ts new file mode 100644 index 00000000000..062c0ea3de5 --- /dev/null +++ b/apps/sim/lib/workspaces/admin-move-source-impact.ts @@ -0,0 +1,536 @@ +import { db } from '@sim/db' +import { + account, + credential, + credentialGroup, + customBlock, + type DataRetentionSettings, + member, + organization, + organizationMemberUsageLimit, + permissionGroup, + permissionGroupWorkspace, + permissions, + user, + workflow, + workspace, + workspaceBYOKKeys, + workspaceEnvironment, +} from '@sim/db/schema' +import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm' +import { alias } from 'drizzle-orm/pg-core' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import type { DbOrTx } from '@/lib/db/types' +import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operations' + +/** + * Everything the source organization loses when a workspace leaves it, plus the + * in-transaction cleanup that keeps the cross-org invariants documented in + * `admin-move.ts` from ever being violated. + * + * Split out of `admin-move.ts` because the move orchestration and the question + * "what does the org left behind lose?" are separate concerns with no shared + * state — the move calls in, passes a source organization id, and gets a + * reviewable summary back. + */ + +/** Capabilities gated on the owning organization holding an Enterprise plan. */ +const ENTERPRISE_GATED_CAPABILITIES = [ + 'permission groups', + 'SSO domains', + 'data retention policies', + 'organization session revocation', + 'workspace forking', + 'custom blocks', +] as const + +export interface WorkspaceMoveSourceOrganizationRow { + id: string + name: string + ownerId: string | null + ownerName: string | null + ownerEmail: string | null +} + +/** + * The organization a workspace is leaving. + * + * Unlike `getDestinationOrganization` this uses a LEFT join on the owner: an + * organization with no owner must not block a move *out* of it — moving the + * workspace away is precisely the repair for that state. + */ +export async function getSourceOrganization( + organizationId: string, + executor: DbOrTx = db +): Promise { + const [row] = await executor + .select({ + id: organization.id, + name: organization.name, + ownerId: member.userId, + ownerName: user.name, + ownerEmail: user.email, + }) + .from(organization) + .leftJoin(member, and(eq(member.organizationId, organization.id), eq(member.role, 'owner'))) + .leftJoin(user, eq(user.id, member.userId)) + .where(eq(organization.id, organizationId)) + .limit(1) + + return row ?? null +} + +export interface CrossOrgForkEdge { + workspaceId: string + name: string + organizationId: string | null + direction: 'parent' | 'child' +} + +/** + * Fork edges that would span two organizations once the workspace lands in + * `destinationOrganizationId`, in both directions. + * + * Deliberately does NOT filter archived workspaces the way `getForkParent` / + * `getForkChildren` do. Those are read helpers for the settings UI; an archived + * workspace can be unarchived, so the invariant has to hold for it too. + */ +export async function findCrossOrgForkEdges( + workspaceId: string, + destinationOrganizationId: string, + executor: DbOrTx = db +): Promise { + const parent = alias(workspace, 'fork_parent') + const [parentRows, childRows] = await Promise.all([ + executor + .select({ + workspaceId: parent.id, + name: parent.name, + organizationId: parent.organizationId, + }) + .from(workspace) + .innerJoin(parent, eq(parent.id, workspace.forkedFromWorkspaceId)) + .where( + and( + eq(workspace.id, workspaceId), + or(isNull(parent.organizationId), ne(parent.organizationId, destinationOrganizationId)) + ) + ), + executor + .select({ + workspaceId: workspace.id, + name: workspace.name, + organizationId: workspace.organizationId, + }) + .from(workspace) + .where( + and( + eq(workspace.forkedFromWorkspaceId, workspaceId), + or( + isNull(workspace.organizationId), + ne(workspace.organizationId, destinationOrganizationId) + ) + ) + ), + ]) + + return [ + ...parentRows.map((row) => ({ ...row, direction: 'parent' as const })), + ...childRows.map((row) => ({ ...row, direction: 'child' as const })), + ] +} + +export interface UnpublishableCustomBlock { + id: string + type: string + name: string + movingWorkspaceUsage: { live: number; deployed: number } + sourceOrgElsewhereUsage: { live: number; deployed: number } +} + +/** + * Source-org custom blocks bound to a workflow inside the moving workspace. + * + * Usage is reported as two separate numbers because they mean different things + * to the admin confirming the move: placements inside the moving workspace + * leave with it, while placements elsewhere in the source org are collateral + * that stays behind and breaks. `getCustomBlockUsageCounts` counts the whole + * org, so the moving workspace's own share is measured and subtracted. + */ +export interface SourceOrgCustomBlockRow { + id: string + type: string + name: string +} + +/** + * The source-org custom blocks bound to this workspace's workflows, and nothing + * more. + * + * Separate from {@link findUnpublishableCustomBlocks} because the move runs + * inside a transaction and must stay on its executor: the usage counts that + * enrich the preflight report come from `getCustomBlockUsageCounts`, which + * reads through the global client with no executor seam. The move only needs + * the ids to delete and the names for the audit entry, so it takes this. + */ +export async function findSourceOrgCustomBlocksForWorkspace( + workspaceId: string, + sourceOrganizationId: string, + executor: DbOrTx = db +): Promise { + return executor + .select({ + id: customBlock.id, + type: customBlock.type, + name: customBlock.name, + }) + .from(customBlock) + .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) + .where( + and( + eq(workflow.workspaceId, workspaceId), + eq(customBlock.organizationId, sourceOrganizationId) + ) + ) +} + +/** + * Preflight-only: the blocks above, enriched with how much breaks. Never call + * this from inside a transaction — `getCustomBlockUsageCounts` reads through + * the global client. + */ +export async function findUnpublishableCustomBlocks( + workspaceId: string, + sourceOrganizationId: string, + executor: DbOrTx = db +): Promise<{ items: UnpublishableCustomBlock[]; total: number }> { + const rows = await findSourceOrgCustomBlocksForWorkspace( + workspaceId, + sourceOrganizationId, + executor + ) + + if (rows.length === 0) return { items: [], total: 0 } + + /** + * Cap BEFORE the fan-out. Each surviving row costs two more queries, so + * enriching an unbounded set would let one admin preflight open hundreds of + * concurrent connections and exhaust the pool. The caller bounds the list for + * the contract anyway; bounding here makes the query cost bounded too. + */ + const MAX_ENRICHED_BLOCKS = 500 + const enrichable = rows.slice(0, MAX_ENRICHED_BLOCKS) + + /** + * Both scopes are measured with the SAME predicates rather than derived by + * subtraction. `getCustomBlockUsageCounts` returns `usageCount` as the union + * of live-editor and active-deployment placements, so subtracting a + * live-only count from it misattributes a block that appears solely in the + * moving workspace's deployment to the source organization's collateral. + */ + const items = await Promise.all( + enrichable.map(async (row) => { + const [moving, elsewhere] = await Promise.all([ + getCustomBlockUsageCounts(sourceOrganizationId, row.type, { + onlyWorkspaceId: workspaceId, + }), + getCustomBlockUsageCounts(sourceOrganizationId, row.type, { + excludeWorkspaceId: workspaceId, + }), + ]) + return { + ...row, + movingWorkspaceUsage: { live: moving.usageCount, deployed: moving.deployedUsageCount }, + sourceOrgElsewhereUsage: { + live: elsewhere.usageCount, + deployed: elsewhere.deployedUsageCount, + }, + } + }) + ) + /** `total` is the untruncated row count so the caller can disclose the gap. */ + return { items, total: rows.length } +} + +export interface WorkspaceMoveCredentialSummaryRow { + items: Array<{ + id: string + displayName: string + type: string + backedBySourceOrgMember: boolean + }> + credentialGroupCount: number + environmentVariableKeys: string[] + byokKeyCount: number + /** Rows omitted to stay within response limits. */ + truncatedCredentials: number + truncatedEnvironmentVariableKeys: number +} + +/** + * Secrets that travel with the workspace, enumerated so the destination's + * admins can see exactly what they inherit. + * + * Reads display metadata only — never `encrypted*` columns, and only the + * *keys* of environment variables. `backedBySourceOrgMember` marks credentials + * whose backing identity belongs to someone in the source organization, + * mirroring `getOrganizationTransferCredentialDependenciesTx`'s predicate: the + * destination would be able to act as that person. + */ +export async function collectWorkspaceCredentialSummary( + workspaceId: string, + sourceOrganizationId: string | null, + executor: DbOrTx = db +): Promise { + const [credentialRows, groupRows, environmentRows, byokRows] = await Promise.all([ + executor + .select({ + id: credential.id, + displayName: credential.displayName, + type: credential.type, + oauthOwnerId: account.userId, + envOwnerUserId: credential.envOwnerUserId, + }) + .from(credential) + .leftJoin(account, eq(account.id, credential.accountId)) + .where(eq(credential.workspaceId, workspaceId)), + executor + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where(eq(credentialGroup.workspaceId, workspaceId)), + executor + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1), + executor + .select({ id: workspaceBYOKKeys.id }) + .from(workspaceBYOKKeys) + .where(eq(workspaceBYOKKeys.workspaceId, workspaceId)), + ]) + + const sourceMemberIds = sourceOrganizationId + ? new Set( + ( + await executor + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sourceOrganizationId)) + ).map((row) => row.userId) + ) + : new Set() + + const variables = environmentRows[0]?.variables + const CREDENTIAL_LIMIT = 1_000 + const allEnvironmentKeys = + variables && typeof variables === 'object' ? Object.keys(variables).sort() : [] + return { + truncatedCredentials: Math.max(credentialRows.length - CREDENTIAL_LIMIT, 0), + truncatedEnvironmentVariableKeys: Math.max(allEnvironmentKeys.length - CREDENTIAL_LIMIT, 0), + items: credentialRows.slice(0, CREDENTIAL_LIMIT).map((row) => { + const backingUserId = row.oauthOwnerId ?? row.envOwnerUserId + return { + id: row.id, + displayName: row.displayName, + type: row.type, + backedBySourceOrgMember: backingUserId !== null && sourceMemberIds.has(backingUserId), + } + }), + credentialGroupCount: groupRows.length, + environmentVariableKeys: allEnvironmentKeys.slice(0, CREDENTIAL_LIMIT), + byokKeyCount: byokRows.length, + } +} + +export interface WorkspaceMoveEntitlementsResult { + sourceIsEnterprise: boolean + destinationIsEnterprise: boolean + capabilitiesLost: string[] +} + +/** + * Whether the destination can carry the source's entitlements. + * + * Reads through `isOrganizationOnEnterprisePlan` rather than the `subscription` + * table so this verdict can never disagree with the gates it protects. A + * personal source has no entitlements to lose. + */ +export async function resolveMoveEntitlements( + sourceOrganizationId: string | null, + destinationOrganizationId: string +): Promise { + const [sourceIsEnterprise, destinationIsEnterprise] = await Promise.all([ + sourceOrganizationId ? isOrganizationOnEnterprisePlan(sourceOrganizationId) : false, + isOrganizationOnEnterprisePlan(destinationOrganizationId), + ]) + return { + sourceIsEnterprise, + destinationIsEnterprise, + capabilitiesLost: + sourceIsEnterprise && !destinationIsEnterprise ? [...ENTERPRISE_GATED_CAPABILITIES] : [], + } +} + +export interface RetainedCollaboratorCap { + userId: string + email: string + sourceOrgLimitDollars: number | null +} + +/** + * Collaborators who keep explicit workspace access after the move, together + * with the per-member usage cap that stops applying to them. + * + * The cap is looked up as `(payer organization, actor)`, so once the payer + * becomes the destination these people fall back to the destination's pooled + * limit with no individual ceiling. The source figures are reported — never + * copied — so the destination's admin can re-apply deliberately. + */ +export async function findRetainedCollaboratorCaps( + workspaceId: string, + sourceOrganizationId: string, + executor: DbOrTx = db +): Promise { + const rows = await executor + .select({ + userId: permissions.userId, + email: user.email, + usageLimit: organizationMemberUsageLimit.usageLimit, + }) + .from(permissions) + .innerJoin(user, eq(user.id, permissions.userId)) + /** + * No membership join. `setOrgMemberUsageLimit` explicitly supports targets + * that are not `member` rows — "external members are supported" — so an + * external collaborator can hold a source-organization cap. Requiring + * membership here silently dropped exactly those people from the review, + * which is the opposite of the field's purpose: disclosing every cap that + * stops applying. The cap row itself already scopes to the source org. + */ + .leftJoin( + organizationMemberUsageLimit, + and( + eq(organizationMemberUsageLimit.userId, permissions.userId), + eq(organizationMemberUsageLimit.organizationId, sourceOrganizationId) + ) + ) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + + return rows.map((row) => ({ + userId: row.userId, + email: row.email, + sourceOrgLimitDollars: row.usageLimit === null ? null : Number(row.usageLimit), + })) +} + +/** Permission-group rows that will be detached, resolved to group names. */ +export async function findAttachedPermissionGroups( + workspaceId: string, + executor: DbOrTx = db +): Promise> { + return executor + .select({ + permissionGroupId: permissionGroupWorkspace.permissionGroupId, + name: permissionGroup.name, + }) + .from(permissionGroupWorkspace) + .innerJoin(permissionGroup, eq(permissionGroup.id, permissionGroupWorkspace.permissionGroupId)) + .where(eq(permissionGroupWorkspace.workspaceId, workspaceId)) +} + +/** Counts the source-org retention entries that name this workspace. */ +export function countRetentionRulesForWorkspace( + settings: DataRetentionSettings | null | undefined, + workspaceId: string +): { piiRedactionRules: number; retentionOverrides: number } { + return { + piiRedactionRules: (settings?.piiRedaction?.rules ?? []).filter( + (rule) => rule.workspaceId === workspaceId + ).length, + retentionOverrides: (settings?.retentionOverrides ?? []).filter( + (override) => override.workspaceId === workspaceId + ).length, + } +} + +/** Removes every entry naming `workspaceId`, or `null` when nothing changed. */ +export function stripRetentionRulesForWorkspace( + settings: DataRetentionSettings | null | undefined, + workspaceId: string +): DataRetentionSettings | null { + if (!settings) return null + const counts = countRetentionRulesForWorkspace(settings, workspaceId) + if (counts.piiRedactionRules === 0 && counts.retentionOverrides === 0) return null + + const next: DataRetentionSettings = { ...settings } + if (settings.piiRedaction?.rules) { + next.piiRedaction = { + ...settings.piiRedaction, + rules: settings.piiRedaction.rules.filter((rule) => rule.workspaceId !== workspaceId), + } + } + if (settings.retentionOverrides) { + next.retentionOverrides = settings.retentionOverrides.filter( + (override) => override.workspaceId !== workspaceId + ) + } + return next +} + +/** + * Deletes the source-org rows that cannot follow the workspace and would + * otherwise desynchronize, and strips the source org's retention entries that + * name it. Runs inside the move transaction. + * + * `permission_group_workspace` grants nothing after the move — `resolveWorkspaceGroup` + * filters by the workspace's *current* organization — but `getGroupWorkspaces` + * joins `workspace` with no organization filter, so leaving the rows would leak + * the departed workspace's name into the source org's group UI and permanently + * desync the denormalized `organization_id` column. + */ +export async function cleanupSourceOrganizationArtifactsTx( + tx: DbOrTx, + params: { workspaceId: string; sourceOrganizationId: string } +): Promise<{ detachedPermissionGroupIds: string[] }> { + const detached = await tx + .delete(permissionGroupWorkspace) + .where(eq(permissionGroupWorkspace.workspaceId, params.workspaceId)) + .returning({ permissionGroupId: permissionGroupWorkspace.permissionGroupId }) + + const [sourceOrg] = await tx + .select({ dataRetentionSettings: organization.dataRetentionSettings }) + .from(organization) + .where(eq(organization.id, params.sourceOrganizationId)) + .for('update') + .limit(1) + + const strippedSettings = stripRetentionRulesForWorkspace( + sourceOrg?.dataRetentionSettings, + params.workspaceId + ) + if (strippedSettings) { + await tx + .update(organization) + .set({ dataRetentionSettings: strippedSettings, updatedAt: new Date() }) + .where(eq(organization.id, params.sourceOrganizationId)) + } + + return { detachedPermissionGroupIds: detached.map((row) => row.permissionGroupId) } +} + +/** True when the two organizations present different whitelabel branding. */ +export async function willBrandingChange( + sourceOrganizationId: string, + destinationOrganizationId: string, + executor: DbOrTx = db +): Promise { + const rows = await executor + .select({ id: organization.id, whitelabelSettings: organization.whitelabelSettings }) + .from(organization) + .where(inArray(organization.id, [sourceOrganizationId, destinationOrganizationId])) + + const source = rows.find((row) => row.id === sourceOrganizationId)?.whitelabelSettings ?? null + const destination = + rows.find((row) => row.id === destinationOrganizationId)?.whitelabelSettings ?? null + return JSON.stringify(source ?? null) !== JSON.stringify(destination ?? null) +} diff --git a/apps/sim/lib/workspaces/admin-move.test.ts b/apps/sim/lib/workspaces/admin-move.test.ts index ea83ac9526c..806bbfa979e 100644 --- a/apps/sim/lib/workspaces/admin-move.test.ts +++ b/apps/sim/lib/workspaces/admin-move.test.ts @@ -28,6 +28,13 @@ import { WORKSPACE_MODE } from '@/lib/workspaces/policy' vi.unmock('drizzle-orm') const { + resolveMoveEntitlements, + findCrossOrgForkEdges, + findUnpublishableCustomBlocks, + findSourceOrgCustomBlocksForWorkspace, + cleanupSourceOrganizationArtifactsTx, + deleteCustomBlock, + acquireOrganizationMutationLock, recordAudit, recordAuditOnce, enqueueOrReschedulePendingOutboxEvent, @@ -40,6 +47,21 @@ const { countPendingSeatInvitations, resolveSeatCapacity, } = vi.hoisted(() => ({ + resolveMoveEntitlements: vi.fn(() => + Promise.resolve({ + sourceIsEnterprise: false, + destinationIsEnterprise: false, + capabilitiesLost: [] as string[], + }) + ), + findCrossOrgForkEdges: vi.fn(() => Promise.resolve([])), + findUnpublishableCustomBlocks: vi.fn(() => Promise.resolve({ items: [], total: 0 })), + findSourceOrgCustomBlocksForWorkspace: vi.fn(() => Promise.resolve([])), + cleanupSourceOrganizationArtifactsTx: vi.fn(() => + Promise.resolve({ detachedPermissionGroupIds: [] }) + ), + deleteCustomBlock: vi.fn(), + acquireOrganizationMutationLock: vi.fn(), recordAudit: vi.fn(), recordAuditOnce: vi.fn(), enqueueOrReschedulePendingOutboxEvent: vi.fn(), @@ -54,13 +76,22 @@ const { })) vi.mock('@sim/audit', () => ({ - AuditAction: { WORKSPACE_UPDATED: 'workspace.updated', INVITATION_UPDATED: 'invitation.updated' }, - AuditResourceType: { WORKSPACE: 'workspace' }, + AuditAction: { + WORKSPACE_UPDATED: 'workspace.updated', + INVITATION_UPDATED: 'invitation.updated', + ORGANIZATION_UPDATED: 'organization.updated', + CUSTOM_BLOCK_DELETED: 'custom_block.deleted', + }, + AuditResourceType: { + WORKSPACE: 'workspace', + ORGANIZATION: 'organization', + CUSTOM_BLOCK: 'custom_block', + }, recordAudit, recordAuditOnce, })) vi.mock('@/lib/billing/organizations/membership', () => ({ - acquireOrganizationMutationLock: vi.fn(), + acquireOrganizationMutationLock, })) vi.mock('@/lib/billing/storage/payer-transfer', () => ({ changeWorkspaceStoragePayerInTx })) vi.mock('@/lib/billing/validation/seat-management', () => ({ @@ -87,6 +118,40 @@ vi.mock('@/lib/invitations/send', () => ({ sendInvitationEmail, })) vi.mock('@/lib/table/billing', () => ({ invalidateWorkspaceTableLimitsCache })) +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ deleteCustomBlock })) +vi.mock('@/lib/workspaces/admin-move-source-impact', () => ({ + cleanupSourceOrganizationArtifactsTx, + collectWorkspaceCredentialSummary: vi.fn(() => + Promise.resolve({ + items: [], + credentialGroupCount: 0, + environmentVariableKeys: [], + byokKeyCount: 0, + truncatedCredentials: 0, + truncatedEnvironmentVariableKeys: 0, + }) + ), + countRetentionRulesForWorkspace: vi.fn(() => ({ + piiRedactionRules: 0, + retentionOverrides: 0, + })), + findAttachedPermissionGroups: vi.fn(() => Promise.resolve([])), + findCrossOrgForkEdges, + findRetainedCollaboratorCaps: vi.fn(() => Promise.resolve([])), + findUnpublishableCustomBlocks, + findSourceOrgCustomBlocksForWorkspace, + getSourceOrganization: vi.fn(() => + Promise.resolve({ + id: 'org-source', + name: 'Source', + ownerId: 'source-owner', + ownerName: 'Source Owner', + ownerEmail: 'source-owner@example.com', + }) + ), + resolveMoveEntitlements, + willBrandingChange: vi.fn(() => Promise.resolve(false)), +})) const movedWorkspace = { id: 'workspace-1', @@ -109,6 +174,15 @@ const personalWorkspace = { storageUsedBytes: 128, } +/** Organization-owned source, for the org-to-org path. */ +const organizationWorkspace = { + ...movedWorkspace, + name: 'Organization workspace', + workspaceMode: WORKSPACE_MODE.ORGANIZATION, + organizationId: 'org-source', + billedAccountUserId: 'source-org-owner', +} + const destination = { id: 'org-1', name: 'Destination', @@ -118,12 +192,19 @@ const destination = { } /** - * The move flow reads the workspace twice in order — the locked classification - * row and the final summary reload — so the workspace queue gets one set per - * read. All invitation/grant/permission selects resolve the queue-less empty - * default. + * The move flow reads the workspace three times in order: the optimistic + * pre-transaction organization read that decides which organizations to lock, + * the locked classification row, and the final summary reload. The workspace + * queue therefore gets one set per read, in that order. + * + * Keep this comment in step with the reads — a stale count silently shifts + * every later queue entry onto the wrong statement, which surfaces as an + * unrelated "could not be reloaded" failure rather than a queueing error. + * + * All invitation/grant/permission selects resolve the queue-less empty default. */ function queueMoveSelects(workspaceRow: Record) { + queueTableRows(workspace, [workspaceRow]) queueTableRows(workspace, [workspaceRow]) queueTableRows(workspace, [workspaceRow]) queueTableRows(organization, [destination]) @@ -156,8 +237,8 @@ describe('classifyWorkspaceMoveState', () => { ).toBe('already-moved') }) - it('continues to reject inter-organization transfers', () => { - expect(() => + it('classifies a workspace owned by a different organization as a move', () => { + expect( classifyWorkspaceMoveState( { workspaceMode: WORKSPACE_MODE.ORGANIZATION, @@ -166,6 +247,19 @@ describe('classifyWorkspaceMoveState', () => { }, 'org-2' ) + ).toBe('move') + }) + + it('rejects a drifted organization mode when no organization is assigned', () => { + expect(() => + classifyWorkspaceMoveState( + { + workspaceMode: WORKSPACE_MODE.ORGANIZATION, + organizationId: null, + archivedAt: null, + }, + 'org-destination' + ) ).toThrowError( expect.objectContaining>({ code: 'already-organization-workspace', @@ -221,6 +315,44 @@ describe('workspace move invitation bounds', () => { }) }) + it('reports a pending invitation as a blocker for an organization-owned source', async () => { + queueTableRows(workspace, [organizationWorkspace]) + queueTableRows(organization, [destination]) + queueTableRows(invitationWorkspaceGrant, [ + { + id: 'invitation-1', + email: 'invitee@example.com', + organizationId: 'org-source', + membershipIntent: 'internal', + permission: 'read', + }, + ]) + + const preflight = await getWorkspaceMovePreflight(organizationWorkspace.id, destination.id) + + expect(preflight.blockers).toEqual([expect.stringContaining('pending invitation')]) + expect(preflight.sourceOrganization).toMatchObject({ id: 'org-source' }) + }) + + it('reports no invitation blocker for a personal source', async () => { + queueTableRows(workspace, [personalWorkspace]) + queueTableRows(organization, [destination]) + queueTableRows(invitationWorkspaceGrant, [ + { + id: 'invitation-1', + email: 'invitee@example.com', + organizationId: null, + membershipIntent: 'internal', + permission: 'read', + }, + ]) + + const preflight = await getWorkspaceMovePreflight(personalWorkspace.id, destination.id) + + expect(preflight.blockers).toEqual([]) + expect(preflight.sourceOrganization).toBeNull() + }) + it('blocks a move when bounded invitation rows expand into too many workspace grants', async () => { queueTableRows(workspace, [personalWorkspace]) queueTableRows(organization, [destination]) @@ -501,6 +633,15 @@ describe('moveWorkspaceToOrganization retries', () => { previousBillingOwnerId: personalWorkspace.billedAccountUserId, newBillingOwnerId: destination.ownerId, organizationAssignedAt: expect.any(String), + /** + * Persisted so a reload of a completed operation can still name the + * organization the workspace came from — the payer transfer has + * already overwritten `workspace.organizationId` by then. + */ + sourceOrganizationId: null, + /** Persisted so the reload path can replay the source-org audit. */ + unpublishedCustomBlocks: [], + detachedPermissionGroupIds: [], }, }, }) @@ -653,6 +794,283 @@ describe('moveWorkspaceToOrganization retries', () => { expect(payerMutation).toBeGreaterThan(firstForUpdate) }) + it('locks both organizations in ascending id order, after invitation locks and before the row lock', async () => { + queueMoveSelects(organizationWorkspace) + + await moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + const lockedOrganizationIds = acquireOrganizationMutationLock.mock.calls.map( + (call) => call[1] as string + ) + expect(lockedOrganizationIds).toEqual(['org-1', 'org-source']) + const invitationLock = acquireInvitationMutationLocks.mock.invocationCallOrder[0] + const firstOrganizationLock = acquireOrganizationMutationLock.mock.invocationCallOrder[0] + const firstForUpdate = dbChainMockFns.for.mock.invocationCallOrder[0] + expect(firstOrganizationLock).toBeGreaterThan(invitationLock) + expect(firstForUpdate).toBeGreaterThan(firstOrganizationLock) + }) + + it('fences the payer transfer on the source organization it read under the locks', async () => { + queueMoveSelects(organizationWorkspace) + + await moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + expect(changeWorkspaceStoragePayerInTx).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + organizationId: destination.id, + expectedCurrentPayer: { + organizationId: 'org-source', + billedAccountUserId: organizationWorkspace.billedAccountUserId, + }, + }) + ) + }) + + it('records the loss in the source organization audit view, not the destination', async () => { + queueMoveSelects(organizationWorkspace) + findSourceOrgCustomBlocksForWorkspace.mockResolvedValueOnce([ + { id: 'block-1', type: 'custom_block_1', name: 'Reporter' }, + ] as never) + + await moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + /** + * `workspaceId: null` + `metadata.organizationId` is the org-level branch + * of `buildOrgScopeCondition`. The workspace-scoped move entry resolves to + * the destination after the move, so without these the organization that + * lost the workspace would have no record of it. + */ + const entries = recordAudit.mock.calls.map((call) => call[0]) + expect(entries).toContainEqual( + expect.objectContaining({ + workspaceId: null, + action: 'organization.updated', + resourceId: 'org-source', + metadata: expect.objectContaining({ organizationId: 'org-source' }), + }) + ) + expect(entries).toContainEqual( + expect.objectContaining({ + workspaceId: null, + action: 'custom_block.deleted', + resourceId: 'block-1', + metadata: expect.objectContaining({ organizationId: 'org-source' }), + }) + ) + }) + + it('records no source-organization entry for a personal source', async () => { + queueMoveSelects(personalWorkspace) + + await moveWorkspaceToOrganization({ + workspaceId: personalWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + expect(recordAudit.mock.calls.map((call) => call[0])).not.toContainEqual( + expect.objectContaining({ action: 'organization.updated' }) + ) + }) + + it('reports the source organization in the applied summary', async () => { + queueMoveSelects(organizationWorkspace) + + const summary = await moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + /** + * The summary reloads the workspace AFTER the payer transfer has rewritten + * `organizationId`, so the source is only reportable if it was captured + * beforehand and threaded through. + */ + expect(summary.sourceOrganization).toMatchObject({ id: 'org-source' }) + }) + + it('re-fences the payer transfer after a SourceOrganizationChangedError retry', async () => { + /** + * The optimistic pre-transaction organization read decides which + * organizations get locked. When the workspace moves between that read and + * the locked read, the attempt must abort and retry — otherwise the payer + * transfer is fenced on an organization the workspace has already left, and + * `changeWorkspaceStoragePayerInTx`'s optimistic check is the only thing + * standing between that and a corrupted storage ledger. + * + * First locked read reports a different organization than the pre-read, so + * the loop retries; the second attempt fences on the organization it + * actually observed under the locks. + */ + queueTableRows(workspace, [organizationWorkspace]) + queueTableRows(workspace, [{ ...organizationWorkspace, organizationId: 'org-moved' }]) + queueTableRows(workspace, [{ ...organizationWorkspace, organizationId: 'org-moved' }]) + queueTableRows(workspace, [{ ...organizationWorkspace, organizationId: 'org-moved' }]) + queueTableRows(organization, [destination]) + + await moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + expect(changeWorkspaceStoragePayerInTx).toHaveBeenCalledTimes(1) + expect(changeWorkspaceStoragePayerInTx).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + expectedCurrentPayer: expect.objectContaining({ organizationId: 'org-moved' }), + }) + ) + }) + + it('unpublishes source-organization custom blocks bound to the moving workspace', async () => { + queueMoveSelects(organizationWorkspace) + findSourceOrgCustomBlocksForWorkspace.mockResolvedValueOnce([ + { id: 'block-1', type: 'custom_block_1', name: 'Reporter' }, + ] as never) + + await moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + expect(deleteCustomBlock).toHaveBeenCalledWith('block-1', expect.anything()) + expect(cleanupSourceOrganizationArtifactsTx).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ sourceOrganizationId: 'org-source' }) + ) + }) + + it('refuses a cross-organization fork edge without mutating anything', async () => { + queueMoveSelects(organizationWorkspace) + findCrossOrgForkEdges.mockResolvedValueOnce([ + { + workspaceId: 'parent-1', + name: 'Parent', + organizationId: 'org-source', + direction: 'parent', + }, + ] as never) + + await expect( + moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + ).rejects.toMatchObject>({ code: 'fork-lineage-conflict' }) + + expect(changeWorkspaceStoragePayerInTx).not.toHaveBeenCalled() + expect(deleteCustomBlock).not.toHaveBeenCalled() + }) + + it('refuses a cross-organization fork edge on a PERSONAL source too', async () => { + queueMoveSelects(personalWorkspace) + findCrossOrgForkEdges.mockResolvedValueOnce([ + { workspaceId: 'parent-1', name: 'Parent', organizationId: 'org-other', direction: 'parent' }, + ] as never) + + /** + * A personal workspace whose parent has since moved into an organization + * still produces a cross-org edge. Gating the check on an organization + * source let the transaction accept a move preflight had already refused. + */ + await expect( + moveWorkspaceToOrganization({ + workspaceId: personalWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + ).rejects.toMatchObject>({ code: 'fork-lineage-conflict' }) + + expect(changeWorkspaceStoragePayerInTx).not.toHaveBeenCalled() + }) + + it('does not fence entitlements when they are not subscription-backed', async () => { + queueMoveSelects(organizationWorkspace) + /** + * `resolveOrganizationEnterprisePlan` grants entitlement by deployment + * configuration in two modes — billing disabled, and self-hosted with + * access control enabled — so `sourceIsEnterprise` is true while no + * `subscription` rows exist. A fence that treats a missing row as a failure + * would then reject EVERY organization-to-organization move in both. + */ + resolveMoveEntitlements.mockResolvedValueOnce({ + sourceIsEnterprise: true, + destinationIsEnterprise: true, + capabilitiesLost: [], + }) + + await moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + expect(changeWorkspaceStoragePayerInTx).toHaveBeenCalledTimes(1) + }) + + it('refuses an entitlement downgrade without mutating anything', async () => { + queueMoveSelects(organizationWorkspace) + resolveMoveEntitlements.mockResolvedValueOnce({ + sourceIsEnterprise: true, + destinationIsEnterprise: false, + capabilitiesLost: ['permission groups', 'workspace forking'], + }) + + await expect( + moveWorkspaceToOrganization({ + workspaceId: organizationWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + ).rejects.toMatchObject>({ + code: 'destination-entitlement-downgrade', + }) + + expect(changeWorkspaceStoragePayerInTx).not.toHaveBeenCalled() + }) + + it('leaves the personal source path untouched', async () => { + queueMoveSelects(personalWorkspace) + + await moveWorkspaceToOrganization({ + workspaceId: personalWorkspace.id, + destinationOrganizationId: destination.id, + adminEmail: 'admin@sim.ai', + }) + + expect(acquireOrganizationMutationLock.mock.calls.map((call) => call[1])).toEqual([ + destination.id, + ]) + expect(deleteCustomBlock).not.toHaveBeenCalled() + expect(cleanupSourceOrganizationArtifactsTx).not.toHaveBeenCalled() + expect(changeWorkspaceStoragePayerInTx).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + expectedCurrentPayer: { + organizationId: null, + billedAccountUserId: personalWorkspace.billedAccountUserId, + }, + }) + ) + }) + it('rejects a stale batch selection when workspace ownership changed', async () => { queueMoveSelects({ ...personalWorkspace, ownerId: 'new-owner' }) diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index 401fb6ea787..1583b95a98a 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -9,6 +9,7 @@ import { permissions, subscription, user, + userStats, workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -16,22 +17,11 @@ import { PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/worksp import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' -import { - and, - asc, - count, - eq, - gt, - ilike, - inArray, - isNotNull, - isNull, - lte, - ne, - or, - sql, -} from 'drizzle-orm' +import { and, asc, count, eq, gt, ilike, inArray, isNotNull, lte, ne, or, sql } from 'drizzle-orm' +import { alias } from 'drizzle-orm/pg-core' +import { isSubscriptionBackedEntitlement } from '@/lib/billing/core/subscription' import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { isOrgPlan } from '@/lib/billing/plan-helpers' import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' import { ENTITLED_SUBSCRIPTION_STATUSES, @@ -54,6 +44,21 @@ import { getInvitationById, isInvitationExpired } from '@/lib/invitations/core' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { PENDING_INVITATION_UNIQUE_INDEX, sendInvitationEmail } from '@/lib/invitations/send' import { invalidateWorkspaceTableLimitsCache } from '@/lib/table/billing' +import { deleteCustomBlock } from '@/lib/workflows/custom-blocks/operations' +import { + type CrossOrgForkEdge, + cleanupSourceOrganizationArtifactsTx, + collectWorkspaceCredentialSummary, + countRetentionRulesForWorkspace, + findAttachedPermissionGroups, + findCrossOrgForkEdges, + findRetainedCollaboratorCaps, + findSourceOrgCustomBlocksForWorkspace, + findUnpublishableCustomBlocks, + getSourceOrganization, + resolveMoveEntitlements, + willBrandingChange, +} from '@/lib/workspaces/admin-move-source-impact' import { mergeInvitationMembershipIntent, mergeInvitationRole, @@ -62,6 +67,31 @@ import { import { WORKSPACE_MODE } from '@/lib/workspaces/policy' const logger = createLogger('AdminWorkspaceMove') + +/** Second `member` alias so one query can test membership of both organizations. */ +const sourceMember = alias(member, 'source_member') + +/** + * Moving a workspace between organizations is the only operation in the product + * capable of separating an artifact from the organization that owns it, so two + * invariants that nothing else has ever had to defend are enforced here. + * + * **A custom block and its bound workflow always share an organization.** + * `publishCustomBlock` refuses a workflow outside the target org, so the pair + * has always been co-located. `getCustomBlockAuthority` resolves by the + * *consumer's* org and `admitCustomBlockChildExecution` deliberately skips its + * concurrency reservation because "the consumer and source workspaces are always + * in the same organization" — a stranded row would run a foreign tenant's + * workflow under its owner's credentials, billed to the wrong payer. The move + * therefore unpublishes every source-org block bound to the moving workspace. + * + * **A fork parent and child always share an organization.** `assertCanFork` + * pins the child to the source's org, and `resolveForkEdge` has no org check at + * all. The move refuses to run while a cross-org edge would result; the fork + * must be disconnected first. + * + * Neither invariant tolerates a transitional or "inert" violation. + */ /** * A dashboard member add may move several grants from one invitation in * consecutive short transactions. Let that split/merge sequence settle before @@ -83,6 +113,11 @@ export class WorkspaceMoveError extends Error { | 'already-organization-workspace' | 'seat-capacity-exceeded' | 'invitation-volume-exceeded' + | 'source-equals-destination' + | 'move-operation-parameter-mismatch' + | 'destination-entitlement-downgrade' + | 'fork-lineage-conflict' + | 'pending-invitations-present' ) { super(message) this.name = 'WorkspaceMoveError' @@ -97,13 +132,104 @@ export interface WorkspaceMoveCandidate { ownerEmail: string workspaceMode: string organizationId: string | null + /** Name of the organization that currently owns the workspace, if any. */ + organizationName: string | null billedAccountUserId: string /** Archived workspaces are movable; surfaced so admin UIs can label them. */ archived: boolean + /** Non-null when the workspace cannot be moved, explaining why. */ + ineligibleReason?: string | null +} + +/** + * The organization a workspace is moving out of. Unlike a destination, an + * ownerless source must not block the move — moving out of it is the fix — so + * every owner field is nullable. + */ +export interface WorkspaceMoveSourceOrganization { + id: string + name: string + ownerId: string | null + ownerName: string | null + ownerEmail: string | null +} + +/** + * Everything the source organization loses or has cleaned up by the move, so an + * admin can review the damage before confirming. + */ +export interface WorkspaceMoveSourceImpact { + /** + * Source-org custom blocks bound to the moving workspace's workflows. These + * are unpublished by the move — see the cross-org invariant in the module + * header. Usage is split because the two halves mean different things: + * placements inside the moving workspace leave with it, while placements + * elsewhere in the source org are collateral that stays behind and breaks. + */ + unpublishedCustomBlocks: Array<{ + id: string + type: string + name: string + movingWorkspaceUsage: { live: number; deployed: number } + sourceOrgElsewhereUsage: { live: number; deployed: number } + }> + /** Fork edges crossing the org boundary. Non-empty blocks the move. */ + blockingForkEdges: Array<{ + workspaceId: string + name: string + organizationId: string | null + direction: 'parent' | 'child' + }> + detachedPermissionGroups: Array<{ permissionGroupId: string; name: string }> + strippedRetentionRules: { piiRedactionRules: number; retentionOverrides: number } + /** Retained collaborators whose source-org per-member cap stops applying. */ + retainedCollaboratorCaps: Array<{ + userId: string + email: string + sourceOrgLimitDollars: number | null + }> + /** The workspace visibly re-skins when the two orgs' whitelabel settings differ. */ + brandingChanges: boolean + /** Rows omitted to stay inside the contract's array bounds, or `null`. */ + truncated: { + customBlocks: number + permissionGroups: number + collaboratorCaps: number + forkEdges: number + credentials: number + environmentVariableKeys: number + } | null +} + +/** Workspace secrets that travel with the move. Never carries secret material. */ +export interface WorkspaceMoveCredentialSummary { + items: Array<{ + id: string + displayName: string + type: string + /** Backed by a source-org member's identity, so the destination inherits their access. */ + backedBySourceOrgMember: boolean + }> + credentialGroupCount: number + /** Variable names only — values are never read. */ + environmentVariableKeys: string[] + byokKeyCount: number + /** Rows omitted to stay within response limits. */ + truncatedCredentials: number + truncatedEnvironmentVariableKeys: number +} + +export interface WorkspaceMoveEntitlements { + sourceIsEnterprise: boolean + destinationIsEnterprise: boolean + /** Non-empty when the destination cannot carry the source's entitlements. */ + capabilitiesLost: string[] } export interface WorkspaceMovePreflight { workspace: WorkspaceMoveCandidate + /** `null` for a personal or grandfathered source. */ + sourceOrganization: WorkspaceMoveSourceOrganization | null destinationOrganization: { id: string name: string @@ -117,6 +243,7 @@ export interface WorkspaceMovePreflight { email: string permission: 'admin' | 'write' | 'read' organizationMember: boolean + sourceOrganizationMember: boolean }> invitations: Array<{ id: string @@ -125,6 +252,13 @@ export interface WorkspaceMovePreflight { permission: 'admin' | 'write' | 'read' workspaceGrantCount: number }> + sourceOrganizationImpact: WorkspaceMoveSourceImpact + credentials: WorkspaceMoveCredentialSummary + entitlements: WorkspaceMoveEntitlements + /** Non-empty means the move will throw; the UI must not offer a confirm. */ + blockers: string[] + /** Advisory consequences the admin should read but which never block. */ + notices: string[] warning: string | null } @@ -168,6 +302,12 @@ interface WorkspaceMoveDestination { interface MoveTransactionResult { performedMove: boolean + /** What the source organization lost, for its own audit entry. */ + sourceOrganizationOutcome: { + sourceOrganizationId: string + unpublishedCustomBlocks: Array<{ id: string; type: string; name: string }> + detachedPermissionGroupIds: string[] + } | null previousBillingOwnerId: string destinationOwnerId: string organizationAssignedAt: Date | null @@ -192,6 +332,22 @@ interface AdminWorkspaceMoveOperationPayload { previousBillingOwnerId: string newBillingOwnerId: string organizationAssignedAt: string + /** + * The organization the workspace came from. Persisted because the payer + * transfer overwrites `workspace.organizationId`, so a reload of a + * completed operation cannot recover it from the row — and the admin UI + * reloads exactly that way after a lost response. + * Optional: operations recorded before this field existed have no value. + */ + sourceOrganizationId?: string | null + /** + * Persisted so the reload path can replay the source organization's loss + * audit. That write is fire-and-forget after commit, so a crash in between + * would otherwise leave the organization that lost the workspace with no + * record and no way to reconstruct one. + */ + unpublishedCustomBlocks?: Array<{ id: string; type: string; name: string }> + detachedPermissionGroupIds?: string[] } } @@ -224,7 +380,10 @@ function parseAdminWorkspaceMoveOperationPayload( Array.isArray(actor) || typeof auditRecord.previousBillingOwnerId !== 'string' || typeof auditRecord.newBillingOwnerId !== 'string' || - typeof auditRecord.organizationAssignedAt !== 'string' + typeof auditRecord.organizationAssignedAt !== 'string' || + (auditRecord.sourceOrganizationId !== undefined && + auditRecord.sourceOrganizationId !== null && + typeof auditRecord.sourceOrganizationId !== 'string') ) { return null } @@ -251,6 +410,12 @@ function parseAdminWorkspaceMoveOperationPayload( previousBillingOwnerId: auditRecord.previousBillingOwnerId, newBillingOwnerId: auditRecord.newBillingOwnerId, organizationAssignedAt: auditRecord.organizationAssignedAt, + sourceOrganizationId: (auditRecord.sourceOrganizationId as string | null | undefined) ?? null, + unpublishedCustomBlocks: + (auditRecord.unpublishedCustomBlocks as + | Array<{ id: string; type: string; name: string }> + | undefined) ?? [], + detachedPermissionGroupIds: (auditRecord.detachedPermissionGroupIds as string[]) ?? [], }, } } @@ -267,6 +432,18 @@ function workspaceMoveOperationMatches( ) } +/** + * The workspace changed organizations between the optimistic pre-transaction + * read and the locked read, so the wrong organization was locked. Handled by + * the same retry loop as {@link InvitationSetChangedError}. + */ +class SourceOrganizationChangedError extends Error { + constructor(readonly organizationId: string | null) { + super('Workspace organization changed while acquiring workspace move locks') + this.name = 'SourceOrganizationChangedError' + } +} + class InvitationSetChangedError extends Error { constructor(readonly invitationIds: string[]) { super('Pending invitation set changed while acquiring workspace move locks') @@ -281,7 +458,11 @@ function isConcurrentPendingInvitationInsert(error: unknown): boolean { ) } -/** Returns movable personal/grandfathered workspaces by case-insensitive name or exact UUID. */ +/** + * Returns movable workspaces by case-insensitive name or exact UUID, including + * organization-owned ones. `organizationName` is joined so an admin can see + * which organization a candidate would be taken *from* before selecting it. + */ export async function searchWorkspaceMoveCandidates(search: string, limit = 20, offset = 0) { const query = search.trim() if (!query) { @@ -297,19 +478,15 @@ export async function searchWorkspaceMoveCandidates(search: string, limit = 20, ownerEmail: user.email, workspaceMode: workspace.workspaceMode, organizationId: workspace.organizationId, + organizationName: organization.name, billedAccountUserId: workspace.billedAccountUserId, archivedAt: workspace.archivedAt, total: sql`count(*) over()`.mapWith(Number), }) .from(workspace) .innerJoin(user, eq(user.id, workspace.ownerId)) - .where( - and( - ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION), - isNull(workspace.organizationId), - or(eq(workspace.id, query), ilike(workspace.name, `%${query}%`)) - ) - ) + .leftJoin(organization, eq(organization.id, workspace.organizationId)) + .where(and(or(eq(workspace.id, query), ilike(workspace.name, `%${query}%`)), undefined)) .orderBy(asc(workspace.name)) .limit(Math.min(Math.max(limit, 1), 50)) .offset(Math.max(offset, 0)) @@ -317,9 +494,16 @@ export async function searchWorkspaceMoveCandidates(search: string, limit = 20, const boundedLimit = Math.min(Math.max(limit, 1), 50) const total = rows[0]?.total ?? 0 return { + /** + * Ineligible rows are returned, not hidden. A support admin searching for a + * workspace by name needs to learn that it exists and why it cannot move — + * an empty result is indistinguishable from "no such workspace" and leaves + * them with no next step. + */ data: rows.map(({ archivedAt, total: _total, ...row }) => ({ ...row, archived: archivedAt !== null, + ineligibleReason: describeWorkspaceMoveIneligibility(row), })), pagination: { total, @@ -342,6 +526,14 @@ export async function getWorkspaceMovePreflight( } assertWorkspaceMovable(workspaceRow) + const sourceOrganizationId = workspaceRow.organizationId + if (sourceOrganizationId === destinationOrganizationId) { + throw new WorkspaceMoveError( + 'Workspace already belongs to this organization', + 'source-equals-destination' + ) + } + const destination = await getDestinationOrganization(destinationOrganizationId) if (!destination) { throw new WorkspaceMoveError('Destination organization not found', 'organization-not-found') @@ -355,6 +547,7 @@ export async function getWorkspaceMovePreflight( email: user.email, permission: permissions.permissionType, memberId: member.id, + sourceMemberId: sourceMember.id, }) .from(permissions) .innerJoin(user, eq(user.id, permissions.userId)) @@ -365,6 +558,13 @@ export async function getWorkspaceMovePreflight( eq(member.organizationId, destinationOrganizationId) ) ) + .leftJoin( + sourceMember, + and( + eq(sourceMember.userId, permissions.userId), + sourceOrganizationId ? eq(sourceMember.organizationId, sourceOrganizationId) : sql`false` + ) + ) .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) .orderBy(asc(user.email)), getPendingInvitationSummaries(workspaceId), @@ -409,8 +609,48 @@ export async function getWorkspaceMovePreflight( ? `This move is blocked: ${currentMembers} current member${currentMembers === 1 ? '' : 's'} plus ${projectedPendingInternalSeats} pending internal invitation reservation${projectedPendingInternalSeats === 1 ? '' : 's'} exceed the ${seatCapacity}-seat Enterprise capacity.` : null + const [sourceOrganization, entitlements, credentials, forkEdges, sourceImpact] = + await Promise.all([ + sourceOrganizationId ? getSourceOrganization(sourceOrganizationId) : null, + resolveMoveEntitlements(sourceOrganizationId, destinationOrganizationId), + collectWorkspaceCredentialSummary(workspaceId, sourceOrganizationId), + findCrossOrgForkEdges(workspaceId, destinationOrganizationId), + collectSourceOrganizationImpact(workspaceId, sourceOrganizationId, destinationOrganizationId), + ]) + + const boundedForkEdges = boundList(forkEdges, PREFLIGHT_LIST_LIMITS.forkEdges) + /** + * One truncation record covering every bounded list, so a partial review is + * never presented as a complete one. + */ + const droppedTotal = + (sourceImpact.truncated?.customBlocks ?? 0) + + (sourceImpact.truncated?.permissionGroups ?? 0) + + (sourceImpact.truncated?.collaboratorCaps ?? 0) + + boundedForkEdges.dropped + + credentials.truncatedCredentials + + credentials.truncatedEnvironmentVariableKeys + const mergedTruncation = + droppedTotal > 0 + ? { + customBlocks: sourceImpact.truncated?.customBlocks ?? 0, + permissionGroups: sourceImpact.truncated?.permissionGroups ?? 0, + collaboratorCaps: sourceImpact.truncated?.collaboratorCaps ?? 0, + forkEdges: boundedForkEdges.dropped, + credentials: credentials.truncatedCredentials, + environmentVariableKeys: credentials.truncatedEnvironmentVariableKeys, + } + : null + + const blockers = buildMoveBlockers({ + entitlements, + forkEdges, + pendingInvitationCount: sourceOrganizationId ? invitationRows.length : 0, + }) + return { workspace: workspaceRow, + sourceOrganization, destinationOrganization: destination, collaborators: collaboratorRows.map((row) => ({ userId: row.userId, @@ -418,12 +658,199 @@ export async function getWorkspaceMovePreflight( email: row.email, permission: row.permission, organizationMember: row.memberId !== null, + sourceOrganizationMember: row.sourceMemberId !== null, })), invitations: invitationRows.map(({ organizationId: _organizationId, ...row }) => row), + sourceOrganizationImpact: { + ...sourceImpact, + blockingForkEdges: boundedForkEdges.items, + truncated: mergedTruncation, + }, + credentials, + entitlements, + blockers, + notices: buildMoveNotices({ + sourceOrganization, + destinationOrganization: destination, + sourceImpact, + credentials, + }), warning, } } +/** + * The conditions that make a move refuse outright, in the order an admin should + * resolve them. Each is re-checked inside the move transaction — this list is + * for presentation, never for authorization. + */ +function buildMoveBlockers(params: { + entitlements: WorkspaceMoveEntitlements + forkEdges: CrossOrgForkEdge[] + pendingInvitationCount: number +}): string[] { + const blockers: string[] = [] + if (params.entitlements.capabilitiesLost.length > 0) { + blockers.push( + `The destination organization is not on Enterprise, so this workspace would lose ${formatList(params.entitlements.capabilitiesLost)}. Upgrade the destination or choose another organization.` + ) + } + if (params.forkEdges.length > 0) { + blockers.push( + `${params.forkEdges.length} fork ${params.forkEdges.length === 1 ? 'edge' : 'edges'} would span two organizations. Disconnect ${params.forkEdges.length === 1 ? 'it' : 'them'} from workspace settings before moving.` + ) + } + if (params.pendingInvitationCount > 0) { + blockers.push( + `${params.pendingInvitationCount} pending invitation${params.pendingInvitationCount === 1 ? '' : 's'} would be re-targeted at another organization. Let ${params.pendingInvitationCount === 1 ? 'it' : 'them'} be accepted or cancel ${params.pendingInvitationCount === 1 ? 'it' : 'them'} first.` + ) + } + return blockers +} + +/** Advisory consequences worth reading before confirming, but never blocking. */ +function buildMoveNotices(params: { + sourceOrganization: WorkspaceMoveSourceOrganization | null + destinationOrganization: WorkspaceMoveDestination + sourceImpact: Omit + credentials: WorkspaceMoveCredentialSummary +}): string[] { + const notices: string[] = [] + if (!params.sourceOrganization) return notices + + notices.push( + `${params.destinationOrganization.name} gains this workspace's entire audit history, and ${params.sourceOrganization.name} loses visibility of it. Organization-scoped data drains follow the same boundary.` + ) + if (params.sourceImpact.unpublishedCustomBlocks.length > 0) { + const strandedDeployments = params.sourceImpact.unpublishedCustomBlocks.reduce( + (total, block) => total + block.sourceOrgElsewhereUsage.deployed, + 0 + ) + notices.push( + `${params.sourceImpact.unpublishedCustomBlocks.length} custom block${params.sourceImpact.unpublishedCustomBlocks.length === 1 ? '' : 's'} will be unpublished from ${params.sourceOrganization.name}${strandedDeployments > 0 ? `, breaking ${strandedDeployments} deployed workflow${strandedDeployments === 1 ? '' : 's'} that stay behind` : ''}.` + ) + } + if (params.sourceImpact.truncated) { + const t = params.sourceImpact.truncated + notices.push( + `This review is incomplete — some lists were truncated to stay within response limits: ${t.customBlocks} custom block(s), ${t.permissionGroups} permission group(s), ${t.collaboratorCaps} collaborator cap(s), ${t.forkEdges} fork edge(s), ${t.credentials} credential(s) and ${t.environmentVariableKeys} environment variable(s) not shown.` + ) + } + const sourceBackedCredentials = params.credentials.items.filter( + (item) => item.backedBySourceOrgMember + ).length + if (sourceBackedCredentials > 0) { + notices.push( + `${sourceBackedCredentials} credential${sourceBackedCredentials === 1 ? '' : 's'} are backed by a ${params.sourceOrganization.name} member's identity, so ${params.destinationOrganization.name} inherits the ability to act as them.` + ) + } + const cappedCollaborators = params.sourceImpact.retainedCollaboratorCaps.filter( + (collaborator) => collaborator.sourceOrgLimitDollars !== null + ).length + if (cappedCollaborators > 0) { + notices.push( + `${cappedCollaborators} retained collaborator${cappedCollaborators === 1 ? '' : 's'} had a per-member usage cap in ${params.sourceOrganization.name} that will no longer apply. Re-apply it in ${params.destinationOrganization.name} if it should continue.` + ) + } + if (params.sourceImpact.brandingChanges) { + notices.push( + `The workspace will re-skin to ${params.destinationOrganization.name}'s branding immediately.` + ) + } + return notices +} + +/** + * Ceilings that keep a preflight response inside its contract's array bounds. + * A workspace with more rows than these is pathological, but silently emitting + * an oversized list makes `requestJson` reject the whole response on the + * client — the review surface would go blank rather than degrade. Truncate and + * say so instead; never drop rows without a notice. + */ +const PREFLIGHT_LIST_LIMITS = { + forkEdges: 500, + customBlocks: 500, + permissionGroups: 500, + collaboratorCaps: 1_000, + credentials: 1_000, + environmentVariableKeys: 1_000, +} as const + +/** Truncates to `limit`, returning what was dropped so callers can disclose it. */ +function boundList(items: T[], limit: number): { items: T[]; dropped: number } { + return items.length <= limit + ? { items, dropped: 0 } + : { items: items.slice(0, limit), dropped: items.length - limit } +} + +/** Renders a list as `a, b and c` for human-facing blocker copy. */ +function formatList(items: string[]): string { + if (items.length <= 1) return items[0] ?? '' + return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}` +} + +/** + * Gathers everything the source organization loses, excluding fork edges, which + * the caller resolves separately because they also drive a blocker. + */ +async function collectSourceOrganizationImpact( + workspaceId: string, + sourceOrganizationId: string | null, + destinationOrganizationId: string +): Promise> { + if (!sourceOrganizationId) { + return { + unpublishedCustomBlocks: [], + detachedPermissionGroups: [], + strippedRetentionRules: { piiRedactionRules: 0, retentionOverrides: 0 }, + retainedCollaboratorCaps: [], + brandingChanges: false, + truncated: null, + } + } + + const [customBlocks, permissionGroups, retentionSettings, collaboratorCaps, brandingChanges] = + await Promise.all([ + findUnpublishableCustomBlocks(workspaceId, sourceOrganizationId), + findAttachedPermissionGroups(workspaceId), + db + .select({ dataRetentionSettings: organization.dataRetentionSettings }) + .from(organization) + .where(eq(organization.id, sourceOrganizationId)) + .limit(1), + findRetainedCollaboratorCaps(workspaceId, sourceOrganizationId), + willBrandingChange(sourceOrganizationId, destinationOrganizationId), + ]) + + const boundedBlocks = boundList(customBlocks.items, PREFLIGHT_LIST_LIMITS.customBlocks) + /** Enrichment already capped the slice, so the gap comes from the true total. */ + const droppedBlocks = Math.max(customBlocks.total - boundedBlocks.items.length, 0) + const boundedGroups = boundList(permissionGroups, PREFLIGHT_LIST_LIMITS.permissionGroups) + const boundedCaps = boundList(collaboratorCaps, PREFLIGHT_LIST_LIMITS.collaboratorCaps) + + return { + unpublishedCustomBlocks: boundedBlocks.items, + detachedPermissionGroups: boundedGroups.items, + strippedRetentionRules: countRetentionRulesForWorkspace( + retentionSettings[0]?.dataRetentionSettings, + workspaceId + ), + retainedCollaboratorCaps: boundedCaps.items, + brandingChanges, + truncated: + droppedBlocks + boundedGroups.dropped + boundedCaps.dropped > 0 + ? { + customBlocks: droppedBlocks, + permissionGroups: boundedGroups.dropped, + collaboratorCaps: boundedCaps.dropped, + forkEdges: 0, + credentials: 0, + environmentVariableKeys: 0, + } + : null, + } +} + /** * Moves one workspace and migrates every pending grant. Workspace ownership, * historical usage, credentials, and collaborator permissions are preserved; @@ -446,6 +873,22 @@ export async function moveWorkspaceToOrganization(params: { params.workspaceId, params.destinationOrganizationId ) + /** + * The source organization must be locked alongside the destination, but its + * id is only knowable by reading the workspace — which happens *after* the + * locks. Read it optimistically here, then re-verify under the locks and + * retry through the existing loop when it moved underneath us. + */ + let candidateSourceOrganizationId = await readWorkspaceOrganizationId(params.workspaceId) + /** + * Resolved outside the transaction on purpose — see the entitlement check + * inside it for why. Recomputed per attempt so a retry after a source-org + * change re-evaluates against the organization actually being left. + */ + let entitlements = await resolveMoveEntitlements( + candidateSourceOrganizationId, + params.destinationOrganizationId + ) let result: MoveTransactionResult | undefined for (let attempt = 0; attempt < 5; attempt += 1) { @@ -459,7 +902,20 @@ export async function moveWorkspaceToOrganization(params: { invitationIds: candidateInvitationIds, workspaceIds: [params.workspaceId], }) - await acquireOrganizationMutationLock(tx, params.destinationOrganizationId) + /** + * Both organizations are mutated, so both are locked — ascending by id, + * mirroring `acquireOrganizationUserMutationLocks`, so two concurrent + * moves swapping a workspace between the same pair cannot deadlock. + */ + for (const organizationId of [ + ...new Set( + [candidateSourceOrganizationId, params.destinationOrganizationId].filter( + (id): id is string => id !== null + ) + ), + ].sort()) { + await acquireOrganizationMutationLock(tx, organizationId) + } const durableOperationRequest: AdminWorkspaceMoveOperationRequest = { workspaceId: params.workspaceId, @@ -531,6 +987,10 @@ export async function moveWorkspaceToOrganization(params: { 'workspace-owner-changed' ) } + if (workspaceRow.organizationId !== candidateSourceOrganizationId) { + throw new SourceOrganizationChangedError(workspaceRow.organizationId) + } + const sourceOrganizationId = workspaceRow.organizationId const moveState = classifyWorkspaceMoveState(workspaceRow, params.destinationOrganizationId) const destination = await getDestinationOrganization(params.destinationOrganizationId, tx) @@ -548,17 +1008,50 @@ export async function moveWorkspaceToOrganization(params: { 'already-organization-workspace' ) } + const recordedAudit = existingDurableOperation + ? (parseAdminWorkspaceMoveOperationPayload(existingDurableOperation.payload)?.audit ?? + null) + : null + /** + * A retry of a confirmed operation must return what the original move + * did, not a blank. The durable payload persists the source + * organization and its losses precisely so this branch can rebuild + * them — discarding it here made the retry claim the source was + * unrecoverable while the payload was sitting right there. + */ + const recordedSourceOrgId = recordedAudit?.sourceOrganizationId ?? null + const replayedSourceOrganization = recordedSourceOrgId + ? await getSourceOrganization(recordedSourceOrgId, tx) + : null return { performedMove: false, + sourceOrganizationOutcome: recordedSourceOrgId + ? { + sourceOrganizationId: recordedSourceOrgId, + unpublishedCustomBlocks: recordedAudit?.unpublishedCustomBlocks ?? [], + detachedPermissionGroupIds: recordedAudit?.detachedPermissionGroupIds ?? [], + } + : null, previousBillingOwnerId: workspaceRow.billedAccountUserId, destinationOwnerId: destination.ownerId, organizationAssignedAt: null, - durableAudit: existingDurableOperation - ? (parseAdminWorkspaceMoveOperationPayload(existingDurableOperation.payload)?.audit ?? - null) - : null, + durableAudit: recordedAudit, invitationEvents: [], - summary: await getMovedWorkspaceSummary(tx, params.workspaceId, destination), + summary: await getMovedWorkspaceSummary(tx, params.workspaceId, destination, { + sourceOrganization: replayedSourceOrganization, + sourceOrganizationImpact: EMPTY_SOURCE_IMPACT, + credentials: EMPTY_CREDENTIAL_SUMMARY, + entitlements: { + sourceIsEnterprise: false, + destinationIsEnterprise: false, + capabilitiesLost: [], + }, + notices: replayedSourceOrganization + ? [] + : [ + 'This workspace was already in the destination organization, so the organization it originally came from is no longer recoverable.', + ], + }), } satisfies MoveTransactionResult } @@ -601,6 +1094,126 @@ export async function moveWorkspaceToOrganization(params: { } } + /** + * The three org-to-org blockers, re-checked under the locks. Preflight + * evaluated them too, but a subscription can lapse, a fork can be + * created, and an invitation can arrive in between — and each of these + * either violates a cross-org invariant or silently rewrites a promise + * the source organization made. + */ + /** + * The fork check is NOT gated on an organization source. A personal + * workspace whose parent has since moved into an organization still + * produces a cross-organization edge when it lands in a different one, + * and the invariant admits no exceptions. Preflight already reports it + * unconditionally; gating it here would let the transaction accept a + * move preflight had refused. + */ + const forkEdges = await findCrossOrgForkEdges( + params.workspaceId, + params.destinationOrganizationId, + tx + ) + if (forkEdges.length > 0) { + throw new WorkspaceMoveError( + `${forkEdges.length} fork ${forkEdges.length === 1 ? 'edge' : 'edges'} would span two organizations. Disconnect the fork before moving this workspace.`, + 'fork-lineage-conflict' + ) + } + + if (sourceOrganizationId) { + /** + * Entitlements are resolved BEFORE the transaction, not here. + * `isOrganizationOnEnterprisePlan` reads through the global client + * with no executor seam, so calling it inside the transaction trips + * the transaction tripwire outside production and reserves a second + * pool connection in it. The check is a precondition, not an + * invariant: a plan lapsing in the seconds between the read and the + * commit lands the workspace in an organization that just lost its + * entitlements, which is recoverable by moving it back — unlike a + * cross-organization artifact, which is not. + */ + /** + * Evaluate BOTH organizations under the locks when entitlement is + * subscription-backed, rather than trusting the pre-transaction + * verdict. That verdict is still what preflight reports, but as a + * blocker it is stale in both directions: a destination that lapsed + * after it was read, and a source that GAINED entitlement after it + * was read, which would otherwise skip the fence entirely. + * + * `isSubscriptionBackedEntitlement` is exported from the same module + * as `resolveOrganizationEnterprisePlan`'s short-circuits, so the two + * modes where entitlement is granted by deployment configuration — + * and no `subscription` row need exist — cannot drift away from this. + */ + if (isSubscriptionBackedEntitlement()) { + const entitledRows = await tx + .select({ referenceId: subscription.referenceId, plan: subscription.plan }) + .from(subscription) + .where( + and( + inArray(subscription.referenceId, [ + sourceOrganizationId, + params.destinationOrganizationId, + ]), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + /** + * Billing-blocked organizations are NOT entitled, even holding an + * active subscription — `resolveOrganizationEnterprisePlan` returns + * false for them via `isOrganizationBillingBlocked`, so a fence + * ignoring it would call a destination entitled that every gate it + * protects treats as disabled. Blocked state lives on the + * organization owner's `user_stats`, which is what + * `getBillingEntityBlockStatus` resolves an organization to. + */ + const blockedRows = await tx + .select({ organizationId: member.organizationId }) + .from(member) + .innerJoin(userStats, eq(userStats.userId, member.userId)) + .where( + and( + inArray(member.organizationId, [ + sourceOrganizationId, + params.destinationOrganizationId, + ]), + eq(member.role, 'owner'), + eq(userStats.billingBlocked, true) + ) + ) + const blockedOrganizationIds = new Set(blockedRows.map((row) => row.organizationId)) + const entitledOrganizationIds = new Set( + entitledRows + .filter((row) => isOrgPlan(row.plan)) + .map((row) => row.referenceId) + .filter((organizationId) => !blockedOrganizationIds.has(organizationId)) + ) + if ( + entitledOrganizationIds.has(sourceOrganizationId) && + !entitledOrganizationIds.has(params.destinationOrganizationId) + ) { + throw new WorkspaceMoveError( + 'The destination organization does not hold a paid organization plan, so this workspace would lose the capabilities gated on one.', + 'destination-entitlement-downgrade' + ) + } + } else if (entitlements.capabilitiesLost.length > 0) { + throw new WorkspaceMoveError( + `The destination organization is not on Enterprise, so this workspace would lose ${entitlements.capabilitiesLost.join(', ')}`, + 'destination-entitlement-downgrade' + ) + } + + const pendingInvitations = await getPendingInvitationSummaries(params.workspaceId, tx) + if (pendingInvitations.length > 0) { + throw new WorkspaceMoveError( + `This workspace has ${pendingInvitations.length} pending invitation${pendingInvitations.length === 1 ? '' : 's'} scoped to its current organization. Let them be accepted or cancel them before moving it.`, + 'pending-invitations-present' + ) + } + } + const now = new Date() await expireLockedPendingInvitations(tx, candidateInvitationIds, now) const lockedInvitationIds = await lockCurrentPendingInvitations(tx, params.workspaceId, now) @@ -634,6 +1247,33 @@ export async function moveWorkspaceToOrganization(params: { } } + /** + * Enforce the cross-org invariants before the payer moves, while the + * source organization is still the one on the row. Unpublishing a + * custom block is the product's own `deleteCustomBlock`; the usage + * counts are captured first so the source org's audit entry can say how + * much it cost. + */ + const sourceOrganization = sourceOrganizationId + ? await getSourceOrganization(sourceOrganizationId, tx) + : null + const unpublishedCustomBlocks = sourceOrganizationId + ? await findSourceOrgCustomBlocksForWorkspace( + params.workspaceId, + sourceOrganizationId, + tx + ) + : [] + for (const block of unpublishedCustomBlocks) { + await deleteCustomBlock(block.id, tx) + } + const cleanup = sourceOrganizationId + ? await cleanupSourceOrganizationArtifactsTx(tx, { + workspaceId: params.workspaceId, + sourceOrganizationId, + }) + : { detachedPermissionGroupIds: [] } + await changeWorkspaceStoragePayerInTx(tx, { workspaceId: params.workspaceId, organizationId: params.destinationOrganizationId, @@ -664,6 +1304,9 @@ export async function moveWorkspaceToOrganization(params: { previousBillingOwnerId: workspaceRow.billedAccountUserId, newBillingOwnerId: destination.ownerId, organizationAssignedAt: now.toISOString(), + sourceOrganizationId, + unpublishedCustomBlocks, + detachedPermissionGroupIds: cleanup.detachedPermissionGroupIds, } : null if (params.durableOperationId && durableAudit) { @@ -699,7 +1342,69 @@ export async function moveWorkspaceToOrganization(params: { organizationAssignedAt: now, durableAudit, invitationEvents: migration.invitationEvents, - summary: await getMovedWorkspaceSummary(tx, params.workspaceId, destination), + sourceOrganizationOutcome: sourceOrganizationId + ? { + sourceOrganizationId, + unpublishedCustomBlocks: unpublishedCustomBlocks.map(({ id, type, name }) => ({ + id, + type, + name, + })), + detachedPermissionGroupIds: cleanup.detachedPermissionGroupIds, + } + : null, + summary: await getMovedWorkspaceSummary(tx, params.workspaceId, destination, { + sourceOrganization, + /** + * What the move actually did, not what preflight projected. The + * rest of the impact described the pre-move state and is not + * recoverable — or meaningful — once the workspace has landed. + */ + sourceOrganizationImpact: { + ...EMPTY_SOURCE_IMPACT, + /** + * Usage counts are zero here rather than measured: they describe + * how much breaks in the source organization, and the reads that + * produce them are not transaction-safe. Preflight carries the + * real numbers; this reports which blocks were unpublished. + */ + unpublishedCustomBlocks: boundList( + unpublishedCustomBlocks, + PREFLIGHT_LIST_LIMITS.customBlocks + ).items.map((block) => ({ + ...block, + movingWorkspaceUsage: { live: 0, deployed: 0 }, + sourceOrgElsewhereUsage: { live: 0, deployed: 0 }, + })), + detachedPermissionGroups: boundList( + cleanup.detachedPermissionGroupIds, + PREFLIGHT_LIST_LIMITS.permissionGroups + ).items.map((permissionGroupId) => ({ permissionGroupId, name: '' })), + /** The applied response is bounded by the same limits as preflight. */ + truncated: + unpublishedCustomBlocks.length > PREFLIGHT_LIST_LIMITS.customBlocks || + cleanup.detachedPermissionGroupIds.length > PREFLIGHT_LIST_LIMITS.permissionGroups + ? { + customBlocks: Math.max( + unpublishedCustomBlocks.length - PREFLIGHT_LIST_LIMITS.customBlocks, + 0 + ), + permissionGroups: Math.max( + cleanup.detachedPermissionGroupIds.length - + PREFLIGHT_LIST_LIMITS.permissionGroups, + 0 + ), + collaboratorCaps: 0, + forkEdges: 0, + credentials: 0, + environmentVariableKeys: 0, + } + : null, + }, + credentials: EMPTY_CREDENTIAL_SUMMARY, + entitlements, + notices: [], + }), } satisfies MoveTransactionResult }) break @@ -708,6 +1413,14 @@ export async function moveWorkspaceToOrganization(params: { candidateInvitationIds = error.invitationIds continue } + if (error instanceof SourceOrganizationChangedError) { + candidateSourceOrganizationId = error.organizationId + entitlements = await resolveMoveEntitlements( + candidateSourceOrganizationId, + params.destinationOrganizationId + ) + continue + } if (isConcurrentPendingInvitationInsert(error)) { candidateInvitationIds = await findInvitationMigrationLockIds( params.workspaceId, @@ -740,6 +1453,24 @@ export async function moveWorkspaceToOrganization(params: { recovered: true, }) } + /** + * Replay the source organization's loss audit on this path too. The write + * is fire-and-forget after commit, so the retry that reaches this branch is + * often the one recovering from a process that died before it landed. + * `recordAuditOnce` keys make it a no-op when it already did. + */ + if (result.sourceOrganizationOutcome) { + await recordSourceOrganizationMoveAudit({ + workspaceId: params.workspaceId, + sourceOrganizationId: result.sourceOrganizationOutcome.sourceOrganizationId, + destinationOrganizationId: params.destinationOrganizationId, + adminEmail: params.adminEmail, + auditActor: params.auditActor, + auditOperationId: params.auditOperationId, + unpublishedCustomBlocks: result.sourceOrganizationOutcome.unpublishedCustomBlocks, + detachedPermissionGroupIds: result.sourceOrganizationOutcome.detachedPermissionGroupIds, + }) + } logger.info('Workspace was already in destination organization', { workspaceId: params.workspaceId, destinationOrganizationId: params.destinationOrganizationId, @@ -749,6 +1480,19 @@ export async function moveWorkspaceToOrganization(params: { invalidateWorkspaceTableLimitsCache(params.workspaceId) + if (result.sourceOrganizationOutcome) { + await recordSourceOrganizationMoveAudit({ + workspaceId: params.workspaceId, + sourceOrganizationId: result.sourceOrganizationOutcome.sourceOrganizationId, + destinationOrganizationId: params.destinationOrganizationId, + adminEmail: params.adminEmail, + auditActor: params.auditActor, + auditOperationId: params.auditOperationId, + unpublishedCustomBlocks: result.sourceOrganizationOutcome.unpublishedCustomBlocks, + detachedPermissionGroupIds: result.sourceOrganizationOutcome.detachedPermissionGroupIds, + }) + } + if (params.auditOperationId && result.durableAudit) { await recordDurableWorkspaceMoveAudit( params.auditOperationId, @@ -836,6 +1580,86 @@ async function recordWorkspaceMoveAudit({ } } +/** + * Records what the source organization lost, in the source organization's own + * audit view. + * + * The workspace-scoped move entry above is visible only to the *destination* + * after the move — `buildOrgScopeCondition` scopes org audit reads by the + * organization's current workspaces — so without this the organization that + * lost the workspace has no record of it at all. `workspaceId: null` plus + * `metadata.organizationId` is that condition's org-level branch, which + * resolves to the source and nowhere else. + */ +async function recordSourceOrganizationMoveAudit(params: { + workspaceId: string + sourceOrganizationId: string + destinationOrganizationId: string + adminEmail: string + auditActor?: { id: string | null; name: string; email: string | null } + auditOperationId?: string + unpublishedCustomBlocks: Array<{ id: string; type: string; name: string }> + detachedPermissionGroupIds: string[] +}): Promise { + const actor = { + actorId: params.auditActor ? params.auditActor.id : null, + actorName: params.auditActor?.name ?? 'Admin Panel', + actorEmail: params.auditActor?.email ?? params.adminEmail, + } + + const moveOut = { + workspaceId: null, + ...actor, + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: params.sourceOrganizationId, + description: 'Workspace moved out of this organization', + metadata: { + organizationId: params.sourceOrganizationId, + workspaceId: params.workspaceId, + destinationOrganizationId: params.destinationOrganizationId, + unpublishedCustomBlockIds: params.unpublishedCustomBlocks.map((block) => block.id), + detachedPermissionGroupIds: params.detachedPermissionGroupIds, + }, + } as const + + if (params.auditOperationId) { + await recordAuditOnce( + `${params.auditOperationId}:workspace-move-source:${params.workspaceId}`, + moveOut + ) + } else { + recordAudit(moveOut) + } + + for (const block of params.unpublishedCustomBlocks) { + const unpublished = { + workspaceId: null, + ...actor, + action: AuditAction.CUSTOM_BLOCK_DELETED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: block.id, + resourceName: block.name, + description: `Unpublished custom block "${block.name}"`, + metadata: { + organizationId: params.sourceOrganizationId, + type: block.type, + reason: 'workspace-moved-to-another-organization', + workspaceId: params.workspaceId, + destinationOrganizationId: params.destinationOrganizationId, + }, + } as const + if (params.auditOperationId) { + await recordAuditOnce( + `${params.auditOperationId}:custom-block-unpublished:${block.id}`, + unpublished + ) + } else { + recordAudit(unpublished) + } + } +} + async function recordDurableWorkspaceMoveAudit( operationId: string, workspaceId: string, @@ -986,8 +1810,52 @@ export async function getWorkspaceMoveOperation( destinationOrganizationId, operationPayload.audit ) + /** + * Reconstruct the source organization from the durable payload. The payer + * transfer already overwrote `workspace.organizationId`, so the row cannot + * supply it — and this reload is the path the admin UI takes after a lost + * response, which is exactly when the operator most needs to see what the + * move did and where it came from. + */ + const recordedSourceOrganizationId = operationPayload.audit.sourceOrganizationId ?? null + const sourceOrganization = recordedSourceOrganizationId + ? await getSourceOrganization(recordedSourceOrganizationId) + : null + + /** + * Replay the source organization's loss audit. `recordAuditOnce` keys make it + * idempotent, so this is a no-op when the original write landed and a repair + * when the process died between commit and that fire-and-forget write. + */ + if (recordedSourceOrganizationId) { + await recordSourceOrganizationMoveAudit({ + workspaceId, + sourceOrganizationId: recordedSourceOrganizationId, + destinationOrganizationId, + adminEmail: operationPayload.audit.actor.email ?? 'admin-api@sim.ai', + auditActor: operationPayload.audit.actor, + auditOperationId: operationId, + unpublishedCustomBlocks: operationPayload.audit.unpublishedCustomBlocks ?? [], + detachedPermissionGroupIds: operationPayload.audit.detachedPermissionGroupIds ?? [], + }) + } + return toWorkspaceMoveOperationView( - await getMovedWorkspaceSummary(db, workspaceId, destination), + await getMovedWorkspaceSummary(db, workspaceId, destination, { + sourceOrganization, + sourceOrganizationImpact: EMPTY_SOURCE_IMPACT, + credentials: EMPTY_CREDENTIAL_SUMMARY, + entitlements: { + sourceIsEnterprise: false, + destinationIsEnterprise: false, + capabilitiesLost: [], + }, + notices: recordedSourceOrganizationId + ? [] + : [ + 'This move was recorded before the source organization was persisted, so it cannot be reported.', + ], + }), operationId ) } @@ -1074,11 +1942,13 @@ async function searchWorkspaceById(workspaceId: string): Promise, } as const +/** + * Source-org context captured BEFORE the payer transfer rewrites + * `workspace.organizationId`. Without it the post-move summary cannot name the + * organization the workspace came from, because the row no longer records it. + */ +interface AppliedMoveContext { + sourceOrganization: WorkspaceMoveSourceOrganization | null + sourceOrganizationImpact: WorkspaceMoveSourceImpact + credentials: WorkspaceMoveCredentialSummary + entitlements: WorkspaceMoveEntitlements + notices: string[] +} + async function getMovedWorkspaceSummary( executor: DbOrTx, workspaceId: string, - destination: WorkspaceMoveDestination + destination: WorkspaceMoveDestination, + appliedContext?: AppliedMoveContext ): Promise { const [movedRow] = await executor .select({ @@ -1736,11 +2635,13 @@ async function getMovedWorkspaceSummary( ownerEmail: user.email, workspaceMode: workspace.workspaceMode, organizationId: workspace.organizationId, + organizationName: organization.name, billedAccountUserId: workspace.billedAccountUserId, archivedAt: workspace.archivedAt, }) .from(workspace) .innerJoin(user, eq(user.id, workspace.ownerId)) + .leftJoin(organization, eq(organization.id, workspace.organizationId)) .where(eq(workspace.id, workspaceId)) .limit(1) if (!movedRow) { @@ -1759,6 +2660,7 @@ async function getMovedWorkspaceSummary( email: user.email, permission: permissions.permissionType, memberId: member.id, + sourceMemberId: sourceMember.id, }) .from(permissions) .innerJoin(user, eq(user.id, permissions.userId)) @@ -1766,10 +2668,20 @@ async function getMovedWorkspaceSummary( member, and(eq(member.userId, permissions.userId), eq(member.organizationId, destination.id)) ) + .leftJoin( + sourceMember, + and( + eq(sourceMember.userId, permissions.userId), + appliedContext?.sourceOrganization + ? eq(sourceMember.organizationId, appliedContext.sourceOrganization.id) + : sql`false` + ) + ) .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) return { workspace: workspaceRow, + sourceOrganization: appliedContext?.sourceOrganization ?? null, destinationOrganization: destination, collaborators: collaboratorRows.map((row) => ({ userId: row.userId, @@ -1777,10 +2689,54 @@ async function getMovedWorkspaceSummary( email: row.email, permission: row.permission, organizationMember: row.memberId !== null, + sourceOrganizationMember: row.sourceMemberId !== null, })), invitations: (await getPendingInvitationSummaries(workspaceId, executor)).map( ({ organizationId: _organizationId, ...row }) => row ), + sourceOrganizationImpact: appliedContext?.sourceOrganizationImpact ?? EMPTY_SOURCE_IMPACT, + credentials: appliedContext?.credentials ?? EMPTY_CREDENTIAL_SUMMARY, + entitlements: appliedContext?.entitlements ?? { + sourceIsEnterprise: false, + destinationIsEnterprise: false, + capabilitiesLost: [], + }, + blockers: [], + notices: appliedContext?.notices ?? [], warning: null, } } + +/** A move that is already applied has no source-org context to report. */ +const EMPTY_SOURCE_IMPACT: WorkspaceMoveSourceImpact = { + unpublishedCustomBlocks: [], + blockingForkEdges: [], + detachedPermissionGroups: [], + strippedRetentionRules: { piiRedactionRules: 0, retentionOverrides: 0 }, + retainedCollaboratorCaps: [], + brandingChanges: false, + truncated: null, +} + +const EMPTY_CREDENTIAL_SUMMARY: WorkspaceMoveCredentialSummary = { + items: [], + credentialGroupCount: 0, + environmentVariableKeys: [], + byokKeyCount: 0, + truncatedCredentials: 0, + truncatedEnvironmentVariableKeys: 0, +} + +/** + * The workspace's current organization, read outside the move transaction so + * both organizations can be locked in a deterministic order. Always re-verified + * under the locks — see {@link SourceOrganizationChangedError}. + */ +async function readWorkspaceOrganizationId(workspaceId: string): Promise { + const [row] = await db + .select({ organizationId: workspace.organizationId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + return row?.organizationId ?? null +}