Skip to content

Commit 2a79726

Browse files
claude[bot]claude
andauthored
fix(plugin-security): refuse ADR-0068 built-in identity names at both position write doors (#17436)
* feat(plugin-security): refuse ADR-0068 built-in identity names at the position write doors `sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`). PR #15948 closed every in-repo reader that turned such a name into authority; it could not stop the row existing, and an out-of-repo reader that reads the name instead of the capability rung reopens the hole with nothing mechanical to catch it. Both declarations now carry an object-level `validations[]` rule whose CEL list literal is GENERATED from `BUILTIN_IDENTITY_NAMES` — the spec constant that declares the identities — so the closed enumeration is imported, never retyped and never widened to an `org_*` pattern. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by ONE refusal carrying ONE code (`VALIDATION_FAILED`). `sys_position` exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling) because `bootstrapBuiltinRoles` seeds exactly these names; `sys_user_position` takes no exemption at all, since no writer in any package creates an assignment row spelling one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW * feat(plugin-security): read-only census for existing reserved-identity-name rows `scripts/measure-reserved-identity-name-census.mjs` reports rows that already stand on an ADR-0068 built-in identity name and rewrites none of them, per the maintainer ruling («refuse new writes only. No migration. A read-only census reports existing colliding rows to the maintainer»). Two modes: the default censuses DECLARATIONS in this repository; `--rows FILE` censuses a deployment from a read-only export, separating the platform's own seeded catalog rows from real collisions and refusing an input that never exported a table rather than reading it as zero. The reserved set is parsed out of the spec constant that declares it, with a control that throws instead of reporting a comfortable zero when the parse finds nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW * fix(scripts,plugin-security): route the census entry guard through invoked-as, and make the gate double refuse combinators Two gate findings on this branch's own diff: - `check:entry-guard` — the census script carried a hand-typed `import.meta.url === file://${process.argv[1]}` guard, which answers false through a symlink and silently does nothing. Routed through `scripts/invoked-as.mjs`'s `isEntrypoint`, like every other `scripts/` entry. - `check:where-matcher` — the DelegatedAdminGate test double read a `$and` / `$or` key as a field name instead of refusing it, the silently-wrong shape: every row would fail the lookup and the assertion would pass for a reason unrelated to what it measures. It now throws on any combinator it does not implement, matching the sibling double in `delegated-admin-gate.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW * chore(plugin-security): regenerate the i18n bundles for the new validation messages `node scripts/check-i18n-bundles.mjs --write`, nothing else in this commit. The two `validations[]` entries added on this branch carry an authored `message`, which the rule validator resolves through i18n at refusal time (`objects.<object>._validations.<rule>.message`), so the package's bundles were behind the schema — `check:i18n` reported `plugins/plugin-security: 7 bundle(s) drifted` on CI, which is the measurement this branch could not take locally until the gate's build prerequisite was cleared. Exactly the gate's designed output: `en` is rewritten from source (it is a copy, not a translation), and merge mode adds the new keys to the translated locales filled with the source text, which still needs translating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW * chore(docs): re-measure the tenant-audit census corpus-scale figures `node scripts/tenant-audit-census.mjs --write` — the script's own documented mode — plus the one prose figure that mirrors the generated count and sits outside the GENERATED block. ROOT CAUSE, and it is this branch's. `declaredObjects()` in `scripts/tenant-audit-census.mjs` walks every `*.object.ts` and counts EACH object literal carrying a snake_case `name:` string literal; it does not distinguish an object declaration from a nested one. The four `actions[]` names on `sys_position` (`activate_position`, `clone_position`, …) were already in the tally before this branch, so 298 was never a count of objects. The two `validations[]` rules added here are counted the same way, moving it to 300 — and a rule name cannot dodge it, since `packages/spec` requires it to be snake_case. Measured in ONE worktree with ONE `node_modules`, switching only HEAD: at `origin/main` (`ab56ea3a1`) the census reports 298 and `check-tenant-audit-census --self-test` exits 0; at this branch's head it reports 300 and the self-test exits 1. The gate itself is green either way — the corpus-scale figures are dated and explicitly NOT compared. What breaks is the self-test case that rewords the prose claim off the page: it builds the string to replace from the LIVE count, so it silently becomes a no-op once the page's tolerated drift becomes real, and the case then fails for the page rather than for the classifier it pins. That latent fragility is #17437 and is NOT closed by this commit. DISCLOSED: the block regenerates whole, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 -> 562 and `engine-shaped types recognised` 59 -> 58 — drift accumulated since the block was last measured at `9cefca9a3`. ⛔ The checker, its fixtures and its expectations are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 010c48a commit 2a79726

15 files changed

Lines changed: 1059 additions & 9 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972)
6+
7+
`sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it.
8+
9+
Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`).
10+
11+
Two doors, two shapes, for a reason:
12+
13+
- **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused.
14+
- **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity.
15+
16+
Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export.
17+
18+
Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`.

content/docs/permissions/tenant-audit-census.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ receiver that none of the three place is an error, never a default.**
8484

8585
Tenancy itself is enabled *by default*`isTenancyDisabled()` reads
8686
`tenancy.enabled === false` and nothing else — so the object registry only has to
87-
find the opt-outs. Across 298 declared objects — the dated, ⛔ unenforced
87+
find the opt-outs. Across 300 declared objects — the dated, ⛔ unenforced
8888
corpus-scale figure below — exactly two opt out (`sys_api_key`,
8989
`sys_sso_provider`), and no write call site on this surface targets either.
9090

@@ -224,13 +224,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
224224
their values are not compared. The reasoning, and the measurement behind it,
225225
are in `scripts/check-tenant-audit-census.mjs`.
226226

227-
Measured on 2026-09-07 at `9cefca9a3`.
227+
Measured on 2026-09-10 at `638d2b544`.
228228

229229
| corpus scale (not enforced) | count |
230230
| :--- | ---: |
231-
| tracked non-test sources scanned | 557 |
232-
| engine-shaped types recognised | 59 |
233-
| declared objects in the registry | 298 |
231+
| tracked non-test sources scanned | 562 |
232+
| engine-shaped types recognised | 58 |
233+
| declared objects in the registry | 300 |
234234
| same-named calls subtracted as non-engine | 137 |
235235

236236
{/* END GENERATED: tenant-audit-census */}

docs/audits/2026-08-tenant-audit-write-call-sites.counts.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
5252
their values are not compared. The reasoning, and the measurement behind it,
5353
are in `scripts/check-tenant-audit-census.mjs`.
5454

55-
Measured on 2026-09-07 at `9cefca9a3`.
55+
Measured on 2026-09-10 at `638d2b544`.
5656

5757
| corpus scale (not enforced) | count |
5858
| :--- | ---: |
59-
| tracked non-test sources scanned | 557 |
60-
| engine-shaped types recognised | 59 |
61-
| declared objects in the registry | 298 |
59+
| tracked non-test sources scanned | 562 |
60+
| engine-shaped types recognised | 58 |
61+
| declared objects in the registry | 300 |
6262
| same-named calls subtracted as non-engine | 137 |
6363

6464
## Every site

0 commit comments

Comments
 (0)