Uh oh!
There was an error while loading. Please reload this page.
fix(auth): canonicalise sys_member.role at the write, and converge existing rows - #8417
Conversation
better-auth reads sys_member.role with a raw split(',') -- no trim, no
lower-case -- so a row stored as 'Owner' or ' owner' is an owner to the
#5942 grade ladder and a plain member to the vendor. Its 'only an owner
may remove an owner' branch therefore never fires and the request falls
through to hasPermission({ member: ['delete'] }), which an org admin
passes: an org admin could remove an owner.
Maintainer ruling 2026-08-13, option A -- normalise at the write:
beforeInsert/beforeUpdate hooks on sys_member plus a one-off convergent
boot pass for existing rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73The 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:
|
…ed predicates 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
…is CJS-typed package) plugin-auth publishes CommonJS, so under module: NodeNext any import.meta is a TS1470 — which drifted the package's frozen TEST_DEBT ledger entry 111 -> 112. Fixed the type rather than the ledger: reuse the findUp-from-CWD idiom rate-limit-storage-isolation.test.ts already established here, and seed createRequire from the package root so the better-auth file read is the one THIS package is pinned to. Re-measure is back at 111. 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#8317
Implements the maintainer's ruling of 2026-08-13 — option A, normalise at the write. Options B (read-seam interception) and C (document the caveat) are not implemented.
The defect
A membership stored with a non-canonical role —
Owner,' owner',OWNER— was an owner to every ObjectStack-side check and a plain member to better-auth.better-auth
1.7.0-rc.2reads that column with a rawrole.split(","), notrim()and notoLowerCase(), in three branches ofdist/plugins/organization/routes/crud-members.mjs:removeMember— only an owner may remove an ownerconst roles = toBeRemovedMember.role.split(",");thenif (roles.includes(creatorRole))updateMemberRole— creator protectionconst isUpdatingCreator = toBeUpdatedMember.role.split(",").includes(creatorRole);organization/leave— last-owner countif (member.role.split(",").includes(creatorRole))Every ObjectStack reader trims and lower-cases (the #5942 grade ladder,
mapMembershipRole). So on such a row the vendor never entered its owner branch and fell through tohasPermission({ member: ['delete'] })— which an org admin passes.What landed
1. Write-path canonicalisation (
member-role-canonical.ts) — ObjectQLbeforeInsert/beforeUpdatehooks onsys_member, registered inauth-plugin.tsat priority 5, ahead of the ADR-0092 identity write guard (10) and the ADR-0024 D5.2 break-glass guard (20), so both judge the value's normal form. They fire for every context,isSystemincluded — better-auth's adapter, SCIM remaps and import scripts are exactly the paths this exists for.2. A one-off convergent boot pass (
canonicalizeStoredMemberRoles, akernel:readyhook next to the existing account-issuer backfill) — idempotent, safe to re-run, and it reports a census of every distinct non-canonical spelling with row counts before and after rewriting, rather than a bare number.Canonicalisation is per token, and the second clause is a measured consumer fact rather than caution:
resolve-authz-context.tsprojects every token throughmapMembershipRole, whosedefault:arm returnsraw.trim()with the case preserved, so a foreign token is a position name asys_position_permission_setrow may be bound to. Lower-casing it would silently re-point that binding;A value carrying no known role is left completely untouched and only reported: no reader can grade it as an owner, so it cannot produce the inversion. A mixed value (
Owner,Sales_Manager) is rewritten — it carriesowner, so it is the inversion class — toowner,Sales_Manager, foreign case intact. A value-level "all tokens known" rule would have left that hole open; there is a test for exactly it.The invariant this buys, stated so it can be tested: for any canonicalised value
vand any known roleR,v.split(',').includes(R)equalsparseOrgRoles(v).includes(R).Verification
The three vendor branches are pinned against predicates EXTRACTED FROM THE INSTALLED VENDOR FILE, not restated in TypeScript. A restatement would put both sides of the comparison on the same source and could not fail for the reason it exists. The test reads
crud-members.mjs, matches each branch, asserts the match is unique, and builds the predicate withnew Functionfrom the captured bytes. A vendor upgrade that moves or corrects any branch fails the extraction and reddens the suite instead of leaving a pin that verifies nothing.member-role-canonical.test.ts, 35 tests, all green. It reproduces the inversion first (ladder says owner, all three vendor branches say plain member) and then shows canonicalisation closing it on all three.Predict-then-mutate ablation — predictions written down before any run:
.toLowerCase()Owner/OWNER/' Owner 'red,' owner'/'owner 'green.trim()no canonicalisation handler registeredpattern matched 4 times ... must identify exactly one branchidfrom the pass's update payloadassertEngineUpdateDispatchpin is live, not decorativefailedrowsThe first two are the anti-vacuity evidence: a vacuous vendor pin would have stayed green under both.
Non-canonical spellings found before rewriting — a static census over every tracked file, 216 role literals in
sys_membercontext, 15 distinct spellings. Every non-canonical inversion-class spelling (Owner,' Owner ',Admin) is in this PR's own new test file. The pre-existing non-canonical values (sales_manager,sales_rep,not_a_declared_role) are already lower-case and trimmed, carry no known role, and are therefore left untouched by construction — consistent with plugin-auth's 1163 tests and dogfood's 748 passing unchanged. No spelling was found that normalisation would destroy meaning for.Gates run locally, real results:
check:nul-bytesOK ·check:engine-double-contractOK (193 pinned, nothing added to the baseline) ·check:error-code-casingOK ·check:adr-0087-registrationOK (no declared-breaking changeset, so no marker owed) ·check:empty-changesetOK ·check:durability-log-levelOK ·check:startup-registry-verdictOK ·check:type-check-debtOK, re-measured with the full closure built: "none above its recorded number, surplus: none".pnpm --filter @objectstack/plugin-auth typecheckclean,test1163/1163,@objectstack/dogfood748 passing.check:engine-double-contractdoes not discover this PR's memory engine: its scan requires two engine siblings besides the verb and this double has one (find). TheassertEngineUpdateDispatchpin is there and is live — the ablation above proves it fires — but the gate is not what verifies it.Decoupled from #8289, deliberately
remove-member-permission-guard.tsis untouched. It reproduces the vendor's predicate byte-for-byte on purpose, including the asymmetry where the target's roles are split withouttrim()and the caller's with it. After this lands, its refusal population and the vendor's agree by construction — which is the point of leaving it alone. A test pins that: the guard's target half still matches the extracted vendor predicate value for value, andtargetCarriesCreatorRole(' owner', 'owner')is stillfalse.The one hole that stays, named rather than hidden
A write that never reaches ObjectQL — raw SQL, an out-of-band driver fixture — can still store a divergent row after boot. It converges at the next restart. Closing it would mean a database-level constraint, a larger decision than the one ruled here.
Generated by Claude Code
Generated by Claude Code