Repository navigation
Commit 09e07c2
fix(plugin-auth)!: under single, no slug change makes another organization the Default Organization (#25286)
Fixes #25271
Clause-②: no (narrowing)
Metadata-protocol: none
## What was wrong (Step 1, measured on `main` at 8dfd126)
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). On a store holding two
organizations, `org_a` slugged `acme` and `org_b` slugged `beta`, where
`org_b` owns an `admin_full_access` grant held by its owner, measured on
the real stack (`ObjectQL` over `@objectstack/driver-sql` +
better-sqlite3, the real last-administrator guard, a real `AuthManager`
with the organization plugin, the real `resolveAuthzContext`):
| door | before (8dfd126) | after (this PR) |
|:---|:---|:---|
| better-auth `POST /auth/organization/update` `{ organizationId: org_b,
data: { slug: 'default' } }`, as `org_b`'s owner | **200**, slug
`default`, holder `platform_admin` **false → true** | **403
`PERMISSION_DENIED`**, slug `beta`, `platform_admin` stays false |
| engine update `{ id: org_b, slug: 'default' }`, `isSystem` (a seed's
upsert, a plugin, a script) | **admitted**, `platform_admin` **false →
true** | **403 `PERMISSION_DENIED`**, slug unchanged |
| data API PATCH and update-mode import (engine update in user context,
with the ADR-0092 identity write guard) | 403 `PERMISSION_DENIED`,
`slug` is not an editable column; slug unchanged | unchanged |
The last-administrator guard's `sys_organization` update 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 on `sys_organization`
`beforeUpdate` beside the update half: under `single`, a slug changed
**to** `default` is admitted only on the organization that is already
the Default Organization (the sole organization taking the slug the boot
gives it). Anything else is refused `403 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 names `slug:
'default'`, reads nothing under a wall, and fails closed when it cannot
read the organizations (a predicate write's matches, or the Default
Organization).
- **Why the engine and not `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's
`organization/update` answers 403 with the code `PERMISSION_DENIED` (the
guard's refusal reaches the caller through the adapter), so a second
check in `auth-manager.ts` would 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.
- **Why not gate every slug change the way create is gated.** That
refuses renaming the slug of a single-organization deployment, which
moves no anchor. The narrow rule refuses only the write that captures
the anchor.
- **Walled postures are unchanged**: no grant anchors there and a slug
is just a slug; the hook returns before any read.
## Pins
(`packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts`,
10 cases)
- HTTP door: refused 403 `PERMISSION_DENIED`, slug unchanged, no
standing gained; CONTROL the sole organization takes `default` (200);
CONTROL another slug (200); CONTROL walled `isolated` (200); a failed
read of this rule's own query refuses.
- Engine door: by id and by predicate, refused; a `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 walled `group`, admitted.
- Ablations through `scripts/ablation-replace.mjs`, each restored to the
HEAD blob with `git diff HEAD` empty (the suite imports `src`, 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
- The refusal answers the standard `PERMISSION_DENIED`, the code every
half of the last-administrator guard answers. A dedicated registered
code would need a row in `packages/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](https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 5f30f00 commit 09e07c2
4 files changed
Lines changed: 410 additions & 8 deletions
File tree
- .changeset
- packages
- plugins/plugin-auth/src
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
325 | 325 | | |
326 | 326 | | |
327 | 327 | | |
| 328 | + | |
328 | 329 | | |
329 | 330 | | |
330 | 331 | | |
| |||
923 | 924 | | |
924 | 925 | | |
925 | 926 | | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
| 979 | + | |
926 | 980 | | |
927 | 981 | | |
928 | 982 | | |
| |||
1856 | 1910 | | |
1857 | 1911 | | |
1858 | 1912 | | |
| 1913 | + | |
| 1914 | + | |
| 1915 | + | |
| 1916 | + | |
| 1917 | + | |
| 1918 | + | |
| 1919 | + | |
| 1920 | + | |
| 1921 | + | |
| 1922 | + | |
| 1923 | + | |
| 1924 | + | |
| 1925 | + | |
| 1926 | + | |
| 1927 | + | |
| 1928 | + | |
| 1929 | + | |
| 1930 | + | |
| 1931 | + | |
| 1932 | + | |
| 1933 | + | |
| 1934 | + | |
| 1935 | + | |
| 1936 | + | |
| 1937 | + | |
| 1938 | + | |
| 1939 | + | |
| 1940 | + | |
| 1941 | + | |
1859 | 1942 | | |
1860 | 1943 | | |
1861 | 1944 | | |
| |||
1952 | 2035 | | |
1953 | 2036 | | |
1954 | 2037 | | |
| 2038 | + | |
| 2039 | + | |
| 2040 | + | |
| 2041 | + | |
| 2042 | + | |
1955 | 2043 | | |
1956 | 2044 | | |
1957 | 2045 | | |
| |||
1962 | 2050 | | |
1963 | 2051 | | |
1964 | 2052 | | |
1965 | | - | |
| 2053 | + | |
| 2054 | + | |
1966 | 2055 | | |
1967 | 2056 | | |
1968 | 2057 | | |
| |||
0 commit comments