Skip to content

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

Open
waleedlatif1 wants to merge 10 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 10 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)

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.
@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 8, 2026 1:46am

Request Review

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes workspace deletion, permissions/stranding logic, and transactional provisioning with serializable isolation—important for data integrity but well-tested and gated behind an explicit opt-in on the DELETE route.

Overview
Workspace deletion no longer refuses deletes when the deleter would be left with zero workspaces. DELETE /api/workspaces/[id] always archives (with admin auth) and opts into provisionFallbackForStrandedMembers, passing actor details for audit.

archiveWorkspace can, when that flag is set, detect members (including org admins without explicit permission rows) who would have no other active workspace, then auto-create a personal fallback in the same serializable transaction as the archive—skipping banned users and recording WORKSPACE_CREATED audits only after commit. The flag defaults off so ban/disable flows still archive without giving users new workspaces.

Workspace creation DB logic moves to shared createWorkspaceRecord (optional transaction executor); POST /api/workspaces delegates to it. listAccessibleWorkspaceRowsForUser accepts an optional executor for transactional stranded checks. Deletion audit metadata can include provisionedWorkspaceUserIds.

Reviewed by Cursor Bugbot for commit ca07155. 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 workspace-deletion bug where an org admin deleting a member's only workspace would leave that member with zero workspaces and no recovery path. Instead of blocking deletion, the fix auto-provisions a personal fallback workspace—atomically within the same serializable transaction—for any member who would otherwise be stranded.

  • archiveWorkspace gains an opt-in provisionFallbackForStrandedMembers flag; when set, it runs under SERIALIZABLE isolation to prevent TOCTOU write-skew, finds all candidates (explicit members + org admins), filters out banned users, and creates fallback workspaces using the new shared createWorkspaceRecord primitive.
  • The DELETE route removes the old broken deleter-only workspace-count check and enables the provisioning flag; createWorkspaceRecord is extracted to deduplicate the workspace-creation logic shared with POST /api/workspaces.

Confidence Score: 5/5

Safe to merge. The archival path is well-protected: provisioning runs atomically inside a serializable transaction, audit logging is correctly deferred to post-commit, and the ban-flow default remains unchanged.

The core fix is architecturally sound and the two previously identified issues (TOCTOU race, early-return skipping archival writes) are both addressed and regression-tested. No new correctness or security issues were found in the current state of the diff.

No files require special attention.

Important Files Changed

Filename Overview
apps/sim/lib/workspaces/lifecycle.ts Core change: adds findMembersStrandedByArchival and auto-provisioning logic inside a serializable transaction. Archival writes are unconditional; fallback provisioning is gated on the opt-in flag. TOCTOU race addressed via serializable isolation. Audit logging correctly deferred to post-commit.
apps/sim/app/api/workspaces/[id]/route.ts Removes the broken deleter-only workspace-count check and replaces it with provisionFallbackForStrandedMembers: true. 404 is now returned when archived: false regardless of workspace existence, consistent with the old conditional logic.
apps/sim/lib/workspaces/create.ts New shared workspace-creation primitive. Correctly accepts an optional executor to support participating in an outer transaction. Preserves all insert logic from the original route implementation.
apps/sim/app/api/workspaces/route.ts createWorkspace refactored to delegate to createWorkspaceRecord. Interface deduplicated via Omit. Return value spread from record matches all previously hand-copied fields.
apps/sim/lib/workspaces/utils.ts getOrgAdminWorkspaceRows and listAccessibleWorkspaceRowsForUser gain an optional executor parameter (defaulting to db), allowing them to be called from within an ongoing transaction for consistent reads.
apps/sim/lib/workspaces/lifecycle.test.ts Comprehensive test coverage: stranded-member detection, org-admin handling, banned-user exclusion, transaction isolation assertion, and regression test confirming archival writes still run when the flag is off.
apps/sim/lib/workspaces/create.test.ts New tests for createWorkspaceRecord covering own-transaction, provided-executor, skipDefaultWorkflow, and billed-account permission row paths.
apps/sim/app/api/workspaces/[id]/route.test.ts New route-level tests covering 401/403/404/200/500 paths and verifying provisionedWorkspaceUserIds metadata is included in the audit entry.
apps/sim/lib/workflows/lifecycle.test.ts Added disableUserResources test asserting the ban flow never opts into fallback provisioning, plus mock additions for delete and archiveWorkspace.

Reviews (7): Last reviewed commit: "fix(workspaces): exclude banned users an..." | Re-trigger Greptile

Comment thread apps/sim/lib/workspaces/lifecycle.ts
…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.
@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.

…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.
@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.

…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.
@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
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.
@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
…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).
@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.

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