Skip to content

Commit 74832b6

Browse files
huangyiireneclaude
andauthored
feat(security)!: retire the walled legacy platform-admin grant dual read and its deprecation log (#11663 L5) (#19136)
Fixes #18336 Clause-②: yes Leg **L5** of the accepted #11663 platform-admin re-anchor design — the **EXIT** of the migration window L4 opened in 17.3.0. Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`, and the once-per-process deprecation pointer that announced this retires with it. ## The ruling this is built against, and the two constraints it did not touch The maintainer ruled option **B** on 2026-09-16 (`issuecomment-5695555178`): #11663's design governs over ADR-0131 D13 for these two symbols, and the card stays on the **17.x** line. ADR-0131 was amended accordingly on `origin/main` before this branch was cut — verified here, not inherited: commit `0b271e2e`, `docs/adr/0131-total-organization-ownership-no-null-organization-id.md`, and the amended **D5** states the scope in its own words: > The legacy unscoped anchor retires with D13 **for the `single` posture only**: its **walled** half, together with the two symbols that carry the migration window — `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` — **left D13 on 2026-09-16** and retire on the **17.x** line under #11663's design instead (#18336). Two standing constraints the ruling did **not** touch, and which make the scope walled-only rather than all-postures: > The rest of Choice 4A (#11974, 2026-08-25) stands: **retiring the walled write must not retire the `single` one** (maintainer, 2026-09-08, #16682 comment `5587754690`) > 比如临时启动的开发环境,我不可能去配置啊 (maintainer, 2026-09-16, the session that produced the ruling — the zero-config constraint) ⇒ The `single` posture keeps its zero-config first-user promotion **and** its grant row. Its disposition is #11979's (Choice 4B), out of scope here. The all-postures reading was measured on this card before dispatch and turns **11 pins red**; this branch does not take it, and the pin `an unscoped admin_full_access grant still confers PLATFORM_ADMIN with no config at all` is green and kept deliberately. ## What changed **`packages/core/src/security/resolve-authz-context.ts`** - §6b: the legacy row-id route to `hasPlatformAdminGrant` is posture-keyed. A new `legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture())`, hoisted once beside the `unscopedUserPsIds` set, gates the one line that derives standing from an unscoped `admin_full_access` grant. - The `else if (hasPlatformAdminGrant)` arm is **removed**. It carried one thing — the deprecation pointer — and the chain collapses to a bare `if (configConfersPlatformAdmin)`. **`packages/core/src/security/platform-admin.ts` / `security/index.ts`** - `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are deleted, along with their once-per-process latch, and dropped from the re-export list. **`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`** - The boot-side pointer call and the `sys_user` read that fed it are gone. - ⭐ **The fail-closed backstop is no longer skipped when a legacy holder exists.** L4 skipped it on the stated ground that 「the deployment HAS an administrator, on the old anchor」 and that the pointer carried the remedy instead. Both premises die with the walled dual read: such a rig now has **zero** platform administrators. It is told, at `error`, naming the variable, the row and its holder. ## The boundary this card draws, stated because it is easy to read wider than it is The retirement takes the **anchor**, not the **row** and not the **grant**. A walled holder keeps the `admin_full_access` permission set they hold — its name and everything downstream resolves from that name — and loses only platform-admin **standing**: the rung, the built-in `platform_admin` position, and every consumer of those. Nothing here writes, deletes or re-owns a grant row; that row's ownership is ADR-0131 **C3**'s, on the v18 line, and the amendment says the drop 「adds no NULL row, removes none, and re-owns none」. `packages/spec` is untouched (this lane's standing red line). That boundary is **pinned**, not merely described: `the retirement takes the ANCHOR, not the GRANT — the held set still grants what it grants`. If a later leg widens to the row, that pin is the one to re-author deliberately. ## Evidence **Clause-② confirmed on the rebuilt artefact**, not on source alone (`turbo run build --filter=@objectstack/plugin-security`, 18/18 tasks, exit 0): | probe on `packages/core/dist` | `reportLegacy…` | `resetLegacy…` | positive control | negative control | |---|---|---|---|---| | occurrences in `index.d.ts` | **0** | **0** | `matchesConfiguredPlatformAdmin` 4, `setPlatformAdminConfigSink` 2 | — | | `name in await import('index.js')` | **false** | **false** | both `true`, `typeof function` | `notAnExport_xyz` false | ⇒ two symbols leave a published entry. Breaking shape, graded `minor` under the launch-window convention, on `@objectstack/core` **and** `@objectstack/plugin-security`. **Negative-pin dissolution.** Every negative pin this branch relies on was dissolved: the forbidden behaviour was put back on disk (anchor hit verified, blob hash before/after recorded), the suite was watched go red, and the restore was proven by `blob == HEAD` and an empty `git diff HEAD` — not by an exit code. | pin family | mutation | result | |---|---|---| | walled rigs derive nothing from the legacy row | delete the `&& !legacyGrantAnchorRetired` gate | **5 failed / 26 passed** — the two walled-standing arms, the anchor-vs-grant boundary pin, the seeded-read arm, and the fourth-corner pin | | the boot fail-closed line is no longer skipped | re-wrap the emission in `if (!unscopedHolder)` | **1 failed / 23 passed** — `expected "vi.fn()" to be called 1 times, but got 0 times` | | the symbol is gone from the module | add a stub `export function reportLegacyPlatformAdminGrant` to `platform-admin.ts` | **1 failed / 24 passed** — the module pin only; the entry pin stayed green | | the symbol is gone from the published entry | add the export to `security/index.ts` | **1 failed / 24 passed** — the entry pin only; the module pin stayed green | The last two are deliberately separate: each mutation reds exactly one of the two absence pins, which is what makes them two measurements rather than one restated twice. ⛔ Per the security family's disclosure discipline, the evidence above describes the consequence of a **proposed** mutation to this branch and carries no reproduction recipe for the shipped product. **A measurement defect found and fixed in the suite's own double.** The pin `adds NO read: the recorded query multiset is identical under both answers of the gate` failed on first run, reporting a `sys_position` read whose `$in` differed by `platform_admin`. It was not a real difference: the resolver issues `{ name: { $in: grants.positions } }` — a live reference to an array §6c mutates **after** the query is issued — and the double stored `opts.where` by reference, so the comparison read the array's final contents instead of the query as issued. The double now deep-copies at record time, and the two postures are measured byte-identical. The same latent shape in a **sibling** harness is reported below as an out-of-scope finding. **Tests and gates.** `pnpm typecheck` green for `@objectstack/core` and `@objectstack/plugin-security` (`VERDICT command-exit 0`). Targeted suites: `resolve-authz-context.platform-admin-config.test.ts` + `platform-admin.test.ts` **56 passed**, `bootstrap-platform-admin-walled-owner.test.ts` **24 passed**. Gate families were re-derived from the real diff with `scripts/pm/dispatch-gates.mjs --commands` (no paths passed) and reconciled with `--ran`. ## Acceptance notes - `docs/qa/platform-checklist/areas/access-security.json` carries a clause describing the retired behaviour (「the once-per-process [authz] pointer names the OS_PLATFORM_OWNER_EMAIL config line」) and names `reportLegacyPlatformAdminGrant` in an anchor's parenthetical. It is **outside this dispatch's declared file surface**, so it is not touched here; the anchor symbol itself is `parsePlatformAdminEmails`, which still resolves. Noted for whoever owns the checklist. - `PlatformAdminConfigSink.warn` now has no caller inside `@objectstack/core`. It is left in place deliberately: removing it would be a **third** published-surface removal, wider than this card, and it would break every consumer that passes an object literal with a `warn` member. - ADR-0131's own text is untouched — it was amended for this leg by #18413 before this branch existed. ## Patch round 1 — the contract review's findings 1, 2 and 4 The at-tier review (`issuecomment-5737292414`) returned FAIL on one blocking finding and two cheap ones. All three are addressed in this branch; nothing that PASSED was touched. ### Finding 1 (blocking) — the retirement was done at one of the anchor's TWO readers `packages/plugins/plugin-auth/src/last-admin-guard.ts` enumerated platform administrators from the same unscoped, in-window `admin_full_access` row on **every** posture, while §6b had stopped deriving standing from it under a wall. One anchor, two readers, disagreeing — and the disagreement is introduced by this diff, so it is fixed here rather than sequenced. Grade 1 of `resolveAdminUserIds` is now keyed on the identical expression the derivation site uses (`postureEnforcesWall(resolveTenancyPosture())`), hoisted once per enumeration; under a wall the grade's `sys_permission_set` scan is skipped outright, so that path also issues one read fewer. `single` is untouched, and the two zero-population refusals gained a conditional clause naming `OS_PLATFORM_OWNER_EMAIL`, because 「restore the `admin_full_access` row」 stopped being a remedy that ends the emptiness on a walled rig. Direction, stated because it reads backwards easily: under a wall the guard now refuses **more** (it protects the config-anchored administrator a write used to be allowed to take away) and refuses **wrongly** less (revoking the inert row is no longer reported as removing the last administrator). Both are narrowings toward the already-declared model. **Walled coverage was as much the finding as the logic.** `last-admin-guard.config-anchor.test.ts` gains a seven-case `[#11663 L5]` suite that drives both walled postures and pairs every walled arm with a `single` control on the identical fixture. The two posture-blind sibling suites (`last-admin-guard.test.ts`, `last-admin-guard.re-pricing.test.ts`) now pin `single` explicitly instead of inheriting whatever the box exports, and the pre-L5 pin 「ALLOWS it when the same user also holds the legacy grant」 says `single` in its title. Dissolution, both new pin families: | mutation | result | |---|---| | remove the grade-1 posture gate | **2 failed / 16 passed** — the blocking arm fails with `promise resolved "{ id: 'usr_owner', … email_verified: false }" instead of rejecting`, i.e. the write that ends the last config-anchored standing goes through; the inverse arm fails with it. Every `single` control stayed green. | | force the walled remedy clause empty | **1 failed / 17 passed** — only the remedy arm; the refusal itself fired unchanged, which is what makes the clause additive and conditional rather than a new verdict. | ### Finding 2 — the ② naming gap Closed by naming, not by stating a precedent. `packages/runtime/src/index.ts:247` and `packages/plugins/plugin-hono-server/src/adapter.ts:4` are `export * from '@objectstack/core'` (re-verified here; a repo-wide grep finds exactly those two), so the two removed names leave those published surfaces too. Both are now in the changeset, alongside `@objectstack/plugin-auth`, whose behaviour this round changes. The level is unaffected — all publishable packages sit in one `fixed` group — and the reason for naming them is the CHANGELOG tombstone an upgrading consumer of *those* packages greps. This follows the `ApiRegistry` precedent rather than the core-only one. ### Finding 4 — prose `admin-standing-surface.ts`'s `sys_user_permission_set` reason is posture-qualified, and so is the module header's account of where `hasPlatformAdminGrant` comes from. ⚠️ The first attempt cited the tracker ids and `check:doc-authoring` refused it — that `reason` is a runtime string that reaches customers, and the baseline is shrink-only. The sentence now says the same thing without them. ### Finding 3 Not touched here, by instruction — the seat filed it separately as #19145. <sub>⚠️ This section was appended by the `domain:services` seat, ⛔ not by the delivering dev: the dev writes a PR body once at creation and never PATCHes it, so it handed the text over in its report. The seat verified the load-bearing claims against the tree before appending — the guard is posture-keyed at `last-admin-guard.ts:941` (control on `origin/main`: 0 occurrences) and the changeset names all five packages — and ⛔ did not verify every figure quoted above, which is the next reviewer's to test.</sub> ## Patch round 2 — the last unkeyed reader of the anchor The at-tier review of `61a951c97` (`issuecomment-5737610351`) returned FAIL on one blocking finding: the retirement was still done at only some of the anchor's readers. This round closes the class rather than one instance. ### The reader set, re-measured here and now closed #11663's design P3 enumerates the in-repo consumers of 「who are the platform admins?」. Their state on this head, each verified in this branch rather than inherited: | reader | how it answers | state | |---|---|---| | `resolve-authz-context.ts` §6b | derives | keyed (round 0) | | `plugin-security/src/bootstrap-platform-admin.ts` | reads the row at boot | keyed (round 0) | | `plugin-auth/src/last-admin-guard.ts` | counts the population | keyed (round 1) | | `plugin-security/src/explain-engine.ts` | **consumes the verdict** | ✅ needs nothing | | `plugin-auth/src/ensure-default-organization.ts` | **confers** on the population | keyed (**this round**) | `explain-engine.ts` was checked, not assumed: at `:562` it sets `hasPlatformAdminGrant: grants.posture === 'PLATFORM_ADMIN'`, reading the resolver's one verdict back, and its only `sys_user_permission_set` read is `collectGrantProvenance`, which reports *why grants were dropped* and derives no standing. It moved with §6b in round 0. **An independent sweep says the set is closed.** A grep for source reads of an *unscoped* `admin_full_access` grant (`organization_id: null` beside the admin permission set, non-test) returns exactly two files in the whole tree — `bootstrap-platform-admin.ts` and `ensure-default-organization.ts` — both now keyed. The remaining files that merely name `ADMIN_FULL_ACCESS` were read: a config-validation refusal, the spec declaration, the seeded set definition, a CLI formatter, sandbox type assertions, and the two already-keyed derivation sites. ### What changed **`ensureDefaultOrganization` step 2** — its legacy fallback is keyed on the same expression the derivation uses. This reader is worse than the guard: the guard *counted* a holder, this one **confers** — it writes the account it picks a `sys_member { role: 'owner' }` row on the Default Organization and hands them the org's seeded rows through `claimSeedOwnership`. Unkeyed, a walled rig carrying a legacy row bound, as an organization owner, an account the derivation grades `MEMBER` and the bootstrap logs at `error` as non-existent. Reachable despite plugin-auth skipping the helper under a wall (`auth-plugin.ts:1100`), because `@objectstack/organizations` — which a walled posture requires — wraps and calls it. Under a wall with no verified declared owner, the answer is now `no_admin`: **a wait, not a silent mis-binding.** The declared owner's verifying update re-runs the helper through the trigger predicate, and the binding happens then. **The trigger predicate's grant-insert arm** retires with it under a wall — cost only, since the helper answered `no_admin` from that grant either way. ⛔ The `sys_user` arms are deliberately untouched: on a walled rig the declared owner's verifying update is the only write that ever grows the population, so keying that off would strand the deployment forever. The sibling predicate `shouldReplayBootstrapFor` in `plugin-security` already spells the same walled exclusion. **Coverage.** `ensure-default-organization.test.ts` had 0 posture-env sites; it now pins `single` for the whole file instead of inheriting the ambient environment, and gains a seven-case `[#11663 L5]` walled suite in which every walled arm is paired with a `single` control on the identical fixture — including the zero-config default (both tenancy inputs unset), a no-added-read pin, and the config anchor still binding under a wall. Dissolution of the two new pin families: | mutation | suite | result | |---|---|---| | remove the step-2 posture gate | `ensure-default-organization.test.ts` | **3 failed / 38 passed** — the walled binding arm fails with `expected { defaultOrgCreated: true, … } to match object { defaultOrgCreated: false, … }`, i.e. the organization really is created and the legacy holder really is bound; the unverified-owner wait and the no-added-read pin fail with it. Every `single` control stayed green. | | the same mutation | `walled-default-org-self-registrant.pin.test.ts` | **1 failed / 4 passed** — the re-authored pin, and only it. The surviving positive control stayed green, so the inverted assertion is falsifiable rather than vacuous. | | delete the trigger's walled exclusion | `ensure-default-organization.test.ts` | **1 failed / 40 passed** — only the trigger arm; the helper's own answers were untouched, which is what makes it a cost fix and not a verdict change. | ### The one declared out-of-lane change `packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts` pinned the pre-L5 model: its positive control asserted that under `isolated` a legacy grant still anchors the bootstrap, saying in its own words that the negative cases were 「green only because objectstack#11211 stopped minting that row on a walled box, not because this path is inert」. This diff makes that path inert, so the pin is **inverted rather than deleted** — the fixture is cloud#1509's reported shape and it is the only place in the tree that pins what a walled rig does with a legacy row at this seam. The suite's method survives: the operator case above it (a declared address reading verified) carries the positive-control role, and the header now records that clause 2 rests on the keying rather than on what gets minted. ⚠️ `packages/plugins/organizations` is **not** in this lane's named package set. This is a deliberate, declared, **test-only** widening forced by CI — its runtime source is untouched. ### Changeset `@objectstack/organizations` is added at `minor`. ⛔ Not because a test was re-authored — that earns nothing. It is named because the behaviour an operator gets *from that package* moves: it is the runtime that performs the default-organization bootstrap on a walled deployment, and after this change it no longer creates a Default Organization for a legacy grant holder, nor binds them as its owner. The tombstone belongs in the CHANGELOG of the package whose behaviour changed, which is the same reasoning that named `@objectstack/runtime` and `@objectstack/plugin-hono-server` in round 1. ### One thing named rather than left to be found `plugin-approvals/src/approval-service.ts:1402` decides its override actor with `posture === 'PLATFORM_ADMIN' || perms.includes(ADMIN_FULL_ACCESS)`. Its first arm moved with §6b in round 0; its second reads the permission-set NAME, which a walled legacy holder still carries — the declared 「the retirement takes the ANCHOR, not the GRANT」 boundary this PR pins. ⚠️ Measured: this diff does **not** change that function's answer for such an account (before, both arms were true; after, the second still is), so it is not a dual-track this PR opens and it is not fixed here. It belongs to whatever leg disposes of the row itself — ADR-0131 C3 on the v18 line, and #11979 for `single`. <sub>⚠️ Appended by the `domain:services` seat, ⛔ not by the delivering dev, which writes a body once at creation and never PATCHes it. The seat independently re-ran the closure sweep with a **different, cruder** instrument (file-level co-occurrence of the token with an `organization_id: null` spelling) and reached the same conclusion by a different route: its extra hits — `sys-user-position.object.ts`, `runtime/src/security/actor-user.ts`, `spec/src/identity/eval-user.zod.ts` — were each read and are a refusal's explanatory comment, a docstring example, and the spec's own constant declaration. ⛔ None derives the population. The seat also verified the surface (16 files, nothing outside the authorised set, zero `packages/spec`, the `organizations` touch test-only). The dissolution figures above are the dev's and are the next reviewer's to test.</sub> --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5eebc9e commit 74832b6

16 files changed

Lines changed: 1045 additions & 388 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-auth": minor
5+
"@objectstack/runtime": minor
6+
"@objectstack/plugin-hono-server": minor
7+
"@objectstack/organizations": minor
8+
---
9+
10+
**Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5).
11+
12+
⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record.
13+
14+
**What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired.
15+
16+
- **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5).
17+
- **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line.
18+
- **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer.
19+
- **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And:
20+
- **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there.
21+
- **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason.
22+
- **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached.
23+
24+
<!-- adr-0087: not-required (no-migration-prescription) Nothing here is a metadata surface: the two removed symbols are plain runtime functions in `packages/core/src/security/platform-admin.ts` with no Zod schema, no `packages/spec` declaration and no stored representation, and the behaviour change is an authorization derivation keyed on an environment variable. `objectstack migrate meta` therefore has nothing to rewrite — the channels that reach an affected consumer are the compiler (for the removed exports) and the boot-time fail-closed log line (for the walled standing). No grant row is written, deleted or re-owned by this change; that rows own migration is ADR-0131 D10/C3 and stays on the v18 line. The plugin-auth guard change and the two re-export packages add no metadata surface either. -->

‎packages/core/src/security/admin-standing-surface.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,10 @@ export interface AdminStandingTable {
9999
* authenticates a principal and seeds `permissions` with the key's scopes, and
100100
* confers no administrator standing of its own — `hasPlatformAdminGrant` is set
101101
* from a `sys_permission_set` row reached through an UNSCOPED
102-
* `sys_user_permission_set` grant (§6b) or from the deployment config matched
103-
* against the caller's own STORED `sys_user` row (§6b-config), never from a
104-
* scope string and never from the caller-seedable `grants.email`.
102+
* `sys_user_permission_set` grant (§6b, on a NON-WALLED posture only since
103+
* #11663 L5) or from the deployment config matched against the caller's own
104+
* STORED `sys_user` row (§6b-config, on every posture), never from a scope
105+
* string and never from the caller-seedable `grants.email`.
105106
*/
106107
export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>> = {
107108
sys_permission_set: {
@@ -130,9 +131,14 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
130131
sys_user_permission_set: {
131132
role: 'derives',
132133
reason:
133-
'The grant that makes a user a platform admin: an UNSCOPED, in-window (ADR-0091) grant of '
134-
+ '`admin_full_access` (§6). Re-pointing it, scoping it to an organization or moving it out '
135-
+ 'of its window revokes the standing while leaving the row in place.',
134+
'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an UNSCOPED, '
135+
+ 'in-window (ADR-0091) grant of `admin_full_access` (§6b). Re-pointing it, scoping it to an '
136+
+ 'organization or moving it out of its window revokes the standing while leaving the row in '
137+
+ 'place. ⚠️ Under a WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half '
138+
+ 'of this legacy anchor is retired, so standing there comes from `OS_PLATFORM_OWNER_EMAIL` '
139+
+ '(§6b-config) and from nothing else, and this row is read only for the permission set it '
140+
+ 'names. Under `single` — the default — the row is unchanged and still the anchor that '
141+
+ "rig's zero-config first-user promotion mints.",
136142
columns: [
137143
'user_id',
138144
'permission_set_id',

‎packages/core/src/security/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ export {
213213
// (plugin-auth's break-glass guard, plugin-security's bootstrap, the audit
214214
// surface) ask the SAME question instead of re-implementing the parse — which
215215
// is the whole reason the config read has exactly one home.
216+
//
217+
// [#11663 L5] ⛔ `reportLegacyPlatformAdminGrant` and
218+
// `resetLegacyPlatformAdminGrantReport` are NO LONGER exported — they are gone.
219+
// They carried L4's time-boxed migration notice, and L5 is that window's exit.
216220
export {
217221
PLATFORM_ADMIN_EMAIL_SEPARATOR,
218222
normalizePlatformAdminEmail,
@@ -221,8 +225,6 @@ export {
221225
resetPlatformAdminEmailMemo,
222226
matchesConfiguredPlatformAdmin,
223227
isConfiguredPlatformAdminEmail,
224-
reportLegacyPlatformAdminGrant,
225-
resetLegacyPlatformAdminGrantReport,
226228
setPlatformAdminConfigSink,
227229
type PlatformAdminEmailConfig,
228230
type PlatformAdminConfigSink,

‎packages/core/src/security/platform-admin.test.ts‎

Lines changed: 42 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,13 @@
1111
* row it has no business looking at.
1212
*/
1313

14-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
14+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1515

1616
import {
1717
isConfiguredPlatformAdminEmail,
1818
matchesConfiguredPlatformAdmin,
1919
normalizePlatformAdminEmail,
2020
parsePlatformAdminEmails,
21-
reportLegacyPlatformAdminGrant,
22-
resetLegacyPlatformAdminGrantReport,
2321
resetPlatformAdminEmailMemo,
2422
resolvePlatformAdminEmails,
2523
setPlatformAdminConfigSink,
@@ -41,7 +39,6 @@ beforeEach(() => {
4139
ambient = process.env[ENV];
4240
delete process.env[ENV];
4341
resetPlatformAdminEmailMemo();
44-
resetLegacyPlatformAdminGrantReport();
4542
sink = makeSink();
4643
setPlatformAdminConfigSink(sink);
4744
});
@@ -50,7 +47,6 @@ afterEach(() => {
5047
if (ambient === undefined) delete process.env[ENV];
5148
else process.env[ENV] = ambient;
5249
resetPlatformAdminEmailMemo();
53-
resetLegacyPlatformAdminGrantReport();
5450
setPlatformAdminConfigSink(undefined);
5551
});
5652

@@ -244,55 +240,49 @@ describe('matchesConfiguredPlatformAdmin — verified match only, fail closed',
244240
});
245241
});
246242

247-
describe('[#11663 P5] reportLegacyPlatformAdminGrant', () => {
248-
it('names the holder, the variable and the line to add — once per process', () => {
249-
reportLegacyPlatformAdminGrant({ userId: 'usr_1', email: 'Ada@Example.com' });
250-
reportLegacyPlatformAdminGrant({ userId: 'usr_2', email: 'bob@example.com' });
251-
expect(sink.warns).toHaveLength(1);
252-
expect(sink.warns[0]).toContain('usr_1');
253-
expect(sink.warns[0]).toContain(`${ENV}=ada@example.com`);
254-
expect(sink.warns[0]).toContain('admin_full_access');
255-
});
256-
257-
it('falls back to a placeholder when the row was never loaded', () => {
258-
// The notice must never force a `sys_user` read of its own — see the
259-
// §6b-config branch, which passes the memoized row only if it is already
260-
// there. A fully-seeded API-key principal has no row loaded.
261-
reportLegacyPlatformAdminGrant({ userId: 'usr_1' });
262-
expect(sink.warns[0]).toContain(`${ENV}=<the administrator's verified email address>`);
243+
/**
244+
* [#11663 L5] The migration pointer is RETIRED — an ABSENCE pin, because absence
245+
* is the only thing left to assert about it.
246+
*
247+
* L4 opened a time-boxed, loud migration window and this module carried its
248+
* pointer: `reportLegacyPlatformAdminGrant`, latched once per process, plus
249+
* `resetLegacyPlatformAdminGrantReport` to drop the latch for tests. L5 closed
250+
* the window — under a walled posture the row the pointer pointed away from is
251+
* no longer an anchor at all (`resolve-authz-context.ts` §6b), and under
252+
* `single` the pointer never fired (it was posture-keyed by #13667). Both
253+
* symbols are gone, from this module AND from `@objectstack/core`'s published
254+
* entry.
255+
*
256+
* ⛔ This pin is not decoration. The suites that USED to cover these symbols all
257+
* asserted a message or a latch count; deleting them leaves nothing that notices
258+
* a well-meaning later edit re-adding a "helpful" deprecation warn to the
259+
* authorization path — which is a per-request cost on every walled rig and a
260+
* second, silent dual-track of the very kind the window existed to close. If a
261+
* future leg genuinely needs a pointer again, ⛔ do not re-add it here: the
262+
* operator-facing line belongs at BOOT, in
263+
* `plugin-security/src/bootstrap-platform-admin.ts`, where an operator can act
264+
* on it. Re-authoring this pin is then a deliberate act, which is the point.
265+
*/
266+
describe('[#11663 L5] the legacy-grant deprecation pointer is retired', () => {
267+
it('⛔ neither symbol is exported by this module any more', async () => {
268+
const mod: Record<string, unknown> = await import('./platform-admin.js');
269+
expect('reportLegacyPlatformAdminGrant' in mod).toBe(false);
270+
expect('resetLegacyPlatformAdminGrantReport' in mod).toBe(false);
271+
// Positive control — the same probe on the same module object finds the
272+
// symbols this leg KEEPS, so an absence read here is an absence and not a
273+
// module that failed to load.
274+
expect('matchesConfiguredPlatformAdmin' in mod).toBe(true);
275+
expect('resolvePlatformAdminEmails' in mod).toBe(true);
263276
});
264277

265-
it('⭐ [#11975] the latch is PER PROCESS, never persistent — a fresh instance re-announces', async () => {
266-
// The migration window's loudness discipline (#11975), in the half nothing
267-
// else pins. Every sibling pin asserts SUPPRESSION — this file's
268-
// "once per process" above, and plugin-security's boot-side
269-
// `bootstrap-platform-admin-walled-owner.test.ts` "EXACTLY ONE line ... even
270-
// across repeated bootstraps" — so all of them stay GREEN if the latch is
271-
// ever hardened past the process: moved onto `globalThis`, stored in a
272-
// module the host keeps alive, or persisted to a row or file to quiet the
273-
// noise. Any of those turns "once per process" into "once EVER", and a
274-
// deployment restarted onto the old anchor is then never told again, which
275-
// is the silent dual-track the window exists to prevent.
276-
//
277-
// A fresh module registry is the closest in-process stand-in for a boot: it
278-
// re-instantiates the module scope the latch lives in exactly as a new
279-
// process would. ⛔ Do not "fix" a red here by resetting the latch in the
280-
// loop — the point is that nothing had to.
281-
const linesPerBoot: number[] = [];
282-
for (let boot = 0; boot < 3; boot++) {
283-
vi.resetModules();
284-
const fresh = await import('./platform-admin.js');
285-
const bootSink = makeSink();
286-
fresh.setPlatformAdminConfigSink(bootSink);
287-
// Twice, so a per-call regression is caught by the same assertion.
288-
fresh.reportLegacyPlatformAdminGrant({ userId: 'usr_1', email: 'ada@example.com' });
289-
fresh.reportLegacyPlatformAdminGrant({ userId: 'usr_1', email: 'ada@example.com' });
290-
fresh.setPlatformAdminConfigSink(undefined);
291-
linesPerBoot.push(bootSink.warns.length);
292-
}
293-
// Exactly one per boot: not 0 (a latch that outlived the process) and not 2
294-
// (a pointer that became per-call).
295-
expect(linesPerBoot).toEqual([1, 1, 1]);
278+
it("⛔ nor by `@objectstack/core`'s security entry — the published surface lost them", async () => {
279+
const entry: Record<string, unknown> = await import('./index.js');
280+
expect('reportLegacyPlatformAdminGrant' in entry).toBe(false);
281+
expect('resetLegacyPlatformAdminGrantReport' in entry).toBe(false);
282+
// Positive control on the same entry: its sibling config exports are still
283+
// re-exported, so the two absences above are about these two names.
284+
expect('matchesConfiguredPlatformAdmin' in entry).toBe(true);
285+
expect('setPlatformAdminConfigSink' in entry).toBe(true);
296286
});
297287
});
298288

‎packages/core/src/security/platform-admin.ts‎

Lines changed: 15 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -331,43 +331,21 @@ export function isConfiguredPlatformAdminEmail(
331331
}
332332

333333
/**
334-
* [#11663 P5] The migration pointer for the LEGACY anchor.
334+
* [#11663 L5] ⛔ The migration pointer for the LEGACY anchor is GONE, and with it
335+
* `reportLegacyPlatformAdminGrant` / `resetLegacyPlatformAdminGrantReport`.
335336
*
336-
* Nothing is revoked in this leg: an unscoped, in-window `admin_full_access`
337-
* grant still confers `PLATFORM_ADMIN` exactly as it did (design §5 step 3 —
338-
* config-derived standing is ADDED, which is what makes this safe to land ahead
339-
* of every deployment setting the variable). What changes is that the row is now
340-
* the OLD anchor, so a holder whose standing rests on it alone is told, once,
341-
* which config line re-anchors them before the row route is removed.
337+
* L4 opened a time-boxed, loud migration window: a walled rig whose platform
338+
* standing rested on the unscoped `admin_full_access` grant row alone was told,
339+
* once per process, which configuration line re-anchors it. L5 is that window's
340+
* EXIT — on a walled rig the row is no longer an anchor at all
341+
* (`resolve-authz-context.ts` §6b), so there is nothing left to point away from.
342342
*
343-
* Once per process, naming ONE holder. Deliberately not once per holder: this
344-
* runs inside the authorization path, and an unbounded per-user ledger there is
345-
* a memory surface for something whose whole job is to say "go look at the
346-
* configuration". The population question (who ALL the administrators are) is
347-
* the audit surface's, filed as its own leg.
343+
* ⛔ Do not reintroduce a request-path notice for this. The walled rig that has
344+
* declared nobody is told at BOOT, by the fail-closed backstop in
345+
* `plugin-security/src/bootstrap-platform-admin.ts`, where an operator can act on
346+
* it — not once per process from inside the authorization path.
347+
*
348+
* ⚠️ The `single` posture is NOT affected: its first-user promotion and its grant
349+
* row stand (Choice 4A), and that row is still that rig's anchor. Its disposition
350+
* is #11979's. See ADR-0131 D5, as amended 2026-09-17 (#18413).
348351
*/
349-
let legacyGrantPointerSaid = false;
350-
351-
export function reportLegacyPlatformAdminGrant(input: {
352-
userId: string;
353-
email?: unknown;
354-
}): void {
355-
if (legacyGrantPointerSaid) return;
356-
legacyGrantPointerSaid = true;
357-
const email = normalizePlatformAdminEmail(input.email);
358-
sink.warn(
359-
`[authz] user ${input.userId} holds PLATFORM_ADMIN through the legacy unscoped `
360-
+ `'admin_full_access' grant row, not through ${PLATFORM_OWNER_EMAIL_ENV}. The grant row is `
361-
+ 'the OLD anchor and is honoured for now; it is removed in a later release. Re-anchor this '
362-
+ `deployment by declaring its administrators in configuration: ${PLATFORM_OWNER_EMAIL_ENV}=`
363-
+ `${email || '<the administrator\'s verified email address>'}`
364-
+ ' (comma-separated for several), and make sure each account\'s email is VERIFIED — an '
365-
+ 'unverified account holding a configured address is not an administrator. Reported once '
366-
+ 'per process; further holders are not listed.',
367-
);
368-
}
369-
370-
/** Drop the once-per-process latch — for tests. */
371-
export function resetLegacyPlatformAdminGrantReport(): void {
372-
legacyGrantPointerSaid = false;
373-
}

0 commit comments

Comments
 (0)