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 +}