Skip to content

feat(security): refuse creating or renaming a sys_capability to a curated name at the admin write door (#8552) - #8623

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8552-refuse-curated-name
Aug 14, 2026
Merged

feat(security): refuse creating or renaming a sys_capability to a curated name at the admin write door (#8552)#8623
os-zhuang merged 2 commits into
mainfrom
claude/issue-8552-refuse-curated-name

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Fixes#8552

Implements the maintainer's recorded ruling (2026-08-13 ~23:50Z, 「接受你的全部建议」): option 4 + option 1. Setup can no longer create — or rename a row to — a sys_capability whose name is in PLATFORM_CAPABILITY_NAMES; the write is refused at the admin-door write gate with an error naming the colliding curated name. For installations already carrying a collision, the shipped decline-and-warn stands unchanged, and the per-boot blockedCurated warning now carries one operator-facing remediation line. Options 2 and 3 are not implemented, per the ruling.

1. Where the refusal sits, and why that is the latest checkpoint

SecurityPlugin's engine middleware, as a new gate assertCuratedCapabilityNameGate sited directly after assertSystemRowWriteGate (packages/plugins/plugin-security/src/security-plugin.ts). That position is the latest checkpoint that can see the whole picture, on three measured grounds:

  • Every admin-door transport funnels through it. REST, the runtime dispatcher, batch and the *Many verbs all reach sys_capability writes through the ObjectQL middleware; there is no second write path to the table. A route-level or form-level check would cover one door and leave the API doors answering 200.
  • It sees payload, operation, AND pre-image. The rename half needs all three: whether an update is a rename-TO a curated name is only decidable against the row's existing name, which this checkpoint reads under a system context (readRowById, the shared readRowById swallows engine failures into null, so a store outage is indistinguishable from an absent row at every gate that probes with it #7505 fail-closed probe). Earlier checkpoints (spec/Zod, route validation) cannot see the stored row.
  • It runs before the empty-principal fall-open and the CRUD grant check, exactly like the sibling provenance gates — so the boundary holds for a principal-less context and for a superuser with modifyAllRecords (measured: the principal-less rename-to-curated case refuses).

Boot/system writes carry isSystem and short-circuit the middleware above the gate, so the curated seeder and package publish are structurally unaffected (measured: the isSystem-bypass control below).

The refusal is a PermissionDeniedError (403 PERMISSION_DENIED), deliberately the same envelope as the two sibling gates at this exact door (managed-provenance forging, platform/package-row rewrites): this is the same ADR-0066 asset-ownership class — the curated names are the platform's, and no grant makes claiming one authorized — not a payload-validation condition. The message opens with the [Security] Access denied prefix both transports read as a 403 marker, names the colliding curated name, and states the remedy (choose a different name). No new error code, so no ledger entry.

2. Create AND rename are both covered — measurement

doorcasemeasured
createsingle insert, curated name, org-scoped shaperefused, names manage_users
createsingle insert, curated name, community NULL-bucket shaperefused, names setup.write
createARRAY insert, one element curatedrefused, names manage_sharing
renameby-id update, my_capmanage_usersrefused
renameby-id update, principal-less contextrefused (gate precedes the fall-open)
renameby-id update, pre-image unreadable (absent)refused — fail closed per the #7505 ruling
renameFILTER update stamping a curated name onto differently-named rowsrefused
renamewhole-table update stamping a curated name (no filter)refused

The filter-rename case uses a $ne probe in the shape of the sibling gate's bulk check: a filter update stamping a curated name is allowed only when the write's own filter provably matches nothing but rows already holding that name (that degenerate shape is a no-op rename, and refusing it would block nothing-changes writes on an existing collision row).

Deliberately NOT covered: restore. Restoring a soft-deleted row that already held a curated name re-establishes a pre-existing collision rather than minting a new one — that residue is option 1's territory (the operator's), same as every other pre-existing colliding row. The ruling names create and rename; this boundary is documented at the gate.

3. Positive controls — the load-bearing half

A refusal too broad passes every "it refuses" assertion, so the controls pin the legitimate halves open, in both deployment shapes the card measured:

  • create non-curated, org-scoped shape (tenantId present) — succeeds through the same door;
  • create non-curated, community NULL-bucket shape (no tenantId, no org stamper — the default community deployment) — succeeds;
  • full-record update of an EXISTING colliding admin row repeating its own curated name unchanged (the Setup full-record PATCH shape) — succeeds: not a rename, and the operator must keep editing rights on their residue row;
  • rename AWAY from the curated name (manage_usersorg_manage_users) — succeeds: this is precisely the remediation the new warning line prescribes, so the gate and the warning are mutually consistent;
  • filter update stamping a curated name that provably renames nothing — succeeds;
  • isSystem bypass — the curated seeder's own insert of manage_users succeeds untouched;
  • scope control — a sys_position named manage_users still inserts: the gate reaches sys_capability only.

Pre-curation package rows are unaffected on their own door too: bootstrapDeclaredCapabilities runs under isSystem (bypasses this gate) and already refuses curated names at its own checkpoint; existing pre-curation rows are not written by anything this PR adds.

4. The remediation line (option 1's addition)

Appended to the seeder's blockedCurated warning (bootstrap-system-capabilities.ts), only on the branches where a blocking row was actually observed:

To resolve by hand: rename the blocking row to a name outside the curated set (or delete it) — through Setup for an admin-authored row, or by editing and re-publishing the owning package for a package-declared one — then restart; the seeder will then seed the platform's definition. (New Setup rows can no longer take a curated name — refused at the write door.)

On the "NO blocking row is visible" branch the line deliberately does not print — telling an operator to rename a row the read just failed to find would assert what was not observed (the same honesty rule the #8537 diagnostic already follows). Pinned in both directions: the three blocked-provenance cases assert the line's presence; the no-row case asserts its absence.

The line is honest against the gate itself: rename-away and delete of an admin-authored colliding row remain permitted (positive controls above), and a package row's remedy is named as the package door because the admin door correctly refuses it (assertSystemRowWriteGate).

5. Ablations — prediction vs measurement

Predictions written down before each run; fix committed first, restores via git checkout of the branch's own committed copy. Baseline at the fix commit: 56 files, 1099 passed, 0 failed.

ablationpredictedmeasured
A1 — insert branch disabledthe 3 create pins RED; the 5 rename pins, all 7 positive controls, both seeder suites GREEN3 failed, 1096 passed — exactly the 3 create pins
A2 — update (rename) branch disabledthe 5 rename pins RED; create pins + controls GREEN5 failed, 1094 passed — exactly the 5 rename pins
A3 — remediation line disabledthe 3 blocked-provenance warning pins RED; the "no blocking row" case GREEN (it asserts the line's ABSENCE, still absent)3 failed, 1096 passed — exactly as predicted, including the deliberately-green absence pin

No divergence anywhere, including the deliberately-green rows. Direction was the ordinary one in all three (pins red under ablation); no inverted or gained-diagnostic direction appeared.

Why the harness is not vacuous:

  • every refusal pin asserts the whole envelope — code: 'PERMISSION_DENIED' AND statusCode: 403 AND a message containing the quoted colliding name followed by is a platform-curated capability name — so a refusal from a neighbour gate (provenance forging, CRUD, RLS) fails the message containment rather than keeping the pin green;
  • the filter-rename test's findOne double answers a row only to this gate's $ne probe shape and null to the system-row gate's managed-row probe, so the measured refusal cannot be the neighbour's;
  • A1 and A2 each leave the other half's pins green, proving each pin reaches its own branch rather than a shared choke point; A3's absence pin staying green shows the remediation assertions read the real message, not a stub.

6. plugin-security translations

Not needed, verified rather than assumed: the refusal message and the boot warning are runtime/log text, not field metadata, and pnpm check:i18n (rebuilt CLI, real extractor run) reports all 9 packages' bundles in sync with zero drift from this diff. sys-permission-set.object.ts and the four translations/*.objects.generated.ts are untouched. (PR #8599, named as the serial constraint at dispatch, merged to main as b45c71e85a before this branch was cut — this branch is based on top of it.)

7. Notes the dispatch did not ask about

  • A negation the gate does not need: an update whose payload repeats the row's own curated name passes the gate but a platform/package row is still protected — assertSystemRowWriteGate runs first and refuses on provenance before name is ever considered. Ordering is load-bearing and documented at the call site.
  • transfer/purge carry no name payload and cannot mint a collision; they are outside the gate by construction, not by omission.
  • The engine-double-contract counts are byte-identical to main's (205 pinned, 133 DEBT, 2 exempt) — the new tests reuse the file's existing middleware harness and declare no new engine double.

Verification — re-run at the merged head (origin/main merged in at 6e66f516)

  • pnpm --filter @objectstack/plugin-security test — 56 files, 1099 passed, 0 failed
  • pnpm --filter @objectstack/plugin-security typecheck — EXIT=0
  • Gate union (re-derived via scripts/pm/dispatch-gates.mjs over the changed paths at the merged head; all EXIT=0 at that head): check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:engine-double-contract, check:i18n, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs
  • pnpm check:type-check-debt (package script: --self-test AND --re-measure, built closure first) — EXIT=0 at both heads: the fix commit ebdbe261 (33 entries re-measured in 240.3s) and the merged head 6e66f516 (33 entries re-measured in 239.9s; 1969 raw tsc errors, none above its recorded number, surplus none). The merged-head run finished after the draft PR opened — this bullet initially recorded it as queued behind another agent's verification-lock phase, updated now with the real result.
  • Changeset: minor for @objectstack/plugin-security, stating plainly that an authoring call that answered 200 now refuses and naming the affected curated names. Not declared-breaking, so no ADR-0087 marker is required (check-adr-0087-registration green).

Generated by Claude Code

…o a curated name at the admin write door (#8552)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 2:43am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 14, 2026 02:58
@os-zhuang
os-zhuang added this pull request to the merge queueAug 14, 2026
Merged via the queue into main with commit c931e53Aug 14, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8552-refuse-curated-name branch August 14, 2026 03:11
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude