Repository navigation
Commit b2e7870
fix(security): the single-posture platform-admin grant is owned by the Default Organization, and every reader of platform standing reads it (#24373)
Fixes #11979
Part of #15212
Clause-②: yes (narrowing)
C8 stage 8b-0a (plan 6251466182 on #15212). Under the `single` tenancy
posture the first-user platform-admin promotion keeps its zero-config
behaviour, and the `admin_full_access` grant row it writes is now
**owned by the Default Organization** (ADR-0131 D5, maintainer ruling A
on #18707). Every reader of platform standing reads that row beside the
organization-less one. The walled posture is unchanged.
## What changes
1. **The promotion writes the Default Organization**
(`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`).
The grant carries `organization_id` = the Default Organization: `slug =
'default'`, else the only organization. That is plugin-auth's
boot-invariant definition, resolved through the module's existing
`resolveDefaultOrganizationId`. A deployment that names no Default
Organization (several organizations, none `default`) is not promoted. It
logs one `error` line and returns `reason:
'default_organization_unresolved'`, and writes no grant without an
owner. The holder scan (leg A, and the predicate legs B and C apply)
reads the Default Organization's grant and the organization-less one. A
user holding both is one holder, and whichever row sorts first by `id`
answers.
2. **The resolver's anchor is one predicate with the posture as its
parameter** (`packages/core/src/security/resolve-authz-context.ts`).
`grantAnchorsPlatformStanding(grant, { walled, defaultOrganizationId
})`: under a wall nothing anchors (unchanged); under `single` the
Default Organization's grant and the organization-less row anchor. The
anchoring grant applies in whichever organization the principal acts in,
as the organization-less row always did. Any other organization's grant
of the set confers the set, never standing. `sys_organization` is read
only for a principal holding an organization-owned `admin_full_access`
grant under `single`, so the batch-equivalence goldens do not move.
3. **plugin-auth reads the same row**
(`ensure-default-organization.ts`). The grant anchor reads the Default
Organization's grant. Under `single`, step 4 reuses the Default
Organization by the invariant's definition (a sole organization with
another slug included) instead of minting a second, `default`-slugged
one that would take the anchor off the promoted grant. Under a wall only
the `default`-slugged one is reused, as before.
4. **The extra `sys_organization` reads, and when they run.** The
resolver reads it only under `single`, and only for a principal holding
an organization-owned `admin_full_access` grant: one small indexed read
when a `default`-slugged organization exists, two otherwise, per
uncached request (deduplicated within a request; the cross-request
grants cache is off by default). Every other principal reads nothing
more, which is why the batch-equivalence goldens (whose fixture holds
the organization-less row) do not move. The last-administrator guard
adds one or two reads per enumeration whenever an `admin_full_access`
grant names an owner, so about three to six small reads per guarded
write on `sys_user`, `sys_member`, `sys_user_permission_set`, the
activation ledger and `sys_organization`.
### Landing points outside the claim's file surface, and why
- `packages/plugins/plugin-auth/src/last-admin-guard.ts` is the
break-glass guard. Its enumeration, its "emptied rather than fresh"
evidence reads and its switched-off-set read all keyed on
`!organization_id`. Left alone, the promoted admin's grant would count
as no administrator, and the guard would wave through the ban or
revocation that ends the last one. It now reads the anchor the resolver
reads. Because the anchor now depends on `sys_organization`, it also
judges the two organization writes that move it: changing the Default
Organization's `slug` while another organization exists, and deleting
it.
- `packages/core/src/security/admin-standing-surface.ts` gains
`sys_organization` as a `derives` table (columns `id`, `slug`, measured
by `admin-standing-surface.test.ts`). The f8eb736 correspondence gate
turned red without it, and is green with the guard's new standing-key
list.
- No ceremony file is touched. A3 below shows the ceremony's attribution
is the intended D10 fate. The fix is on the readers' side.
## A3 — measured: the ceremony attributes the promoted admin's grant to
the Default Organization
On `18.0.0-next.3` semantics (this branch's base `e4bcced0`), with the
cli acceptance fixture of `organization-ownership-apply.test.ts` plus
one promoted-admin grant (`grant_admin`, `organization_id` NULL,
`permission_set` `admin_full_access`). This was a scratch copy of the
fixture run under `--project integration` and deleted afterwards:
```
A3-READING [["attribution","done"], …] [{"id":"grant_admin","user_id":"usr_admin","permission_set":"admin_full_access","organization_id":"org_default"}]
```
So after `os migrate organization-ownership --apply` the grant is the
Default Organization's. On the base tree that row conferred nothing. The
resolver read `!ownGrant` only: the new `[ADR-0131 D5]` resolver tests
fail on base (2 of 6). And the holder scan found no NULL holder and
promoted again: the new fresh-boot golden "an upgraded database whose
administrator's grant the ceremony gave the Default Organization
promotes nobody else" fails on base and passes here. That is the live
defect the dispatch suspected. The `rewrite` step blocked in that
scratch run only because the fixture's catalog double serves no
`admin_full_access`, which has nothing to do with attribution.
## Tests
On the final head `bc90a53af6` (after merging `origin/main`):
- `pnpm --filter @objectstack/core test`: 104 files, 2558 passed.
- `pnpm --filter @objectstack/plugin-security test`: 188 files, 3739
passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-auth test`: 162 files, 3140 passed,
10 skipped.
- `typecheck` for core, plugin-security and plugin-auth: exit 0
(plugin-auth after its build, which its examples project reads).
- Platform-admin dogfood (`packages/qa/dogfood`, on a full build):
`admin-platform-admin-standing`, `org-admin-affordance-reach` and
`admin-credential-lifecycle` pass, 26 tests. The boot log shows `first
user promoted to platform admin: admin@objectos.ai` three times and no
withheld promotion.
New or flipped pins assert the substance:
- the fresh-boot golden's grant owner `null` becomes `org_default`;
- an attributed grant is a holder, and nothing is re-promoted;
- a database naming no Default Organization withholds at `error` and
writes nothing;
- the resolver confers standing from the Default Organization's grant,
in it and with no active organization, and from a sole organization of
any slug, but not from another organization's grant, not with several
organizations and none `default`, and never under a wall, which reads no
organization;
- the guard counts the holder and refuses the ban, the revocation, the
re-scoping, the re-slugging and the delete; the controls are another
organization's grant and a sole organization's slug or name change;
- `ensureDefaultOrganization` binds the Default Organization's grant
holder.
The test fixtures that promote now hold the Default Organization the
`single` boot creates.
**Reverse verification.** The fix was committed first. The four source
files were then restored to base `e4bcced0` and the new tests run, and
the files restored to HEAD (proved by an empty `git diff HEAD` and
HEAD-blob hash equality). Results on base: core D5 suite 2 failed;
plugin-security fresh-boot golden 3 failed; plugin-auth guard and
default-org suites 7 failed. All green on HEAD.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands` derived 73 families at
`11016ab9`. All 73 ran with exit 0 after a full build (`check:i18n` and
`check:dual-build-cjs-loads` answered PREREQUISITE NOT MET before it,
and green after). `--ran` reconciliation: "73 derived famil(ies)
accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero)". The derivation
is unchanged on the final head. A subset (`check:published-files`,
`check:cross-package-test-inputs`, `check:durability-log-level`,
`check:startup-registry-verdict`, `check:issue-citations`,
`check:doc-authoring`) was re-run after the last source commit, all exit
0. Every family CI runs is read from this PR's check-runs.
## Acceptance notes
- Clause-②: yes (narrowing). Breaking, shipped as `minor` on all three
packages under the launch-window convention; the changeset carries the
three FROM → TO pairs with their fixes and the ADR-0087 disposition
`not-required (no-migration-prescription)`. Narrowings: (1)
plugin-security — a `single` deployment with several organizations and
none slugged `default` is no longer promoted (one `error` line, `reason:
'default_organization_unresolved'`, no row); fix: slug the intended
organization `default`. (2) plugin-auth — a `sys_organization` `slug`
change or delete that would leave no administrator who can sign in is
refused (403 `PERMISSION_DENIED`); fix: give another account
administrator standing first. (3) plugin-auth —
`ensureDefaultOrganization` under `single` reuses a sole organization of
any slug instead of creating a second `default`-slugged one. Widening:
under `single` a Default-Organization-owned `admin_full_access` grant
now confers `PLATFORM_ADMIN` and applies in every organization (a
pre-existing Default-Organization-scoped grant gains the rung on
upgrade), and `sys_organization` becomes a standing-deriving table. No
spec, REST answer or package entry changes: the new core helpers and the
guard's organization key list are module-private; the bootstrap's return
gains one `reason` string.
- Line budget: the dispatch estimated about 200 source lines. This lands
about 620 source lines and 425 test lines. The extra source is the
break-glass guard (enumeration, evidence reads, organization hooks),
which the anchor change made necessary, plus its explanatory comments.
- Not in this PR: the organization-less reader's retirement (stage 8c-2)
and engine-wide system-insert stamping (stage 8b). The organization-less
row still anchors under `single`, by D5's "until D10's migration clears
it".
- #11979's Constraints paragraph ("config now required for admin") is
superseded by ruling A, as the card's discharged note says. Nothing here
requires config under `single`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d65826f commit b2e7870
23 files changed
Lines changed: 918 additions & 156 deletions
File tree
- .changeset
- packages
- 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 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
268 | 268 | | |
269 | 269 | | |
270 | 270 | | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
271 | 292 | | |
272 | 293 | | |
273 | 294 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
101 | | - | |
102 | | - | |
103 | | - | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
| |||
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
129 | | - | |
130 | | - | |
131 | | - | |
132 | | - | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
133 | 135 | | |
134 | 136 | | |
135 | | - | |
136 | | - | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
137 | 140 | | |
138 | 141 | | |
139 | 142 | | |
| |||
148 | 151 | | |
149 | 152 | | |
150 | 153 | | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
151 | 169 | | |
152 | 170 | | |
153 | 171 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
655 | 655 | | |
656 | 656 | | |
657 | 657 | | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
0 commit comments