Skip to content

organization/remove-member answers a permission denial with 400 "cannot leave as the only owner" — wrong status, wrong reason, false even with two owners #8289

Description

@baozhoutao

When a caller without permission calls POST /api/v1/auth/organization/remove-member, the request is correctly refused — but the response is:

400 {"message":"You cannot leave the organization as the only owner",
"code":"YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER"}

The caller is not leaving, is not an owner, and — as shown below — the organization does not have only one owner. Every clause of that message is false. The actual reason is "you are a member, you may not remove people".

No security hole: the removal genuinely does not happen (member list unchanged after the attempt). This is about the response being wrong and actively misleading.

Measured

Fixture: workspace with owner lisi, plain member zhangsan (the caller).

1. Member tries to remove the owner

actor: { email: lisi→zhangsan, role: "member" }
POST organization/remove-member { memberIdOrEmail: "lisi@…" }
→ 400 YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER

2. Add a second owner, so "only owner" is now factually false, and retry

members: [owner:lisi@…, owner:outsider@…, member:zhangsan@…]
actor role: "member"
POST organization/remove-member { memberIdOrEmail: "outsider@…" }
→ 400 YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER
members after: unchanged (all three) ✅ refusal is real

Two owners exist and the target is not the caller, yet the same "only owner" refusal comes back.

3. The sibling endpoint gets it right

POST organization/update-member-role { memberId: <owner>, role: "member" } as the same member
→ 403 YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER ← correct code, correct reason

organization/update, organization/delete and organization/invite-member likewise return proper 403 YOU_ARE_NOT_ALLOWED_TO_* for the same actor. remove-member is the odd one out.

4. The legitimate paths work fine, so this is not a functional defect

as OWNER: remove the other owner → 200, members: [owner:lisi, member:zhangsan]
as OWNER: remove the member → 200, members: [owner:lisi]

Expected

403 with a YOU_ARE_NOT_ALLOWED_TO_REMOVE_THIS_MEMBER-style code, matching the rest of the organization API. The only-owner guard should fire only when it is actually true — i.e. on the self-removal / leave path of a sole owner.

Likely shape of the bug: the permission branch falls through into the leave/last-owner guard instead of returning its own denial, so the last-owner check answers for a question it was never asked.

Why it matters beyond tidiness

  • A client cannot distinguish "you lack permission" from "business rule blocks this", so it cannot decide whether to hide the action or to surface a retry.
  • It is a 400 where every sibling denial is a 403, so generic auth-error handling misses it.
  • Combined with console: workspace members page offers Invite / Remove member to the member role; only the server 403 stops it #8092 (the console shows Remove member to plain members, including on the owner's row), the end-user experience is: click a button you should not have, and be told you cannot leave an organization you were not leaving.

Environment

objectos-ee-deploy (Caddy → app → postgres:16, NODE_ENV=production, OS_TENANCY_POSTURE=isolated) on http://localhost:8080, observed 2026-08-13. Probed via the REST API with real sessions; a throwaway workspace was used so the owner-removal paths could be exercised safely.

Related: #8092 (the UI affordance that leads a member here).

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions