Repository navigation
Commit e5f194b
fix(plugin-auth): an organization-delete read that fails refuses 503 before better-auth's first write (#25184)
Fixes #25059
Clause-②: no
Metadata-protocol: none
Why `Clause-②: no`: the refusal replaces an outcome only a failed store
read produces, and the typed 503 replaces a bare 500 fault; no request a
caller can make is answered differently.
## What changed
`AuthManager.assertOrganizationDeletable`, the `POST
/organization/delete` admission in better-auth's global `hooks.before`:
- **A failed session read refuses as well.** `resolveActor` answered a
failed session read with `null`, the value "no session" gives, so the
admission fell through. It takes an opt-in `failedReadThrows` option
now. With it, when no actor was resolved and the explicit token read
itself threw, that throw propagates, and the admission answers it with
the same typed `503`. No credential, a credential the store does not
hold, an expired session, a credential that does not parse, and one
carrying a control character all still answer `null`, so they reach
better-auth's `401`. Only the admission passes the option. The other
nine `this.resolveActor(ctx)` call sites are byte-identical in the diff.
- **A read it decides on that fails now refuses.** A failed read of the
caller's `sys_member` row or of the `sys_organization` row used to land
in `catch { return; }` and hand the request to better-auth, which
re-reads, then clears the session's active organization, then deletes
the members and invitations, and only then meets the engine's refusal at
the organization row. It now answers `503` with the standard ADR-0112
code `SERVICE_UNAVAILABLE`, before better-auth's first write.
- **A failed Default Organization decision answers the same typed
`503`.** It already refused before any write, but with a bare `Error`,
which better-auth serves as an untyped 500.
- **The vendor-decided cases still fall through, unchanged:** no actor,
no member row, not permitted, no organization row.
- The wire pieces sit beside the 409's in `organization-delete.ts`:
`ORGANIZATION_DELETE_READ_FAULT_CODE` and
`organizationDeleteReadFaultBody(object)`. The body names the table it
could not read. The driver's own error rides `cause` and is not relayed.
Two names were added to the existing `organization-delete.js` import
list in `auth-manager.ts`; nothing else outside the method moved.
## The four mechanism assumptions, re-measured on this branch
1. **The lookup block.** Measured: it holds four lookups, but
`resolveActor` cannot throw. Both of its paths catch, and it answers
`null`, the same value as "no session". So of the four, three can throw
into the catch: the `sys_member` read, `hasPermission` and the
`sys_organization` read.
2. **The Default Organization decision answers 500.** Measured by
ablation A2 below: with this change reverted to main's bare propagation,
the pin reads `expected 500 to be 503`.
3. **Which status and code.** `503 SERVICE_UNAVAILABLE`: the vocabulary
this file already uses for a read fault (the organization slug guard,
the invitation role cap and the invitation placement check), a
`StandardErrorCode`, so the error-code ledger needs no row.
`check:dispatcher-error-vocabulary` ran green (Verification below).
4. **Can `hasPermission` throw for a decidable vendor answer?** Yes, and
only for that. better-auth 1.7.12
`plugins/organization/has-permission.mjs` reaches the adapter only
behind `options.dynamicAccessControl?.enabled`, and the admission hands
it the roles map alone, so the call reads nothing. It throws only on the
member row itself: a role that is not a string (`role.split`), or a role
name that resolves to an inherited object member (`authorize` is not a
function). better-auth's `deleteOrganization` calls the identical
predicate (the same roles object: `organizationOptions.roles` is the
`customOrgRoles` the admission holds as `orgRolesMap`) on the same row,
before `setActiveOrganization`. So that throw keeps falling through, and
a pin holds that nothing is attempted when it does.
## The session read
The card names `resolveActor` among the lookups that throw. Measured: it
never threw. Both of its paths catch and answer `null`, so a failed
session read read as "no actor" and fell through. An uncommitted scratch
test failed only the admission's own session reads while better-auth's
stayed real; it got `200`, and better-auth's session update and member
and organization deletes ran with nothing counted.
`resolveActor(ctx, { failedReadThrows: true })` now tells the two apart.
It is used by the admission alone, and the default reading is unchanged
for the other nine callers.
Only the explicit token read counts as a failed read. Two things are
deliberately not counted:
- the first path's throw, when the second path finds no token;
- a failure reading a token that carries a control character. PostgreSQL
refuses U+0000 in a text parameter, and a crafted cookie can carry
`%00`. Counting it would turn that request's `401` into a `503`.
## Pins
`packages/plugins/plugin-auth/src/organization-delete-read-fault.test.ts`.
Real `ObjectQL` over `@objectstack/driver-sql` and better-sqlite3, the
manifest's objects, a real `AuthManager` with the organization plugin
(the `session-read-failure-answer.test.ts` harness).
- **One case per read that can fail:** the caller's session read (both
of `resolveActor`'s paths fail inside the admission's call, fired 2),
the caller's `sys_member` read, the `sys_organization` read, and the
Default Organization decision's count. The admission's own read fails
once, scoped to the admission's frame, so better-auth's reads of the
same table stay real. Each case asserts `503`, `SERVICE_UNAVAILABLE` and
the table named. It also asserts that no write was attempted: the
session update and the member, invitation and organization deletes are
recorded by engine hooks when attempted, not when committed. Then a
CONTROL: with the read back, the same delete answers `200` and the
organization is gone.
- **What still falls through:** a request with no session gets
better-auth's `401`, with nothing attempted. A credential carrying a
control character gets the same `401`, with nothing attempted, even
while the admission's read of it fails.
- **The `hasPermission` fall-through:** a stored member row whose role
is null reaches better-auth, which refuses before its first write.
Nothing is attempted, and the answer is not the admission's `503`.
### Ablations
A1 to A3 ran at `947ab6c25` (before the merge of `main`); A4 and A5 at
`5884148db`, the final head (baseline there `Tests 7 passed (7)`). The
test imports `./auth-manager.js` relatively, so the subject resolves to
`src/` and no build sits between a mutation and its run. Each mutation
was applied with `scripts/ablation-replace.mjs` (the anchor hit once,
landed on disk, blob changed), and then restored.
| | mutation | result |
|---|---|---|
| baseline | none | `Tests 4 passed (4)` |
| A1 | the read helper swallows the fault (main's fall-through put back)
| the three read pins red, each `expected 200 to be 503`: the request
reached better-auth and deleted the organization |
| A2 | the Default Organization decision propagates bare (main's R3) |
its pin red, `expected 500 to be 503`; the other three green |
| A3 | a `hasPermission` throw refuses `503` instead of falling through
| the fall-through pin red, `expected '{"code":"SERVICE_UNAVAILABLE",…'
not to contain 'SERVICE_UNAVAILABLE'` |
| A4 | `resolveActor` swallows the failed session read again | the
session-read pin red, `expected 200 to be 503`: the request reached
better-auth and deleted the organization |
| A5 | the control-character guard removed | the control-character pin
red, `expected 503 to be 401` |
Restore after each mutation: blob `f3015ed6f7b0` == HEAD, `git diff
HEAD` empty, zero markers on disk, `git status --porcelain` empty.
## Verification
- At the final head `5884148db`, under the lock: `pnpm --filter
@objectstack/plugin-auth build` exit 0; `pnpm --filter
@objectstack/plugin-auth exec vitest run` gave `Test Files 185 passed
(185)`, `Tests 3485 passed | 10 skipped (3495)`; `pnpm --filter
@objectstack/plugin-auth typecheck` exit 0 (`check:test-typecheck: OK`,
no debt row added); `pnpm check:type-check-debt` exit 0 (`re-measure: OK
— 1 ledger entr(ies) re-measured in 190.9s, 26 raw tsc error(s) total,
none above its recorded number`). The lock printed `VERDICT command-exit
0`.
- Gate union at `5884148db`: the 70 commands derived by `node
scripts/pm/dispatch-gates.mjs --commands` (the same 70 at `947ab6c25`,
`46c61dade` and `5884148db`), plus `check-changeset-fixed` and
`check:authz-resolver`, whose rosters sit in a directory this diff
touches. Every exit code was captured before any pipe, and all are 0.
`--ran`: `70 derived · 70 run · 0 NOT-MEASURED · 0 UNRUN`.
- The other nine callers: in the diff, the one changed `resolveActor(`
line is the admission's; `git grep` counts nine `this.resolveActor(ctx)`
call sites, and `failedReadThrows: true` occurs once
(`auth-manager.ts:8378`, inside `assertOrganizationDeletable`).
- Not run locally: the full-farm `check:*` sweep, `pnpm lint`, the
dogfood battery (the title carries no `!` and there is no `(narrowing)`
arm), and the `packages/qa` dogfood pins. CI runs those; every check-run
on `5884148db` succeeded or was skipped.
## Acceptance notes
- The #24948 dogfood pin for a failed Default Organization lookup
(`packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts:282`)
asserts a status of 400 or above. It stays green with `503`. It could be
tightened to `503` plus `SERVICE_UNAVAILABLE`. That file is in the
`domain:cli` lane and was not edited here.
- The record count (`organizationDeleteRefusal` /
`defaultOrganizationDeleteRefusal`) still propagates a failure bare, as
an untyped 500. That is the same family as the Default Organization
decision. It is not changed here because what reaches it is not only a
store fault: an engine refusal carrying its own code lands there too, so
a blanket `503` would mislabel it.
- A client sees `503 SERVICE_UNAVAILABLE` from an organization delete
only when a store read failed. Nothing was deleted then, and a retry is
the remedy.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 9c431ba commit e5f194b
4 files changed
Lines changed: 445 additions & 31 deletions
File tree
- .changeset
- packages/plugins/plugin-auth/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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
| 214 | + | |
214 | 215 | | |
215 | 216 | | |
216 | 217 | | |
| 218 | + | |
217 | 219 | | |
218 | 220 | | |
219 | 221 | | |
| |||
8148 | 8150 | | |
8149 | 8151 | | |
8150 | 8152 | | |
| 8153 | + | |
| 8154 | + | |
| 8155 | + | |
| 8156 | + | |
| 8157 | + | |
| 8158 | + | |
| 8159 | + | |
| 8160 | + | |
8151 | 8161 | | |
8152 | 8162 | | |
8153 | 8163 | | |
| 8164 | + | |
8154 | 8165 | | |
8155 | 8166 | | |
8156 | 8167 | | |
| |||
8164 | 8175 | | |
8165 | 8176 | | |
8166 | 8177 | | |
| 8178 | + | |
8167 | 8179 | | |
8168 | 8180 | | |
8169 | 8181 | | |
| |||
8183 | 8195 | | |
8184 | 8196 | | |
8185 | 8197 | | |
8186 | | - | |
8187 | | - | |
8188 | | - | |
8189 | | - | |
| 8198 | + | |
| 8199 | + | |
| 8200 | + | |
| 8201 | + | |
| 8202 | + | |
| 8203 | + | |
| 8204 | + | |
| 8205 | + | |
| 8206 | + | |
| 8207 | + | |
| 8208 | + | |
| 8209 | + | |
| 8210 | + | |
| 8211 | + | |
8190 | 8212 | | |
8191 | 8213 | | |
8192 | 8214 | | |
| |||
8200 | 8222 | | |
8201 | 8223 | | |
8202 | 8224 | | |
| 8225 | + | |
8203 | 8226 | | |
8204 | 8227 | | |
8205 | 8228 | | |
| |||
8314 | 8337 | | |
8315 | 8338 | | |
8316 | 8339 | | |
8317 | | - | |
8318 | | - | |
8319 | | - | |
8320 | | - | |
| 8340 | + | |
| 8341 | + | |
| 8342 | + | |
| 8343 | + | |
| 8344 | + | |
| 8345 | + | |
| 8346 | + | |
8321 | 8347 | | |
8322 | | - | |
| 8348 | + | |
| 8349 | + | |
| 8350 | + | |
8323 | 8351 | | |
8324 | 8352 | | |
8325 | 8353 | | |
| |||
8329 | 8357 | | |
8330 | 8358 | | |
8331 | 8359 | | |
8332 | | - | |
| 8360 | + | |
| 8361 | + | |
| 8362 | + | |
| 8363 | + | |
| 8364 | + | |
| 8365 | + | |
| 8366 | + | |
| 8367 | + | |
| 8368 | + | |
| 8369 | + | |
| 8370 | + | |
| 8371 | + | |
| 8372 | + | |
| 8373 | + | |
| 8374 | + | |
| 8375 | + | |
| 8376 | + | |
| 8377 | + | |
| 8378 | + | |
| 8379 | + | |
| 8380 | + | |
| 8381 | + | |
| 8382 | + | |
| 8383 | + | |
| 8384 | + | |
8333 | 8385 | | |
8334 | | - | |
8335 | | - | |
8336 | | - | |
8337 | | - | |
8338 | | - | |
8339 | | - | |
8340 | | - | |
8341 | 8386 | | |
8342 | | - | |
| 8387 | + | |
8343 | 8388 | | |
8344 | 8389 | | |
8345 | 8390 | | |
| |||
8348 | 8393 | | |
8349 | 8394 | | |
8350 | 8395 | | |
8351 | | - | |
8352 | | - | |
8353 | | - | |
8354 | 8396 | | |
8355 | | - | |
8356 | | - | |
8357 | | - | |
| 8397 | + | |
| 8398 | + | |
| 8399 | + | |
| 8400 | + | |
| 8401 | + | |
8358 | 8402 | | |
8359 | 8403 | | |
8360 | | - | |
8361 | | - | |
8362 | | - | |
8363 | | - | |
8364 | | - | |
8365 | | - | |
| 8404 | + | |
| 8405 | + | |
| 8406 | + | |
| 8407 | + | |
| 8408 | + | |
| 8409 | + | |
| 8410 | + | |
| 8411 | + | |
| 8412 | + | |
| 8413 | + | |
| 8414 | + | |
| 8415 | + | |
8366 | 8416 | | |
8367 | 8417 | | |
8368 | 8418 | | |
| |||
0 commit comments