Repository navigation
Commit 149294c
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
File tree
- .changeset
- packages
- plugins/plugin-security/src
- qa/dogfood/test
| 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 | + | |
Large diffs are not rendered by default.
0 commit comments