Repository navigation
Commit 8dfd126
fix(core,plugin-auth)!: the last-admin guard judges a sys_organization insert that moves the Default Organization; one Default Organization definition (#25265)
Fixes #25208
Clause-②: yes (narrowing)
Metadata-protocol: none
The three 8c-2 review carriers of record 6253205661 (owed scope of
#15212, claim 6345294692).
## 1. The anchor guard: measured, then a `beforeInsert` half
**Measurement first, on `main` at `e58bd0e3`.** I used a real ObjectQL
engine over better-sqlite3, with the real last-admin guard and the real
`resolveAuthzContext`. The `single` posture has a sole organization
slugged `acme`, and an `admin_full_access` grant owned by it is the only
standing of `usr_admin`.
| write | before | after |
|---|---|---|
| (A) insert a second organization, slug `b` | `usr_admin` is
`platform_admin` | the insert is admitted; `usr_admin` is no longer
`platform_admin`; a ban of `usr_admin` is then **admitted** (the guard
counts nobody, so it reads the bootstrap window) |
| (B) insert an organization slugged `default`, then a grant of the set
owned by it to `usr_b` | `usr_admin` admin, `usr_b` not | the insert is
admitted; `usr_admin` loses standing; `usr_b` **gains** it |
So the anchor can move. (A) is the fail-open the review named: the
silent loss also disarms the guard for the next write.
**What the code and ADR-0131 D5 support: refuse, judged like the update
and delete halves.** D5 makes the Default Organization's grant the
anchor under `single`. D3/D5 define the Default Organization as the one
slugged `default`, else the only one. So keeping the anchor fixed by
writing to an organization on the caller's behalf is not available:
re-slugging a row the caller did not touch would be a write nobody asked
for. The guard is a break-glass invariant (ADR-0135 D5.2). Its update
and delete halves already refuse a Default Organization re-slug or
delete only when no administrator who can sign in would remain. The
insert half asks the same question with the same enumerate, simulate,
enumerate machinery, so it cannot drift from them:
- `guardOrganizationInsert` is registered on `beforeInsert` of
`sys_organization`, priority 20, with the guard's package.
- The simulation adds the inserted row to both reads of the Default
Organization definition (`PendingStandingWrite.inserted`, as the
activation-ledger insert half does). A payload with no id yet is
simulated under a placeholder id that no grant can name.
- It refuses `PERMISSION_DENIED` / 403 on `sys_organization`. The
refusal says what moved and names the remedy: give the current Default
Organization the slug `default` first (while it is the only one, that
changes nothing else), or grant another account standing.
- It admits when an organization owner or admin, an
`OS_PLATFORM_OWNER_EMAIL` administrator, or a `default`-slugged Default
Organization remains. ⛔ It is not standing preservation. A platform
administrator who also owns an organization keeps that standing, which
the invariant counts, so the insert is admitted and the grant stops
anchoring. That is the same verdict the update half gives a re-slug.
- Under a wall no grant anchors, so the half returns before any read.
**Reach, measured through `os serve` (production, `single`).** Both
public create doors already refuse a second organization: better-auth
`POST /auth/organization/create` answers `403
ORGANIZATION_CREATION_DISABLED`, and `POST
/api/v1/data/sys_organization` answers `405
OBJECT_API_METHOD_NOT_ALLOWED` (that object's allowed list still names
`import`, which I did not measure). So the half guards the writers below
HTTP: seeds, scripts, a host's own engine calls and imports.
## 2. The ceremony pin
The acceptance fixture of `organization-ownership-apply.test.ts` now
holds `grant_promoted`: an `admin_full_access` grant with a NULL
`organization_id`, in the 17.x shape (by id to an `admin_full_access`
mirror row). The test reads `readDefaultOrganizationId`
(`@objectstack/core`) over the same SQLite database before the ceremony.
It pins that the grant is attributed to that answer (`org_default`), and
that under a wall it stays unattributed.
Two fixture consequences:
- The catalog double now declares `admin_full_access`, as the composed
security plugin ships it.
- The "name only the uncomposed security plugin declares" block asks for
a catalog without it (`catalog({ adminSetDeclared: false })`), which is
its premise. The walled test's `sys_permission_set` count moves 3 to 4
(the extra mirror, not deleted under a wall).
## 3. One Default Organization definition
`@objectstack/core` exports the rule once, plus the resolver's read:
- `findDefaultOrganization(read)`: `slug = 'default'`, else the only
organization, else none. The answer reports `several` too. It is
parameterised by the read, because the readers differ on purpose in
**how** they read, never in what they pick.
- `DEFAULT_ORGANIZATION_SLUG`.
- `readDefaultOrganizationId(ql)`: the resolver's former private
function, now exported. Its read fails loud on an unreachable store and
reads a never-provisioned one as none.
| copy | now | kept on purpose |
|---|---|---|
| `resolve-authz-context.ts` (`readDefaultOrganizationId`) | the rule
over `tryFind`, exported | the read policy
(`AuthzStoreUnavailableError`, registry check) |
| `default-organization-invariant.ts` | the rule over a read that
**propagates** | the producer half (create, `ambiguous` report) |
| `ensure-default-organization.ts` (plugin-auth) | the rule over its
swallowing `tryFind` | the walled branch reads only `slug = 'default'`;
under a wall no sole-organization leg applies (comment in place) |
| `last-admin-guard.ts` | the rule over the guard's own read | the
simulation of a pending write (now also an insert) and `scan`'s
fail-closed ceiling, which is why it cannot call the resolver's read |
| `tenancy-service.ts` (`resolveDefaultOrgId`) | the rule over
`findRows` | answers `null`, and a failed read reads as none |
| `suggested-audience-bindings.ts` (`resolveDefaultOrganizationId`) |
the rule over its read | `scopedSystemCtx()`, and a failed read throws
(the promotion decides) |
| `organization-ownership-plan.ts` | the rule over raw SQL | no engine;
a store with no `slug` column has no `default` to find |
Two edge answers move to the resolver's:
- In the ceremony's plan, a `default`-slugged row with a blank id now
falls through to the only-organization reading. It used to name nothing.
- In plugin-security's reader, a numeric id is read as its string. It
used to be read as no id.
## Tests and proof
All runs are at `6f6e9003` (after merging `main`) unless noted.
- **New pins:**
- `last-admin-guard.default-organization.test.ts`: 6 cases, 15/15 in the
file.
- `default-organization.test.ts` (core): 8.
- The apply-test pin: the file is 112/112.
- **Ablations** (`scripts/ablation-replace.mjs`, each restored to the
HEAD blob, `git diff HEAD` empty):
- Remove the insert half's `enforceStanding`: 3 pins red.
- Remove the wall short-circuit: the walled control red.
- Drop the inserted row from the simulation: 3 pins red.
- Core: sole leg `=== 1` changed to `>= 1`: 2 red.
- The plan naming another organization: the acceptance test red. Its
effective-access assertion (`:584`) fails ahead of the new pin, because
the same attribution moves that grant's access line.
- **Suites:**
- At `2c165f8b`: plugin-auth 185 files, 3493 passed. plugin-security 208
files, 4274 passed. core 110 files, 2638 passed. Each part exited 0.
- After the merge: the scoped files 292 + 130 + 8 + 112, joined with
`&&`, exit 0. `typecheck` of core, plugin-auth, plugin-security and cli:
exit 0.
- **`node scripts/pm/dispatch-gates.mjs --commands`** at `2c165f8b`:
- 76 derived. 74 exit 0 (the three i18n gates after building their
prerequisite).
- 2 exit 3, NOT MEASURED: `check-plugin-teardown-shape --self-test`
needs an unshallow clone; `check:dual-build-cjs-loads` needs the full
build.
- `--ran`, with exit codes recorded: 76 accounted.
`check-changeset-fixed` and `check:optional-error-sink` exit 0.
- **`check-governed-merges --branch`:** 0 of 13 paths governed. 633
changed lines (+516/−117). Not Tier H or S.
- **R1 (fresh boot):** a production `os serve` of `examples/app-crm`
under `single`, on a fresh SQLite file: health 200, sign-up 200, sign-in
200, session 200. The harness's fresh-store leg passes too.
- **R4 (walled):** a production `os serve` of `examples/app-crm` under
`isolated` and under `group`. `@objectstack/organizations` was declared
in the app for the run and reverted after. Both: health 200, sign-up
200, sign-in 200, two organizations created through `POST
/auth/organization/create` (200 each, through the new hook's wall path),
session 200, 0 organization-less grants.
- **Upgrade harness** (`upgrade-18x-harness.dogfood.test.ts`, both
fixture legs, 18.0 and 18.0.0, plus the fresh store): 16/16.
- **Full dogfood battery:** running on `6f6e9003` when this body was
written. The result is in the os-dev-report on #25208.
## Acceptance notes
- **The `admin_full_access` anchor predicate stays in two places.** The
guard's `anchorOwnerTest` and the resolver's
`grantAnchorsPlatformStanding` each spell it. Record 6253205661 proposed
exporting it with the definition. This card's surface is the Default
Organization definition, and the guard's form reads the simulated
Default Organization, so it is not the same function.
- **The `ambiguous.organizationCount` field is always 2.** That is what
the boot invariant could measure: the rule reads at most two rows.
plugin-auth's boot warning therefore says "holds 2 organizations" for
any number above one. This is unchanged and pre-existing.
- **Not measured:** whether `import` on `sys_organization` (listed as
allowed by the data API under `single`) reaches the engine's
`beforeInsert`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 24360dc commit 8dfd126
13 files changed
Lines changed: 516 additions & 117 deletions
File tree
- .changeset
- packages
- cli/src/utils
- core/src/security
- plugins
- plugin-auth/src
- plugin-security/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 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| |||
83 | 83 | | |
84 | 84 | | |
85 | 85 | | |
86 | | - | |
| 86 | + | |
87 | 87 | | |
88 | 88 | | |
89 | 89 | | |
| |||
92 | 92 | | |
93 | 93 | | |
94 | 94 | | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
95 | 100 | | |
96 | 101 | | |
97 | 102 | | |
| |||
276 | 281 | | |
277 | 282 | | |
278 | 283 | | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
279 | 292 | | |
280 | 293 | | |
281 | 294 | | |
| |||
290 | 303 | | |
291 | 304 | | |
292 | 305 | | |
293 | | - | |
| 306 | + | |
| 307 | + | |
294 | 308 | | |
295 | 309 | | |
296 | 310 | | |
| |||
334 | 348 | | |
335 | 349 | | |
336 | 350 | | |
337 | | - | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
338 | 356 | | |
339 | 357 | | |
340 | 358 | | |
| |||
397 | 415 | | |
398 | 416 | | |
399 | 417 | | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
400 | 433 | | |
401 | 434 | | |
402 | 435 | | |
| |||
519 | 552 | | |
520 | 553 | | |
521 | 554 | | |
| 555 | + | |
522 | 556 | | |
523 | 557 | | |
524 | 558 | | |
| |||
582 | 616 | | |
583 | 617 | | |
584 | 618 | | |
| 619 | + | |
585 | 620 | | |
586 | 621 | | |
587 | 622 | | |
| |||
591 | 626 | | |
592 | 627 | | |
593 | 628 | | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
594 | 637 | | |
595 | 638 | | |
596 | 639 | | |
| |||
922 | 965 | | |
923 | 966 | | |
924 | 967 | | |
925 | | - | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
926 | 971 | | |
927 | 972 | | |
928 | 973 | | |
| |||
2102 | 2147 | | |
2103 | 2148 | | |
2104 | 2149 | | |
2105 | | - | |
| 2150 | + | |
2106 | 2151 | | |
2107 | 2152 | | |
2108 | 2153 | | |
| |||
2128 | 2173 | | |
2129 | 2174 | | |
2130 | 2175 | | |
2131 | | - | |
| 2176 | + | |
2132 | 2177 | | |
2133 | 2178 | | |
2134 | 2179 | | |
| |||
2145 | 2190 | | |
2146 | 2191 | | |
2147 | 2192 | | |
2148 | | - | |
| 2193 | + | |
2149 | 2194 | | |
2150 | 2195 | | |
2151 | 2196 | | |
| |||
2161 | 2206 | | |
2162 | 2207 | | |
2163 | 2208 | | |
2164 | | - | |
| 2209 | + | |
2165 | 2210 | | |
2166 | 2211 | | |
2167 | 2212 | | |
| |||
2181 | 2226 | | |
2182 | 2227 | | |
2183 | 2228 | | |
2184 | | - | |
| 2229 | + | |
2185 | 2230 | | |
2186 | 2231 | | |
2187 | 2232 | | |
| |||
2194 | 2239 | | |
2195 | 2240 | | |
2196 | 2241 | | |
2197 | | - | |
| 2242 | + | |
2198 | 2243 | | |
2199 | 2244 | | |
2200 | 2245 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
490 | 490 | | |
491 | 491 | | |
492 | 492 | | |
493 | | - | |
494 | 493 | | |
495 | 494 | | |
496 | 495 | | |
| |||
1336 | 1335 | | |
1337 | 1336 | | |
1338 | 1337 | | |
1339 | | - | |
1340 | | - | |
1341 | | - | |
| 1338 | + | |
| 1339 | + | |
| 1340 | + | |
| 1341 | + | |
| 1342 | + | |
| 1343 | + | |
| 1344 | + | |
1342 | 1345 | | |
1343 | 1346 | | |
1344 | 1347 | | |
1345 | 1348 | | |
1346 | 1349 | | |
1347 | | - | |
1348 | | - | |
1349 | | - | |
1350 | | - | |
1351 | | - | |
1352 | | - | |
| 1350 | + | |
| 1351 | + | |
| 1352 | + | |
| 1353 | + | |
| 1354 | + | |
| 1355 | + | |
| 1356 | + | |
| 1357 | + | |
| 1358 | + | |
1353 | 1359 | | |
1354 | 1360 | | |
1355 | 1361 | | |
| |||
0 commit comments