Skip to content

bugfix-batch-0139: Prevent removing the final organization owner#2525

Open
elie222 wants to merge 1 commit into
mainfrom
cursor/org-owner-removal-race-0139
Open

bugfix-batch-0139: Prevent removing the final organization owner#2525
elie222 wants to merge 1 commit into
mainfrom
cursor/org-owner-removal-race-0139

Conversation

@elie222
Copy link
Copy Markdown
Owner

@elie222 elie222 commented May 5, 2026

Summary

  • Replace the owner count/delete race with a conditional deleteMany guard.
  • Verify the delete actually happened and preserve the last-owner invariant under contention.
  • Add organization action regression coverage.

Verification

  • pnpm install
  • pnpm test --run utils/actions/organization.test.ts
  • pnpm --filter inbox-zero-ai lint -- utils/actions/organization.ts utils/actions/organization.test.ts (passed with one unrelated existing warning)
  • git diff --check

Batch marker: bugfix-batch-0139

Open in Web Open in Cursor 

@elie222 elie222 added the bug Something isn't working label May 5, 2026 — with Cursor
@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
inbox-zero Ignored Ignored May 5, 2026 0:10am

@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@elie222 elie222 marked this pull request as ready for review May 5, 2026 12:39
if (targetMember.role === "owner") {
const ownerCount = await prisma.member.count({
where: { organizationId: targetMember.organizationId, role: "owner" },
const deletedMember = await prisma.member.deleteMany({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Residual TOCTOU in owner-removal guard can eliminate all org owners under READ COMMITTED

Prisma's deleteMany translates to a single DELETE … WHERE id = $1 AND EXISTS (SELECT … role = 'owner' AND id != $1) statement. Under PostgreSQL's default READ COMMITTED isolation, each statement sees a snapshot taken at statement start, before any concurrent transaction has committed. If two owners each concurrently submit a request to remove the other (e.g., simultaneous clicks, or a deliberate script), both DELETE statements can evaluate their "another owner exists" subquery against the same pre-delete snapshot, both find "yes another owner exists," and both succeed — leaving the organization with zero owners.

With zero owners, nobody in the organization can ever call any of the admin-only actions (invite, remove, cancel invitation, etc.), effectively permanently locking the organization's management plane. This is meaningfully worse than the bug being fixed; the original code had a larger window but only between a count and a delete in the same goroutine.

Prompt To Fix With AI
Wrap the owner-count check and owner delete in a single Prisma serializable transaction so PostgreSQL acquires predicate locks and aborts concurrent conflicting transactions. Replace the current `deleteMany` block (lines 308-327) with:

```typescript
if (targetMember.role === "owner") {
  await prisma.$transaction(
    async (tx) => {
      const otherOwner = await tx.member.findFirst({
        where: {
          organizationId: targetMember.organizationId,
          role: "owner",
          NOT: { id: memberId },
        },
        select: { id: true },
      });

      if (!otherOwner) {
        throw new SafeError(
          "Cannot remove the last remaining owner from the organization.",
        );
      }

      await tx.member.delete({ where: { id: memberId } });
    },
    { isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
  );
}
```

And keep the guarded `if (targetMember.role !== "owner") { await prisma.member.delete(...) }` path for non-owners as-is.  Under SERIALIZABLE isolation PostgreSQL detects the read/write conflict between two concurrent owner-removal transactions and serializes them, so only the first succeeds and the second retries (or throws), guaranteeing at least one owner always remains.

Severity: medium | Confidence: 68%

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/web/utils/actions/organization.ts">

<violation number="1" location="apps/web/utils/actions/organization.ts:309">
P2: Owner deletion now happens before premium cleanup, so failures in `removeUserFromPremium` can leave a partially applied operation (member removed but action errors and premium state not fully cleaned).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

if (targetMember.role === "owner") {
const ownerCount = await prisma.member.count({
where: { organizationId: targetMember.organizationId, role: "owner" },
const deletedMember = await prisma.member.deleteMany({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Owner deletion now happens before premium cleanup, so failures in removeUserFromPremium can leave a partially applied operation (member removed but action errors and premium state not fully cleaned).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/actions/organization.ts, line 309:

<comment>Owner deletion now happens before premium cleanup, so failures in `removeUserFromPremium` can leave a partially applied operation (member removed but action errors and premium state not fully cleaned).</comment>

<file context>
@@ -306,10 +306,21 @@ export const removeMemberAction = actionClientUser
     if (targetMember.role === "owner") {
-      const ownerCount = await prisma.member.count({
-        where: { organizationId: targetMember.organizationId, role: "owner" },
+      const deletedMember = await prisma.member.deleteMany({
+        where: {
+          id: memberId,
</file context>

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

Labels

bug Something isn't working bugfix-batch-0139

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants