Commit 2a79726
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
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages/plugins/plugin-security/src
- objects
- translations
- scripts
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
87 | | - | |
| 87 | + | |
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
| |||
224 | 224 | | |
225 | 225 | | |
226 | 226 | | |
227 | | - | |
| 227 | + | |
228 | 228 | | |
229 | 229 | | |
230 | 230 | | |
231 | | - | |
232 | | - | |
233 | | - | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
234 | 234 | | |
235 | 235 | | |
236 | 236 | | |
Lines changed: 4 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
55 | | - | |
| 55 | + | |
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
59 | | - | |
60 | | - | |
61 | | - | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
| |||
0 commit comments