Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/reserved-identity-name-position-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/plugin-security": minor
---

feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972)

`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.

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`).

Two doors, two shapes, for a reason:

- **`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.
- **`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.

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.

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`.
10 changes: 5 additions & 5 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ receiver that none of the three place is an error, never a default.**

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

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

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

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

{/* END GENERATED: tenant-audit-census */}
8 changes: 4 additions & 4 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

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

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

## Every site
Expand Down
Loading
Loading