Skip to content

Commit 149294c

Browse files
feat(plugin-security): a boot report lists, per organization, every catalog reference whose name resolves nowhere (ADR-0131 D3/D4, C3 stage 3) (#22671)
Part of #15204 Clause-②: no **Stage 3 of the C3 cutover stage plan** (`6094501866` on #15204): the S9 dangling-name boot report, under the claim amendment `6095710107`. #15204 stays open for its other stages; this PR carries no closing keyword. The S9 content is the list #15196's releases carried to it (`6080018188`, `6084206172`, `6093100744`), with the rulings Q3 A (`6050490870`) and Q (a) (`6094045326`). **Landing order: after stage 1.** The report's "confers nothing" holds for a *named* reference only once the authorization resolver reads positions, sets and set bodies from the catalog (stage 1). One assertion is pending stage 1 (below). ## What changes - **`catalog-reference-report.ts`** (new, `@objectstack/plugin-security`). It reads three tables: `sys_user_position`, `sys_user_permission_set` and `sys_position`. Every name is resolved through `createSecurityCatalogReader` (`@objectstack/core`, already on `main`). `collectCatalogReferences` returns a structured result; `reportCatalogReferences` also logs it, one line per kind of finding. The report writes nothing. - **Wiring** in `security-plugin.ts`: one `kernel:bootstrapped` hook, registered after the grant-name backfill and the row-only position backfill. Handlers run in registration order, so a name either backfill resolves during the boot is not reported. Nothing runs without a hook-capable context: a report at `start()` would judge a catalog that is still filling. - **`grant-permission-set-name-backfill.ts`**: two warning sentences and two module-doc lines that said such grants "still resolve by id today". That stopped being true when the resolver began reading grants by name (C2 stage S5a). They now say these grants confer nothing until a later boot names them. - **One dogfood HTTP pin** (`packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts`) for S10's Setup answer (E2). - **`.changeset/15204-s3-boot-report.md`** (`@objectstack/plugin-security`, minor). It names what the report lists and where the operator reads it. ## Which S9 items this covers | # | S9 item | Here | Pinned by | |:--|:--|:--|:--| | 1 | Assignment rows whose name resolves nowhere, per organization (`sys_user_position.position`, `sys_user_permission_set.permission_set`) | `positionAssignments`, `permissionSetGrants` per organization | `catalog-reference-report.test.ts`: "an assignment and a grant whose names resolve nowhere are listed under their own organization, and nowhere else" (three buckets: organization-less, two organizations) | | 2 | A grant on a catalog-unresolved, row-only set: unnamed, confers nothing | `unnamedGrants` per organization, with the grant's `permission_set_id`; every unnamed grant is listed, whatever the reason the backfill left it unnamed | "a grant on a row-only set stays unnamed after the name backfill, confers nothing, and is listed". It asserts both halves on this tree: the resolver grants neither the set's name nor its system permission. | | 3 | Walled, organization-authored positions with no catalog home, by name and organization (Q3 A) | `positionsWithoutDefinition` per organization under a walled posture. The organization's seeded built-in copies resolve and are not listed. | "under a walled posture, an organization-authored position has no catalog home..." | | 3a | Q3 A's promotion path | A definition of that name saved through the metadata door (`saveMetaItem`, type `position`) clears the position from the next report | "under a walled posture, a definition saved through the metadata door gives the position its catalog home, and it leaves the report" | | 4 | Same-name position rows stored under `single` before S10 (Q (a)) | `conflictingPositionNames`, under `single` only: every name more than one `sys_position` row carries, with each row's organization and whether the catalog resolves the name. Reported as conflicting, never merged. | "under single, a position name more than one row carries is conflicting...": two organizations' agreeing rows, and a package's seeded row beside an organization's pre-S10 row | | 5 | The S7 backfill's refused-name class | Each entry of `positionsWithoutDefinition` carries `refusedName`. The S7 backfill records its verdict and stops reporting the class; this report lists it at every boot. | "under single, the refused-name class stays listed at every boot, after the row-only backfill has recorded its verdict" | | 6 | HTTP pin of S10's Setup answer (E2) | Showcase, `single`: `POST /api/v1/data/sys_position` under `manager` (package-held) and `everyone` (built-in anchor) answers `403 NOT_OVERRIDABLE`. The rows stay as they were and no environment definition is written. A rename into `manager` answers the same. | the dogfood file above | | 7 | The S4b warning sentence gains "confers nothing until then" | `grant-permission-set-name-backfill.ts` | not pinned: the wording is not parsed by any consumer | Also pinned: - A catalog that cannot be read, or a scan that cannot be read, stops the report. It reports no finding and logs the stop once at `warn`. - A composition without the three objects reads nothing. - The wiring pin: the report runs after both backfills, a name either backfill resolved is not reported, and the report's handler issues zero `insert` / `update` / `delete` calls. ## The fail-closed half: pending stage 1 The stage-plan test reads "a removed declaration fails closed and is reported; re-adding it clears both". On this tree the report half holds and is active ("the grant and the assignment are listed while the declarations are gone, and drop out once they are back"). The resolver half does not hold yet, so it is written but skipped: - **The test to enable:** `packages/plugins/plugin-security/src/catalog-reference-report.test.ts`, `it.skip('PENDING STAGE 1: the removed declaration fails closed at the resolver, and re-adding it restores the grant'` → `it(`. - **Measured on this tree** (commit `2d5ca52d`, un-skipped through `scripts/ablation-replace.mjs`, restore proven blob == HEAD): it fails at the `fails closed` assertion. The holder still gets `{ auditor: true, bit: true }` after the declarations are removed, because the set row and the junction row still grant. - **Measured on stage 1's head** (`claude/issue-15204-s1-position-permission-sets` at `8cbedf96`, with this branch's commits cherry-picked into a throwaway worktree and the test un-skipped): `Tests 13 passed (13)`. The pending case passes there, and the other twelve pass beside stage 1's resolver. The worktree was then removed. ## Measured on the real composition - **Showcase boot** (the dogfood harness, `single`). The boot log carries, in order: the grant-name backfill's record line, the position backfill's record line, then the report's line, "every position assignment and permission-set grant names an item the security catalog resolves at this boot, and no position row is without a definition". So there is no false positive on a fresh showcase. - **Ablation of the E2 pin.** `heldPositionNameRefusal` was made to return `null` (`scripts/ablation-replace.mjs`, then `pnpm --filter @objectstack/plugin-security build`). `scripts/ablation-dist-preflight.mjs` found the marker in `dist/index.js` and `dist/index.mjs`. The pin then went 3 red / 1 green: the creates of `manager` and `everyone` answered `201` and landed an organization row beside the seeded one, and the rename answered `409`. The precondition case stayed green. Restored, rebuilt, and `--absent` confirmed: the marker is absent from all 6 built files and the tree is clean. ## Tests and gates | What | Head | Result | |:--|:--|:--| | `pnpm --filter @objectstack/plugin-security test` (full package) | `613a5b56` (pre-merge) | 196 files: `1 failed, 4112 passed, 46 skipped`. The one failure was the handler-count pin below. After the fix (`54864c2a`), the three touched files re-ran `71 passed, 1 skipped`. | | `pnpm --filter @objectstack/plugin-security typecheck` (incl. the test layer) | `115660d7` | exit 0 | | `catalog-reference-report.test.ts` | `115660d7` | `12 passed, 1 skipped` | | dogfood `position-held-name-setup-create.dogfood.test.ts` | `aafa8cd9` | `4 passed` | | `node scripts/pm/dispatch-gates.mjs --commands` (70 families) + `check:startup-registry-verdict` + `check:durability-log-level` | `ad45b764` (`origin/main` `5fb17466` merged) | all exit 0; `--ran` reconciliation: 70 derived, 70 run, 0 NOT-MEASURED (derived from recorded exit codes) | | post-merge re-run of the package tests, typecheck and the dogfood pin | `ad45b764` (`origin/main` `5fb17466` merged) | **NOT MEASURED** (three lock acquisitions timed out, 27 minutes queued, behind another session's objectui test run). Declared to CI, which runs these on the merged tree. The merge brought six commits, none touching this diff's files. | - **One existing pin changed.** `grant-permission-set-name-backfill.test.ts` pinned the `kernel:bootstrapped` handler count at 2. It is now 3, and its comment names the report. - **One gate fix during the run.** `check:query-options-erasure` counted a new test-surface erasure (236 → 237): a pass-through `find` options argument cast to `any`. It is now typed `EngineQueryOptions`, and the ratchet holds at 236. - **Declared to CI:** the dogfood suite beyond this one file, and every importer of `@objectstack/plugin-security`. Its published surface is byte-unchanged (nothing new is exported), but every boot that composes `SecurityPlugin` now runs the report. ## Acceptance notes - **Sharing rules are outside this report's "fails closed".** A sharing rule with a `position` recipient expands its holders from `sys_user_position` rows (`plugin-sharing/src/sharing-rule-service.ts`, `recipient_type === 'position'`), independently of the catalog. So a removed position declaration keeps its sharing-rule grants until that reader moves. That move is planned in stage 2 (the services readers). The report lists the assignments either way. - **Not measured: the HTTP authorization of the promotion path.** Q3 A's promotion was measured at the metadata door's own seam (`saveMetaItem`, `actor: 'system'`) under `isolated`. Whether a platform administrator's `PUT /api/v1/meta/position/NAME` is accepted under a wall was not measured here. - **The two position categories read `sys_position`.** Stage 8 retires that object. After that they read nothing and name the object as absent, so their population is C7's migration report from then on. - **Every assignment row is read at every boot** (paged, 500 per page). It is deliberately not a `NOT IN` query on the resolved names: under a case-insensitive collation (MySQL's default) such a query would hide a case-variant dangling name. - **`metadataDoorAcceptsPositionName` is imported from `position-write-through.ts`**, which stage 7 retires. The S7 backfill imports it too. It is S7's predicate, read and never re-derived. - **Untouched (batch #310 item 4):** the seven seeders, `permission-set-projection.ts` and `per-organization-catalog.ts`. The report reads organization ids with a local three-line helper rather than adding a consumer to `per-organization-catalog.ts`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7e6544 commit 149294c

7 files changed

Lines changed: 1274 additions & 15 deletions

‎.changeset/15204-s3-boot-report.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
A boot report lists, per organization, every security catalog reference whose name resolves nowhere (ADR-0131 D3/D4)
6+
7+
Clause-②: no
8+
9+
ADR-0131 D4 makes every reference to a position or a permission set a name, resolved through the security catalog (the environment registry). A stored reference whose name the catalog does not hold confers nothing, while Setup still lists it. At every boot, at `kernel:bootstrapped` and after the two one-time backfills, `@objectstack/plugin-security` now reads the stored references and logs what the catalog does not resolve. The report writes nothing: no row, no definition, no ledger entry.
10+
11+
- **Where to read it.** In the boot log, as `[security]` lines at `error`, one line per kind of finding. Each line's metadata lists the organizations (`organizationId`, `null` for organization-less rows) with a count and the names or row ids, at most 50 per list. A clean boot logs one `info` line that says every reference resolves.
12+
- **What it lists, per organization:**
13+
- `sys_user_position` assignments whose `position` the catalog does not resolve;
14+
- `sys_user_permission_set` grants whose `permission_set` the catalog does not resolve;
15+
- `sys_user_permission_set` grants with no `permission_set` at all. The one-time name backfill could not name them: the set row is missing, belongs to another organization, or carries a name the catalog does not resolve (a row-only set). An unnamed grant confers nothing;
16+
- `sys_position` rows whose name the catalog does not resolve, by name and organization. Under a walled posture these are the positions an organization authored in Setup: the catalog is environment-level, so they have no catalog home, and their assignments distribute no permission set. Under `single` these are the row-only positions the row-only backfill could not define, with `refusedNames` naming the ones whose names the metadata door refuses. The backfill reports that class once and records its verdict; this report lists it at every boot.
17+
- **And under `single` only:** position names carried by more than one `sys_position` row (two organizations' rows, or an organization's row beside a package's or a built-in's seeded row). A position name is unique per deployment under `single`, so each is reported as conflicting, never guessed and never merged.
18+
- **What clears a line.** The next boot re-reads everything. A reference whose definition is declared again (its package or its environment definition) leaves the report. So does a walled organization's position once a definition of that name is saved through the metadata door. Otherwise remove the row, or re-point it at a name the catalog holds.
19+
- **A read that did not happen is not a finding.** If a scan or the catalog read fails, the report says which store it could not read, at `warn`, and reports nothing for that boot.
20+
- **The grant-name backfill's warnings now say what such a grant is worth.** A grant the backfill could not name, because its set row's name is not in the catalog or its name write did not land, confers nothing until a later boot names it. The warning used to say these grants still resolved by id. That stopped being true when the authorization resolver began reading grants by name.
21+
- **Nothing to migrate.** No configuration is needed.

‎packages/plugins/plugin-security/src/catalog-reference-report.test.ts‎

Lines changed: 567 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)