Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): organization/remove-member answers a permission denial as 403, not as the only-owner 400 - #8316
Conversation
Pins the measured defect before any fix: better-auth 1.7.0-rc.2's removeMember orders its owner-target branch AHEAD of hasPermission, so a non-owner caller gets 400 YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER instead of a permission refusal. 5 assertions red, 6 green (the must-not-break set). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
better-auth 1.7.0-rc.2 orders removeMember's 'only an owner may remove an owner' rule ahead of its real permission check and reports it with the sole-owner invariant's code and a 400. Answer the permission class in the global before-hook instead, with the 403 YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER envelope the sibling endpoints use. The guard stays silent on the self-removal path, so the genuine sole-owner invariant remains the vendor's; the permission half is decided by the vendor's own exported hasPermission, so there is no second spelling of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8289
Where the 400 is minted
Not in our packages — inside the pinned vendor. Re-verified triage's grep on current
origin/main(2efd2c9bc, not the35e7417triage measured at): no producer ofYOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNERexists anywhere inpackages/orapps/. The only local hits for "only owner" are prose inlast-admin-guard.tsandinvitation-role-cap.ts.The string is minted in
better-auth@1.7.0-rc.2,dist/plugins/organization/routes/crud-members.mjs, in theremoveMemberhandler:Branch (3a) is the defect: "only an owner may remove an owner" is a permission rule, reported with the sole-owner invariant's message and a
400, and ordered ahead of the route's real permission check at (4). Whenever the target is an owner and the caller is not, (3a) short-circuits and the invariant answers a question it was never asked.That also explains the filer's step 2 — adding a second owner changed nothing because (3a) fires before the owner count at (3b) is ever consulted. Every clause of the message can be false simultaneously.
Branch (4) carries a second, smaller defect:
UNAUTHORIZED(401), where every sibling denial answersFORBIDDEN(403). That is why a plain member removing another plain member was also not matching sibling parity.Fix shape, and why this one
No fork, no vendoring, no patched dependency. The correction lives in our bridge — the global before-hook in
auth-manager.ts, delegating to a newremove-member-permission-guard.ts.It has to be a before-hook, not a response remap. better-auth pins the HTTP status when the handler throws:
dispatch.mjscallstoResponse(response, { status: result.status })with the original error'sstatusCode, andbetter-call'stoResponseresolvesinit?.status ?? data.statusCode— init wins. So an after-hook can replace the body but not the status, which would yield a400carrying aYOU_ARE_NOT_ALLOWED_TO_*code: a worse answer than the one being fixed. A before-hook throw is dispatched throughtoResponse(before, { headers })with no status in init, so the thrown403stands — the same mechanism the existing/sso/registerFORBIDDEN gate relies on.Two properties keep the guard from changing policy, both pinned by tests:
trim()and the caller's with it. A "cleaned up" predicate would refuse inputs the vendor allows, which would be a policy change smuggled in under an envelope fix. The permission half calls the vendor's own exportedhasPermission, never a local re-derivation, so there is no second spelling of the authorization question.Net: the refusal set is byte-for-byte the vendor's; only the envelope changes.
Ablation (predict → mutate → observe)
Mutation:
git checkout origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts(removes the hook, the method and the roles-map stash; the guard module and tests stay). Predictions were written down before running the mutation, and are reproduced here unchanged.update-member-roleorganization/leaveTests 6 failed | 1118 passed (1124)under the mutation;1124 passedrestored. 12/12 predictions matched.Row 6 was the one genuinely new prediction rather than a replay of the pre-fix baseline, and it is the interesting one: it proves the
AUTH_MANAGER_PLUGINSskip entry added forhasPermissionis self-cleaning — remove the import and the guard immediately reports the entry as stale, so the entry 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. Had any flipped, the guard would be changing policy rather than the envelope.
Sole-owner guard and legitimate paths still work
Pinned explicitly, not merely left alone:
400 YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNERorganization/leave→ still400, same code200(the invariant is about the last owner, so this pins that the guard did not over-refuse)200200200Gates run locally
pnpm --filter @objectstack/plugin-auth testpnpm --filter @objectstack/plugin-auth typecheckcheck:engine-double-contractcheck:nul-bytescheck:error-code-casingThe new test file's engine double routes
updatethroughassertEngineUpdateDispatchanddeletethroughassertEngineDeleteDispatch, and enforcessys_member's declared{organization_id, user_id}UNIQUE index — that last one matters here specifically, because every assertion reads the membership rows back to prove a removal did or did not happen, and the owner count is exactly the fact the vendor's sole-owner branch turns on. No baseline or ledger was touched.Out of scope, filed separately
#8317 — better-auth's org-role matching is case- and whitespace-sensitive (
split(',')with no trim/lowercase), so asys_member.roleofOwnerreads as an owner to our #5942 grade ladder and as a plain member to the vendor; an admin can then remove such an owner. Not fixed here on purpose: correcting it would change who is refused, under cover of a response-shape fix. Filed unassigned for triage.Related: #8092 (the console shows the Remove member affordance to plain members — objectui side, deliberately untouched here).
Generated by Claude Code
Generated by Claude Code