Skip to content

fix(workspaces): auto-provision a replacement workspace instead of blocking deletion#5494

Open
waleedlatif1 wants to merge 12 commits into
stagingfrom
fix-workspace-delete-strand-members
Open

fix(workspaces): auto-provision a replacement workspace instead of blocking deletion#5494
waleedlatif1 wants to merge 12 commits into
stagingfrom
fix-workspace-delete-strand-members

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • The workspace DELETE route only checked the deleter's own workspace count before allowing deletion, not the workspace actually being deleted. An org admin (implicit admin access to every org workspace) could delete another member's only workspace and leave them with zero workspaces, with no reliable recovery (the pre-existing "no workspaces" client-side fallback only covers the generic /workspace redirect page).
  • archiveWorkspace never blocks deletion. Instead, when opted in via provisionFallbackForStrandedMembers (set by the interactive DELETE route only), it auto-provisions a personal fallback workspace, atomically in the same serializable transaction, for any member who would otherwise be left with zero active workspaces. This is off by default — a future caller of archiveWorkspace that doesn't know about this feature gets the safe, side-effect-free default rather than having to remember to opt out.
  • The stranded-member check correctly accounts for org-admin-derived access (reuses listAccessibleWorkspaceRowsForUser), so an org admin is never wrongly flagged just because their only explicit permission row was on the workspace being deleted.
  • Records a WORKSPACE_CREATED audit entry (attributed to the deleting admin) for each auto-provisioned fallback workspace, alongside provisionedWorkspaceUserIds metadata on the deletion's own audit entry.
  • Extracted the workspace-creation write into a shared lib/workspaces/create.ts (createWorkspaceRecord, parameterized by an optional executor) so POST /api/workspaces and the archival safety net share one implementation; app/api/workspaces/route.ts's CreateWorkspaceParams is now Omit<CreateWorkspaceRecordParams, 'executor'> instead of a hand-copied duplicate.
  • disableUserResources (ban flow) needs no changes at all — the default (no opt-in) already matches its required behavior: a banned user's owned workspaces are fully archived regardless of other members' workspace counts.
  • Ran a 4-angle /simplify review (reuse / simplification / efficiency / altitude) against the diff and applied every actionable finding: returned the provisioned-users list from the transaction instead of mutating an outer let, deduplicated the CreateWorkspaceParams interface, inverted the stranded-handling flag to opt-in (resolving the "surprising side effect for future callers" altitude concern without reintroducing the TOCTOU race a literal function-split would cause), and renamed a colliding test helper. The efficiency pass confirmed the per-member loop and single-transaction sequential writes are correct as written (parallelizing statements on one DB connection wouldn't help and risks write-ordering bugs).

Type of Change

  • Bug fix

Testing

  • Unit tests for archiveWorkspace: opt-in provisioning for a stranded member (still archives), only the actually-stranded member in a mixed group, org-admin not falsely stranded, no provisioning when everyone's safe, audit entry recorded/skipped correctly, default-off behavior for the ban flow, serializable isolation asserted
  • Tests for the extracted createWorkspaceRecord (own transaction vs. provided executor, skip-default-workflow, billed-account permission row)
  • Route tests for DELETE /api/workspaces/[id] covering 401/403/404/200 (including provisioned-user audit metadata)/500
  • Full tsc --noEmit, biome check, and bun run check:api-validation all pass
  • 546 tests passing across app/api/workspaces, lib/workspaces, lib/workflows, and every other caller of the touched shared helpers

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 9, 2026 3:13am

Request Review

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core workspace archival, membership access checks, and automatic workspace creation under serializable transactions; mistakes could strand users or create duplicate fallbacks, though behavior is opt-in and heavily tested.

Overview
Workspace deletion no longer blocks when the deleter would keep at least one workspace. The DELETE route drops the prior check on the caller’s membership count and always archives the target workspace when the caller is an admin, opting into provisionFallbackForStrandedMembers on archiveWorkspace.

archiveWorkspace gains an opt-in safety net: when that flag is set, it finds members (including org admins without explicit permission rows) who would have zero active workspaces after archival, skips actively banned users, and creates a personal fallback workspace in the same serializable transaction before archival writes. Audit and workspaceCreated telemetry for fallbacks run only after commit; the route records provisionedWorkspaceUserIds on the deletion audit entry.

Workspace creation is centralized in createWorkspaceRecord (optional executor for nested transactions), used by POST /api/workspaces and the archival fallback path. listAccessibleWorkspaceRowsForUser, getOrgAdminWorkspaceRows, and getActivelyBannedUserIds accept an optional transaction executor so stranded checks stay atomic. disableUserResources also opts into fallback provisioning so non-banned co-members are not stranded when a banned owner’s workspaces are archived.

Broad unit/route test coverage was added for these flows.

Reviewed by Cursor Bugbot for commit c6be4cf. Configure here.

Comment thread apps/sim/lib/workspaces/lifecycle.ts Outdated
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug in the workspace DELETE route where the pre-deletion check only inspected the deleter's own workspace count rather than accounting for all members of the workspace being deleted, allowing an org admin to strand other members with zero workspaces. The fix shifts from blocking deletion to auto-provisioning a personal fallback workspace for any member who would otherwise be left with zero active workspaces, atomically inside a serializable transaction that prevents the concurrent-deletion write-skew the original approach was vulnerable to.

  • archiveWorkspace gains provisionFallbackForStrandedMembers (opt-in), a findMembersStrandedByArchival helper that covers both explicit members and org-admin-derived access, and a serializable transaction to guard against concurrent-deletion races; post-commit audit and telemetry are fired only after the transaction commits to avoid phantom records on rollback.
  • createWorkspaceRecord is extracted into a new lib/workspaces/create.ts shared by the POST route and the archival safety net, eliminating the hand-copied duplicate and accepting an optional executor for atomic composition.
  • disableUserResources now opts into provisionFallbackForStrandedMembers: true to protect non-banned co-members of a banned user's workspaces, but runs the calls concurrently via Promise.all, introducing a risk of PostgreSQL serialization failures when the banned user owns multiple workspaces sharing a stranded member.

Confidence Score: 4/5

Safe to merge for the interactive deletion path; the ban flow has a latent failure mode that can leave workspace resources partially unarchived when a banned user owns multiple workspaces.

The core bug fix (stranded-member auto-provisioning under serializable isolation) is well-designed and the previously flagged issues are resolved. The one remaining defect is in disableUserResources: it now passes provisionFallbackForStrandedMembers: true to concurrent archiveWorkspace calls via Promise.all. When a banned user owns two or more workspaces that share a non-banned co-member with no other workspaces, the concurrent serializable transactions read each other's workspace rows during the stranded check, forming a read-write anti-dependency cycle that PostgreSQL SSI detects and resolves by aborting one transaction. With no retry logic, the entire ban-resource-cleanup throws, leaving some workspaces unarchived while the account ban itself remains applied.

apps/sim/lib/workflows/lifecycle.ts — the disableUserResources function runs concurrent serializable archiveWorkspace calls that can abort each other; switching to sequential execution would eliminate the conflict.

Important Files Changed

Filename Overview
apps/sim/lib/workspaces/lifecycle.ts Core change: adds findMembersStrandedByArchival and auto-provisioning of fallback workspaces inside the serializable archival transaction. Logic is well-structured; TOCTOU and early-return bugs from previous review threads are resolved.
apps/sim/lib/workflows/lifecycle.ts disableUserResources now opts into provisionFallbackForStrandedMembers, introducing concurrent serializable transactions via Promise.all that can abort each other via PostgreSQL SSI when a banned user owns multiple workspaces sharing a stranded member.
apps/sim/lib/workspaces/create.ts New shared workspace-creation primitive; correctly defers telemetry when an executor is provided, handles own transaction otherwise, and inserts workspace + permissions + optional default workflow atomically.
apps/sim/app/api/workspaces/[id]/route.ts Removes the incorrect deleter-scoped workspace-count guard; delegates stranded-member protection to archiveWorkspace with provisionFallbackForStrandedMembers; propagates provisionedWorkspaceUserIds to the audit entry.
apps/sim/app/api/workspaces/route.ts createWorkspace now delegates to the shared createWorkspaceRecord; CreateWorkspaceParams becomes an Omit alias, eliminating the hand-copied duplicate.
apps/sim/lib/workspaces/utils.ts getOrgAdminWorkspaceRows and listAccessibleWorkspaceRowsForUser gain an optional executor parameter so they can run inside the archival transaction under serializable isolation.
apps/sim/lib/auth/ban.ts getActivelyBannedUserIds gains an optional executor parameter; minimal change, correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Admin
    participant DELETE_Route as DELETE /api/workspaces/[id]
    participant archiveWorkspace
    participant findStranded as findMembersStrandedByArchival
    participant createWorkspaceRecord
    participant DB as PostgreSQL (SERIALIZABLE tx)
    participant Audit

    Admin->>DELETE_Route: "DELETE /api/workspaces/{id}"
    DELETE_Route->>archiveWorkspace: "archiveWorkspace(id, {provisionFallbackForStrandedMembers: true, actorId})"
    archiveWorkspace->>DB: BEGIN SERIALIZABLE
    DB->>findStranded: SELECT explicit members + org admins
    findStranded->>DB: listAccessibleWorkspaceRowsForUser (per candidate)
    findStranded-->>archiveWorkspace: strandedUserIds[]
    loop for each stranded user
        archiveWorkspace->>createWorkspaceRecord: "createWorkspaceRecord({userId, executor: tx})"
        createWorkspaceRecord->>DB: INSERT workspace + permissions + workflow
    end
    archiveWorkspace->>DB: UPDATE/DELETE workspace resources (KB, files, keys, schedules...)
    archiveWorkspace->>DB: UPDATE workspace SET archivedAt
    DB-->>archiveWorkspace: COMMIT → provisionedFallbacks[]
    archiveWorkspace->>Audit: recordAudit WORKSPACE_CREATED (per fallback)
    archiveWorkspace-->>DELETE_Route: "{archived: true, provisionedWorkspaceUserIds}"
    DELETE_Route->>Audit: recordAudit WORKSPACE_DELETED (+provisionedWorkspaceUserIds metadata)
    DELETE_Route-->>Admin: 200 OK
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Admin
    participant DELETE_Route as DELETE /api/workspaces/[id]
    participant archiveWorkspace
    participant findStranded as findMembersStrandedByArchival
    participant createWorkspaceRecord
    participant DB as PostgreSQL (SERIALIZABLE tx)
    participant Audit

    Admin->>DELETE_Route: "DELETE /api/workspaces/{id}"
    DELETE_Route->>archiveWorkspace: "archiveWorkspace(id, {provisionFallbackForStrandedMembers: true, actorId})"
    archiveWorkspace->>DB: BEGIN SERIALIZABLE
    DB->>findStranded: SELECT explicit members + org admins
    findStranded->>DB: listAccessibleWorkspaceRowsForUser (per candidate)
    findStranded-->>archiveWorkspace: strandedUserIds[]
    loop for each stranded user
        archiveWorkspace->>createWorkspaceRecord: "createWorkspaceRecord({userId, executor: tx})"
        createWorkspaceRecord->>DB: INSERT workspace + permissions + workflow
    end
    archiveWorkspace->>DB: UPDATE/DELETE workspace resources (KB, files, keys, schedules...)
    archiveWorkspace->>DB: UPDATE workspace SET archivedAt
    DB-->>archiveWorkspace: COMMIT → provisionedFallbacks[]
    archiveWorkspace->>Audit: recordAudit WORKSPACE_CREATED (per fallback)
    archiveWorkspace-->>DELETE_Route: "{archived: true, provisionedWorkspaceUserIds}"
    DELETE_Route->>Audit: recordAudit WORKSPACE_DELETED (+provisionedWorkspaceUserIds metadata)
    DELETE_Route-->>Admin: 200 OK
Loading

Reviews (8): Last reviewed commit: "fix(workspaces): protect non-banned co-m..." | Re-trigger Greptile

Comment thread apps/sim/lib/workspaces/lifecycle.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b075b9f. Configure here.

@waleedlatif1 waleedlatif1 changed the title fix(workspaces): prevent deleting a workspace that strands other members fix(workspaces): auto-provision a replacement workspace instead of blocking deletion Jul 8, 2026
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 96b1e68. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/workspaces/lifecycle.ts Outdated
Comment thread apps/sim/lib/workspaces/lifecycle.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b08ecbb. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/workspaces/lifecycle.ts
Comment thread apps/sim/lib/workspaces/lifecycle.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit ca07155. Configure here.

The workspace DELETE route only checked the deleter's own workspace
count before allowing deletion, not the workspace being deleted. An
org admin with implicit admin access to every org workspace could
delete another member's only workspace, leaving them with zero
workspaces.

archiveWorkspace now checks, inside the same transaction as the
archive, whether any member of the workspace being deleted would be
left with zero active workspaces, and blocks if so. The ban flow
(disableUserResources) opts out via force: true since it must fully
disable a banned user's workspaces regardless of other members.
…te guard

Address two review findings on the stranded-member check:

- The check only counted a member's explicit permission rows, so an
  org admin whose only explicit row was on the workspace being deleted
  could be wrongly reported as stranded even though they still have
  access to every other workspace in their org. Now reuses
  listAccessibleWorkspaceRowsForUser (explicit rows + org-admin-derived
  access) instead of a raw permission count, parameterized with an
  executor so it can run against the same tx.

- Two concurrent deletions of different workspaces shared by the same
  sole member could each read a pre-deletion count, both conclude the
  member isn't stranded, and together leave them with zero workspaces.
  The archive transaction now runs under serializable isolation (skipped
  when force is set, since that path never runs the check) so Postgres
  detects the write skew and aborts one transaction instead.
…ocking deletion

Blocking deletion when it would strand a member was the wrong UX: an
org admin doing legitimate cleanup would be stopped because some
unrelated, possibly-inactive member happened to have this as their
only workspace.

archiveWorkspace now always allows the deletion and instead
auto-provisions a personal fallback workspace, atomically in the same
transaction, for any member who would otherwise be left with zero
active workspaces. This guarantees the invariant holds regardless of
which route/link/client the affected member's next request comes
through, unlike the pre-existing client-side "no workspaces" recovery
which only covers the generic /workspace redirect page.

Extracted the workspace-creation write (insert workspace + owner
permission row + default workflow) out of the POST /api/workspaces
route into a shared lib/workspaces/create.ts so both the route and
the archival safety net use the same logic, parameterized by an
optional executor so it can run inside an existing transaction.

The ban flow (disableUserResources) keeps force: true, now meaning
"skip stranded-member handling entirely" — a banned user's owned
workspaces must still be fully disabled, and the banned user
specifically shouldn't be handed a fresh workspace as a side effect.
…workspaces

Normal workspace creation gets its own WORKSPACE_CREATED audit row;
the auto-provisioned fallback for a stranded member didn't, making it
only discoverable indirectly via the deletion's audit metadata.

archiveWorkspace now accepts an optional actor (from the deleting
admin's session) and records a WORKSPACE_CREATED audit entry per
fallback workspace it provisions, attributing the action to the admin
who triggered the deletion.
…ioning to opt-in

Per multi-angle simplify/altitude review of the auto-provisioning fix:

- archiveWorkspace's stranded-member handling is now opt-in
  (provisionFallbackForStrandedMembers, default off) instead of opt-out
  (force). A future caller of archiveWorkspace that doesn't know about
  this feature now gets the safe default (no side-effect workspace
  creation) rather than having to remember to pass force: true. Kept
  the logic inside archiveWorkspace's single transaction rather than
  splitting into two composable functions, since that would either
  reintroduce the TOCTOU race (separate transactions) or require
  threading an external tx through archiveWorkspace (bigger, riskier
  change for uncertain benefit).
- provisionedWorkspaceUserIds is now returned from the transaction
  callback instead of mutated via an outer `let`.
- Deduplicated CreateWorkspaceParams in app/api/workspaces/route.ts —
  now Omit<CreateWorkspaceRecordParams, 'executor'> instead of a
  hand-copied interface that could drift from the shared type.
- Added a comment at the createWorkspaceRecord call site in
  archiveWorkspace noting it intentionally bypasses
  getWorkspaceCreationPolicy (system-provisioned safety net, not user
  self-service).
- Renamed a colliding local test helper (createTx used for two
  different mock shapes in adjacent lib/workspaces test files).

disableUserResources (ban flow) no longer needs a flag at all — the
default now matches its existing behavior.
The previous commit's `if (!options.provisionFallbackForStrandedMembers) { return [] }`
returned from the ENTIRE transaction callback, not just the stranded-member
check — silently skipping every archival write (workspace.archivedAt,
apiKey deletion, mcpServers, workflowSchedule, etc.) whenever the flag
wasn't set. That's every caller except the interactive DELETE route,
including the ban flow: banning a user would report `{ archived: true }`
while leaving the workspace and all its resources fully live.

Also moves the audit-log call for auto-provisioned fallback workspaces
outside the transaction. recordAudit is fire-and-forget and doesn't
participate in the transaction, so recording it before the transaction
commits could leave a phantom audit entry pointing at a fallback
workspace that got rolled back (e.g. on a serialization failure).

Added a test asserting archival writes still run when the flag is off
(the exact gap that let the regression through undetected), and a test
proving no audit entry is recorded when the transaction subsequently
fails.

Both caught by Greptile review before merge.
…s in stranding check

Two gaps found by Cursor review on the latest commit:

- findMembersStrandedByArchival only considered users with an explicit
  permissions row on the workspace being deleted. An org admin who
  accesses that workspace purely through their organization role (no
  permission row at all) was never evaluated as a stranding candidate,
  so they could be left with zero workspaces without getting a
  fallback. Now unions explicit permission holders with the
  organization's admins/owners (via the member table + ORG_ADMIN_ROLES)
  before running the same accessibility check on every candidate.

- A banned user who still holds a permissions row on someone else's
  workspace (they don't have to own it) would get a fresh fallback
  workspace if stranded by that workspace's deletion — even though
  banned users should never receive new resources. Now filters
  actively-banned userIds (via the existing getActivelyBannedUserIds
  helper, which also covers blocked emails/domains) out of the
  stranded list before provisioning.

Added tests for both: an org admin with no explicit permission row
gets provisioned when stranded, and an actively banned stranded member
does not get provisioned (and gets no audit entry).
…nnection mid-transaction

createWorkspaceRecord now only fires workspaceCreated telemetry when it commits
its own transaction; callers passing a nested executor (the fallback-provisioning
path) fire it themselves after their outer transaction commits, avoiding a phantom
event for a workspace that gets rolled back. getActivelyBannedUserIds now accepts
an executor so the stranded-member check runs the banned-user lookup against the
open transaction instead of borrowing a second pooled connection while the first
sits idle-in-transaction.
…ban flow; trim redundant comments

disableUserResources now opts into provisionFallbackForStrandedMembers.
Actively banned users are already excluded from receiving a fallback
(findMembersStrandedByArchival filters them out), so this only protects a
non-banned co-member of a banned owner's workspace from the same stranding
bug this PR fixes for interactive deletion — previously they had no safety
net at all.

Also trims several inline comments that only restated what the adjacent
assertion or test title already made clear, splitting a couple of
telemetry assertions into their own descriptively-named tests instead of
narrating them inline.
@waleedlatif1 waleedlatif1 force-pushed the fix-workspace-delete-strand-members branch from ca07155 to c6be4cf Compare July 9, 2026 03:13
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c6be4cf. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant