Repository navigation
Description
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
400where every sibling denial is a403, so generic auth-error handling misses it. - Combined with console: workspace members page offers Invite / Remove member to the
memberrole; 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).
Activity
hotlong commentedon Aug 13, 2026
Triage:
pm:queue,domain:identity, type Bug. Removed the filer'spriority:p2— the p1–p5 ladder is retired and has no reader; urgency on ordinary queue items is carried by the Bug-first tie-break.Producer reading (this changes the dispatch premise, done at triage per the "who produces this?" question): on
origin/main@35e7417,packages/plugins/plugin-auth/src/auth-route-ledger.ts:198dispositionsPOST /api/v1/auth/organization/remove-memberassource: 'better-auth', disposition: 'sdk'— a straight pass-through to better-auth'sorganizations.removeMember. A tree-wide grep forCANNOT_LEAVE/ "only owner" finds no producer of this message in our packages (onlylast-admin-guard.tsprose, which is the break-glass invariant, not this string). So the wrong-branch fall-through the card hypothesizes most likely lives inside the better-auth dependency (pinned to the 1.7.0-rc line — see #3002/#3653).Dispatch premise for the executing seat: first establish where the 400 is minted (our hooks vs. vendor). If vendor: the fix shape is a bridge-level answer (intercept/remap on our side) or an upstream patch — ⛔ do not fork/vendor better-auth for this without a ruling, and any bridge remap must not mask the genuine only-owner refusal on the real leave path (the card's step 4 shows legitimate paths work; keep them working). The sibling endpoints' correct
403 YOU_ARE_NOT_ALLOWED_TO_*behavior is the parity target and the regression pin set.Related: #8092 (console shows the affordance that leads members here — objectui side).
Size/model suggestion: M,
mode:subagent, opus.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Claim: session
session_01PEVB6w7D7uCszR9Mw1BL73, branchclaude/issue-8289-remove-member-denial-status.File surface (expected):
packages/plugins/plugin-auth/src/**(the better-auth bridge / route ledger area) plus its tests. Starting from triage's producer reading — first task is diagnostic: establish whether the400 YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNERis minted by our hooks or inside the pinned better-auth dependency, and only then choose the fix shape. No fork/vendor of better-auth without a ruling.Generated by Claude Code
claude commentedon Aug 13, 2026
{ "issue": 8289, "status": "done", "branch": "claude/issue-8289-remove-member-denial-status", "pr": "https://github.com/objectstack-ai/objectstack/pull/8316", "head_sha": "96f797fbb96e77a4211e05188aa843bbf5dc6f35", "premise_still_valid": true, "where_the_400_is_minted": { "verdict": "vendor, not ours - triage's producer reading confirmed on current HEAD", "evidence": [ "Re-ran the tree-wide grep on origin/main 2efd2c9bc (triage measured at 35e7417): no producer of YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER in packages/ or apps/. Only local 'only owner' hits are prose in last-admin-guard.ts:686 and invitation-role-cap.ts:103.", "Producer located: better-auth@1.7.0-rc.2, dist/plugins/organization/routes/crud-members.mjs, throw sites at lines 196, 200 (removeMember) and 427 (leaveOrganization).", "Line 196 is the defect: 'if target carries owner AND caller does not -> throw BAD_REQUEST ONLY_OWNER'. It is a PERMISSION rule wearing the invariant's code, and it is ordered AHEAD of the route's real hasPermission check at line 202.", "This explains the filer's step 2 exactly: line 196 short-circuits before the owner COUNT at line 200 is consulted, so adding a second owner cannot change the answer.", "Second, smaller vendor defect at line 202: the real permission denial is APIError.from('UNAUTHORIZED', ...) = 401, where every sibling (update-member-role lines 285/292/310) uses FORBIDDEN = 403." ] }, "fix_shape": { "chosen": "bridge-level pre-emptive gate in our global better-auth before-hook (auth-manager.ts) delegating to a new remove-member-permission-guard.ts. No fork, no vendoring, no patched dependency.", "why_before_hook_not_response_remap": "better-auth pins the HTTP status at throw time: dispatch.mjs calls toResponse(response, {status: result.status}) with the ORIGINAL error's statusCode, and better-call's toResponse resolves 'init?.status ?? data.statusCode' - init WINS. An after-hook can replace the body but NOT the status, which would produce a 400 carrying a YOU_ARE_NOT_ALLOWED_TO_* code, a worse answer than the bug. A before-hook throw goes through toResponse(before, {headers}) with no status in init, so the thrown 403 stands. Same mechanism the existing /sso/register FORBIDDEN gate relies on.", "why_it_cannot_change_policy": [ "Silent on the sole-owner path: when the caller removes THEMSELVES while carrying the creator role, the guard returns and lets the vendor answer. Exemption is exact - vendor line 200 can only fire when caller and target are the same person (it needs both to carry the creator role while at most one such member exists).", "Never widens a refusal: the role test reproduces the vendor's line-196 predicate literally, including the asymmetry where the target's roles are split WITHOUT trim() and the caller's WITH it. A cleaned-up predicate would refuse inputs the vendor allows - a policy change smuggled in under an envelope fix.", "The permission half calls the vendor's OWN exported hasPermission (public export of better-auth/plugins/organization), never a local re-derivation, so there is no second spelling of the authorization question. Measured why this matters: memberAc grants no member:delete, and our closed ADR-0108 vocabulary adds delegated_admin - today our grade ladder happens to agree with the ac map, but keeping two spellings in agreement is not something to rely on." ], "net": "the refusal SET is byte-for-byte the vendor's; only the envelope changes" }, "ablation": { "mutation": "git checkout origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts (removes hook + method + roles-map stash; guard module and tests stay). Predictions written to disk BEFORE running.", "result": "12/12 predictions matched. Tests 6 failed | 1118 passed (1124) under mutation; 1124 passed after restore.", "table": [ {"n": 1, "assertion": "step 1: plain member removes the owner", "predicted": "FAIL 400 ONLY_OWNER", "observed": "FAIL 400 ONLY_OWNER", "match": true}, {"n": 2, "assertion": "step 2: second owner added, member removes owner", "predicted": "FAIL 400 ONLY_OWNER", "observed": "FAIL 400 ONLY_OWNER", "match": true}, {"n": 3, "assertion": "plain member removes another plain member", "predicted": "FAIL 401", "observed": "FAIL 401", "match": true}, {"n": 4, "assertion": "ADMIN removes an owner", "predicted": "FAIL 400 ONLY_OWNER", "observed": "FAIL 400 ONLY_OWNER", "match": true}, {"n": 5, "assertion": "parity with sibling update-member-role", "predicted": "FAIL removal 400 != 403", "observed": "FAIL 400 != 403", "match": true}, {"n": 6, "assertion": "managed-extension-fields plugin accounting", "predicted": "FAIL on the STALE assertion (not the unaccounted one)", "observed": "FAIL: 'AUTH_MANAGER_PLUGINS names plugins auth-manager.ts no longer imports: hasPermission'", "match": true}, {"n": 7, "assertion": "genuine sole-owner refusal on self-removal", "predicted": "PASS", "observed": "PASS", "match": true}, {"n": 8, "assertion": "sole-owner guard through organization/leave", "predicted": "PASS", "observed": "PASS", "match": true}, {"n": 9, "assertion": "non-last owner may still leave", "predicted": "PASS", "observed": "PASS", "match": true}, {"n": 10, "assertion": "owner removes the other owner -> 200", "predicted": "PASS", "observed": "PASS", "match": true}, {"n": 11, "assertion": "owner removes a plain member -> 200", "predicted": "PASS", "observed": "PASS", "match": true}, {"n": 12, "assertion": "admin may still remove a plain member -> 200", "predicted": "PASS", "observed": "PASS", "match": true} ], "note": "Row 6 was the one genuinely NEW prediction rather than a replay of the pre-fix baseline, and it proves the AUTH_MANAGER_PLUGINS skip entry added for hasPermission is self-cleaning: remove the import and the guard immediately reports the entry stale, so it cannot rot into a permanent hole. The pass set 7-12 is what makes the ablation meaningful in the other direction - the guard only ever adds a pre-emptive refusal on inputs the vendor already refuses, so it cannot be the reason any of those six work." }, "invariant_and_legitimate_paths_proof": { "sole_owner_self_removal": "still 400 YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER (vendor's, untouched) - pinned", "sole_owner_via_organization_leave": "still 400, same code - pinned (the real leave path, never routed through the guard at all)", "non_last_owner_leaving": "still 200 - pinned, and this is the pin that would catch an over-refusing guard", "owner_removes_other_owner": "200 - pinned", "owner_removes_plain_member": "200 - pinned", "admin_removes_plain_member": "200 - pinned (proves the guard did not widen to admins' ordinary authority)" }, "summary": "The 400 is minted inside better-auth 1.7.0-rc.2, not in our packages: removeMember orders its 'only an owner may remove an owner' rule ahead of the route's real permission check and reports it with the sole-owner invariant's code and status, so a caller who merely lacks permission is told they cannot leave an organization they were not leaving. Fixed at our bridge with a before-hook gate (no fork, no vendoring) that answers the permission class with 403 YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER, reproducing the vendor's own predicate literally so the refusal set is unchanged and only the envelope moves; the guard is structurally silent on the self-removal path, so the genuine sole-owner invariant stays the vendor's. A second vendor defect on the same route - the real permission denial answering 401 where every sibling answers 403 - is corrected in the same gate by delegating to better-auth's own exported hasPermission rather than re-deriving it. 11 new regression tests plus a 12-row predict-then-mutate ablation, all predictions matched.", "tests": "pnpm --filter @objectstack/plugin-auth test -> 'Test Files 48 passed (48) / Tests 1124 passed (1124)'. Pre-fix baseline on the same new file: 'Tests 5 failed | 1119 passed', failures being exactly the 5 envelope assertions. pnpm --filter @objectstack/plugin-auth typecheck -> clean (tsc --noEmit, no output). check:engine-double-contract -> 'OK - 186 pinned, 133 in the DEBT ledger, 2 exempt' (my new test file is among the pinned; no baseline entry added). check:nul-bytes -> 'OK (scanned 7549 text file(s) ... no raw ASCII control bytes)'. check:error-code-casing -> 'no lowercase error codes in 3863 scanned file(s) (ADR-0112)'. Build closure built first: pnpm --filter '@objectstack/plugin-auth^...' build. CI on 96f797fbb at report time: Check Changeset success, Check PR Size success, ADR maintainer approval success, no red checks; Test Core 1-3/3, ESLint, TypeScript Type Check, Dogfood gates still in_progress - not waited on per the 2026-08-10 dispatch contract.", "open_questions": [], "out_of_scope_findings": [ "filed as #8317: better-auth's org-role matching is case- and whitespace-sensitive (split(',') with no trim/lowercase), so a sys_member.role of 'Owner' reads as an owner to our #5942 grade ladder and as a plain member to the vendor - an admin can then remove such an owner. Deliberately NOT fixed in this PR: normalising the predicate would change WHO is refused under cover of a response-shape fix. Filed unassigned, unlabeled, for triage. Searched first: #5942 unified our TWO spellings and is closed; the vendor is a third spelling nobody reconciled, so this is not a duplicate." ], "notes_and_caveats": [ "Not verified: no live deployment reproduction. All evidence is the real better-auth pipeline driven through a real AuthManager via handleRequest (the #5233/#7725 precedent), plus source reading of the pinned vendor dist. I did not boot objectos-ee-deploy to re-observe the filer's curl results.", "One test-accounting file touched beyond the fix: managed-extension-fields.test.ts gained an AUTH_MANAGER_PLUGINS entry for hasPermission with a skip reason. That guard's import scanner regex cannot distinguish a plugin factory from any other one-name destructure off better-auth/plugins/*, and hasPermission is a predicate that declares no schema. I used the guard's own documented skip affordance rather than reshaping the import to dodge the scanner, which would have taught the codebase an evasion pattern and made the scanner blind to a real future plugin. This is NOT a baseline or ratchet entry, and ablation row 6 shows it self-cleans.", "No ratchet, ledger or baseline was raised or weakened. In particular scripts/engine-double-contract.baseline.json was NOT touched - the gate's suggestion to add an entry there was declined per the brief; the double was wired to assertEngineUpdateDispatch instead, and additionally made to enforce sys_member's declared {organization_id, user_id} UNIQUE index, since every assertion in the file reads membership rows back and the owner count is the fact the vendor's sole-owner branch turns on.", "PR body carries two attribution footers: mine in session-URL form plus a bare-form one the platform appended during the PATCH. Cosmetic; the durable session-URL footer is present.", "PR labels at report time (bot-applied, not set by me): documentation, size/l, tests, tooling. No skip-changeset needed - a changeset is present and Check Changeset is green." ] }Generated by Claude Code
fix(plugin-auth): organization/remove-member answers a permission den…