bugfix-batch-0139: Prevent removing the final organization owner#2525
bugfix-batch-0139: Prevent removing the final organization owner#2525elie222 wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
|
| if (targetMember.role === "owner") { | ||
| const ownerCount = await prisma.member.count({ | ||
| where: { organizationId: targetMember.organizationId, role: "owner" }, | ||
| const deletedMember = await prisma.member.deleteMany({ |
There was a problem hiding this comment.
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%
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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>
Summary
Verification
pnpm installpnpm test --run utils/actions/organization.test.tspnpm --filter inbox-zero-ai lint -- utils/actions/organization.ts utils/actions/organization.test.ts(passed with one unrelated existing warning)git diff --checkBatch marker:
bugfix-batch-0139