Repository navigation
fix(plugin-auth)!: under single, no slug change makes another organization the Default Organization - #25286
Conversation
…ation the Default Organization Under `single` the Default Organization is the organization slugged `default`, else the only one, and its `admin_full_access` grant confers platform administrator standing (ADR-0131 D5). Re-slugging another organization to `default` made it the Default Organization: measured on 8dfd126, better-auth `organization/update` answered 200 and the grant holder became platform_admin through the real resolveAuthzContext; a system-context engine update did the same. One judge, `refuseDefaultOrganizationSlugCapture`, refuses it unless the organization already is the Default Organization. The last-administrator guard runs it on `sys_organization` `beforeUpdate` (every engine write), and `beforeUpdateOrganization` asks it before the vendor writes so the HTTP door answers 403 PERMISSION_DENIED instead of a 500. Walled postures read nothing and admit. Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude <noreply@anthropic.com>
…he pre-check and the export Ablating the `beforeUpdateOrganization` pre-check showed better-auth's `organization/update` still answers 403 PERMISSION_DENIED: the adapter writes through the engine and the guard's refusal reaches the caller with its code. The pre-check changed only a read fault's answer (503 rather than the guard's fail-closed 403), so it goes, and with it the new public export: the judge is module-private to the last-administrator guard. Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude <noreply@anthropic.com>
…wn read Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e766342cd3ef1bfde65b2caf6cda73aa9b0aaeb1 && git checkout e766342cd3ef1bfde65b2caf6cda73aa9b0aaeb1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a8358581aab41981c94a697da4d2ef87faca42ac 80315628e91fdcd3194b61d9489d3f4296be6143 && git checkout -B drift-repro a8358581aab41981c94a697da4d2ef87faca42ac && git merge --no-ff 80315628e91fdcd3194b61d9489d3f4296be6143
node scripts/docs-audit/affected-docs.mjs --json a8358581aab41981c94a697da4d2ef87faca42ac
|
…to, an organization The `default moved` case re-slugged a second organization to `default` through a system engine update: the anchor capture this card closes, now refused 403 PERMISSION_DENIED. The case keeps both memo directions on a path that stays legal: `default` moved off the Default Organization withdraws standing; the second organization deleted makes the first the only one again, which confers it. The capture itself is asserted refused. Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude <noreply@anthropic.com>
…ngle-reslug-default-anchor
… the stranding it leaves Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed at the head the seat named after the merge of ① Derived judgments
② Semver levelChangeset
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
Seat ACCEPT · epic PM Seat check
|
Fixes #25271
Clause-②: no (narrowing)
Metadata-protocol: none
What was wrong (Step 1, measured on
mainat 8dfd126)Under
singlethe Default Organization is the organization sluggeddefault, else the only one, and itsadmin_full_accessgrant confers platform administrator standing (ADR-0131 D5). On a store holding two organizations,org_asluggedacmeandorg_bsluggedbeta, whereorg_bowns anadmin_full_accessgrant held by its owner, measured on the real stack (ObjectQLover@objectstack/driver-sql+ better-sqlite3, the real last-administrator guard, a realAuthManagerwith the organization plugin, the realresolveAuthzContext):POST /auth/organization/update{ organizationId: org_b, data: { slug: 'default' } }, asorg_b's ownerdefault, holderplatform_adminfalse → truePERMISSION_DENIED, slugbeta,platform_adminstays false{ id: org_b, slug: 'default' },isSystem(a seed's upsert, a plugin, a script)platform_adminfalse → truePERMISSION_DENIED, slug unchangedPERMISSION_DENIED,slugis not an editable column; slug unchangedThe last-administrator guard's
sys_organizationupdate half admitted the re-slug because it only refuses a write that leaves nobody standing (ADR-0135 D5.2), and this one takes standing from nobody.The fix, and why this one
A new hook in
last-admin-guard.ts, registered onsys_organizationbeforeUpdatebeside the update half: undersingle, a slug changed todefaultis admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Anything else is refused403 PERMISSION_DENIED(ADR-0112 envelope; the message names the Default Organization rule, the current standing, and the remedy: keep another slug, hold one organization, or declare a walled posture). It reads only when the payload namesslug: 'default', reads nothing under a wall, and fails closed when it cannot read the organizations (a predicate write's matches, or the Default Organization).beforeUpdateOrganization. Every door that can change a slug is an engine update: better-auth's adapter writes through the engine, and so do seeds, plugins and the data API. A better-auth hook alone would leave the seed and plugin doors open. Measured by ablation: with only the engine hook, better-auth'sorganization/updateanswers 403 with the codePERMISSION_DENIED(the guard's refusal reaches the caller through the adapter), so a second check inauth-manager.tswould buy nothing but a different answer to a read fault. A first commit carried that pre-check; it was removed, and with it the public export it needed.Pins (
packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts, 10 cases)PERMISSION_DENIED, slug unchanged, no standing gained; CONTROL the sole organization takesdefault(200); CONTROL another slug (200); CONTROL walledisolated(200); a failed read of this rule's own query refuses.default-slugged Default Organization beside it, refused by this rule before any unique index (at HTTP, better-auth's own slug-taken check answers that shape 400 first); a failed read refuses; CONTROL walledgroup, admitted.scripts/ablation-replace.mjs, each restored to the HEAD blob withgit diff HEADempty (the suite importssrc, no dist leg): the hook's registration removed → 6 red (every refusal pin), 4 controls green; the wall short-circuit removed → the 2 walled controls red; the Default-Organization admission removed → the sole-organization control red.Proof (tests, gates, R1, R4, the upgrade harness, dogfood) is in the os-dev-report on #25271.
Acceptance notes
PERMISSION_DENIED, the code every half of the last-administrator guard answers. A dedicated registered code would need a row inpackages/spec's error-code ledger, outside this card's file surface.docs/adr/**is untouched. The rule is ADR-0131 D5's definition applied to the update write; the ADR's 2026-10-22 D5 note could name this hook beside the insert half when the epic next amends it.Generated by Claude Code