You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit abc25a3
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-auth)!: the last-admin guard and the default-org bootstrap count only the Default Organization's admin_full_access grant (#25141)
Fixes#24883
Clause-②: yes (narrowing)
Metadata-protocol: none
Seat decision 6324268472 (option A). The measurement behind it is
6324239568.
## Why
Since 18.x step 7a-2, `resolveAuthzContext` gives an `admin_full_access`
grant platform standing only under `single`, and only when the Default
Organization owns it (`grantAnchorsPlatformStanding`). An
organization-less grant anchors nothing in any posture (ADR-0131 D1/D5).
Two plugin-auth readers still counted such a grant:
- the last-admin guard's `anchorOwnerTest`;
- the default-organization bootstrap's grant read.
Two compositions can still hold that row:
- a lean embedding of `registerLastAdminGuard` with no
`sys_organization`, over a tenant column that accepts NULL
(driver-memory enforces no NOT NULL; a pre-7b-0 SQL table is nullable);
- a host outside `os serve`, on a database built before 18.x.
In both, the guard believed an administrator remained where the resolver
saw none. It **permitted** banning the deployment's only platform
administrator, the verified `OS_PLATFORM_OWNER_EMAIL` account. This PR
closes that fail-open.
## What changed
- **`last-admin-guard.ts`**
- `anchorOwnerTest` reads the resolver's rule: a grant anchors only when
the Default Organization owns it. Under a wall, or with no nameable
Default Organization, none anchors.
- The header (grade 1), the docblocks and the refusal texts no longer
say "unscoped" or "or by none".
- The walled-remedy clause of the emptied-not-fresh refusal is retired.
Under a wall no grant is evidence any more, so the clause could not be
reached.
- **`ensure-default-organization.ts`:** the bootstrap reads only the
Default Organization's grant, and no grant when none is nameable. The
organization-less read is gone.
- **Tests** (the 81 cases the measurement predicted turned red at the
first source commit, and all are re-fixtured; none is skipped or
weakened):
- Guard suites (47):
- each engine registers `sys_organization` with a `default` row, and the
platform-admin grant is owned by it;
- the suites now run on a fresh NOT NULL store
(`relaxEveryRegisteredTenantColumn` dropped), except where a case
deliberately models a pre-18 row;
- `sqliteEnginePool` gains `keepBootOrganizations`, which keeps the
boot's organization rows and refuses a case that changed them.
- The config-anchor L5 suite now pins BOTH keys, posture and owner, in
both directions:
- an organization-less holder is no survivor under `single` either: the
new direction;
- the Default Organization's grant still anchors under `single`.
- Bootstrap suites (34): the fake engines hold the Default Organization
the boot invariant creates, and the promotion's grant is owned by it.
The org-create cases go through the config anchor, the one population
that needs no organization first.
- New `last-admin-guard.anchor-parity.test.ts` (13 cases) runs a real
engine with the real resolver:
- the write-feasibility matrix on sqlite, with the tenant column
nullable where the row models a lean embedding or a pre-18 store; with
and without the organization object; 0, 1 and 2 organizations; `single`
and `isolated`. Each case asserts what the engine does with an
organization-less grant, then that the guard counts its holder exactly
when the resolver does;
- **the negative pin, failure direction (ii)**, on a lean embedding with
no organization object over a nullable tenant column, and on a pre-18
store: banning the resolver's only platform administrator is refused;
- the positive control.
- Two new bootstrap pins: an organization-less grant binds nobody, and
with no nameable Default Organization no grant is read.
- **Deviation from the dispatch:** the lean-embedding pin runs on sqlite
with a nullable tenant column, not on driver-memory.
`check:driver-memory-census` refuses a new driver-memory consumer
without a maintainer ruling, and the guard takes the same path on both
drivers. No dependency is added.
- `content/docs/permissions/authorization.mdx`: the platform-admin
anchor wording, and the embedder's remedy.
- One `patch` changeset with the BREAKING banner, the FROM → TO, and the
embedder's remedy (D5's config anchor plus membership).
## Verification (local, before CI)
- `@objectstack/plugin-auth`: 3448 tests, 3438 passed, 0 failed, 10
skipped. `pnpm typecheck` exit 0, after a full DTS build of the
plugin-auth closure.
- `@objectstack/organizations`: 233/233.
- Ablation, each restored blob == HEAD:
- putting the organization-less half back in `anchorOwnerTest` turns 6
cases red: the matrix's no-organization-object row, both negative pins,
and three config-anchor pins;
- putting the bootstrap's null element back turns the 2 new bootstrap
pins red.
- `check-governed-merges --branch`: NOT governed, 0 of 15 paths hit the
register (14 at `980cdc13c`, plus `permission-sets.mdx` in `bc359a7c`).
- The full gate list, the dogfood battery (organization-delete included)
and R1/R4 are reported on the card in the os-dev report, against the
head they ran on.
## Acceptance notes
- The guard's `judgeOrganizationDelete` (#25016) asks the same count, so
it inherits the fix. Its pins are re-run with the dogfood battery.
- File surface: the claim names the two source files, their tests, the
docs page and one changeset. The PR stays inside it: no `package.json`
or lockfile change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p
---
_Generated by [Claude
Code](https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth)!: the last-admin guard and the default-organization bootstrap count only the Default Organization's `admin_full_access` grant — an organization-less grant is no administrator
6
+
7
+
Clause-②: yes (narrowing)
8
+
Metadata-protocol: none
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The rows this reads differently exist only on a database the ownership ceremony has not certified, or in a composition that registers no organization object. -->
11
+
12
+
**BREAKING** for a host that holds an `admin_full_access` grant row with no `organization_id` (`NULL`, absent or blank). Since the resolver stopped reading such a row as platform-admin standing (ADR-0131 D1/D5), the two plugin-auth readers that still counted it now read it the same way.
13
+
14
+
-**`registerLastAdminGuard` (the last-administrator guard).** FROM: an organization-less `admin_full_access` grant counted as a platform administrator under `single`, beside the Default Organization's. TO: only the Default Organization's grant counts, and only under `single`; with no nameable Default Organization (a composition that registers no organization object among them) no grant counts. The configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, verified accounts) and organization owners and admins count as before.
15
+
- The guard now **refuses** a write it used to permit: ending the standing of the last administrator the resolver recognises while an organization-less holder exists. Counting that holder let the deployment's only platform administrator be banned, deleted or downgraded.
16
+
- It now **permits** a write it used to refuse: banning, deleting or revoking an organization-less holder who is nobody's administrator.
17
+
- Under a walled posture, a switched-off or dangling organization-less grant is no longer "emptied environment" evidence, and the refusal no longer carries its walled remedy sentence (no grant is evidence there).
18
+
-**`ensureDefaultOrganization` (the bootstrap's grant anchor).** It reads only the Default Organization's `admin_full_access` grant, and no grant when no Default Organization is nameable. An organization-less grant holder is never bound as the Default Organization's owner. The configured owner is read first, as before.
19
+
20
+
**Who sees it.** No served composition: a fresh install, a certified 18.0.0 database and a ceremonied database hold no organization-less grant, and the engine derives or refuses one wherever the organization object is registered. What changes is a host that embeds the guard on an engine with no organization object (for example on driver-memory), or runs plugin-auth outside `os serve` on a database the ceremony has not certified.
21
+
22
+
**What an embedder does.** Anchor your administrators the way ADR-0131 D5 names: declare them in `OS_PLATFORM_OWNER_EMAIL` (their accounts verified), or give them an owner or admin `sys_member` row. Or register the organization object (`sys_organization`, which plugin-auth ships) and own the grant by the Default Organization. On a database 17.x or an 18.0 pre-release created, run `os migrate organization-ownership`, which attributes every organization-less grant to an organization.
0 commit comments