Uh oh!
There was an error while loading. Please reload this page.
feat(security): refuse creating or renaming a sys_capability to a curated name at the admin write door (#8552) - #8623
Conversation
…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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
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_capabilitywhose name is inPLATFORM_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-bootblockedCuratedwarning 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 gateassertCuratedCapabilityNameGatesited directly afterassertSystemRowWriteGate(packages/plugins/plugin-security/src/security-plugin.ts). That position is the latest checkpoint that can see the whole picture, on three measured grounds:sys_capabilitywrites 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.updateis a rename-TO a curated name is only decidable against the row's existingname, which this checkpoint reads under a system context (readRowById, the sharedreadRowByIdswallows engine failures intonull, 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.modifyAllRecords(measured: the principal-less rename-to-curated case refuses).Boot/system writes carry
isSystemand 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 deniedprefix 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
manage_userssetup.writemanage_sharingmy_cap→manage_usersThe filter-rename case uses a
$neprobe 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:
tenantIdpresent) — succeeds through the same door;tenantId, no org stamper — the default community deployment) — succeeds;manage_users→org_manage_users) — succeeds: this is precisely the remediation the new warning line prescribes, so the gate and the warning are mutually consistent;manage_userssucceeds untouched;sys_positionnamedmanage_usersstill inserts: the gate reachessys_capabilityonly.Pre-curation
packagerows are unaffected on their own door too:bootstrapDeclaredCapabilitiesruns underisSystem(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
blockedCuratedwarning (bootstrap-system-capabilities.ts), only on the branches where a blocking row was actually observed: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 checkoutof the branch's own committed copy. Baseline at the fix commit: 56 files, 1099 passed, 0 failed.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:
code: 'PERMISSION_DENIED'ANDstatusCode: 403AND a message containing the quoted colliding name followed byis 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;findOnedouble answers a row only to this gate's$neprobe shape andnullto the system-row gate's managed-row probe, so the measured refusal cannot be the neighbour's;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.tsand the fourtranslations/*.objects.generated.tsare untouched. (PR #8599, named as the serial constraint at dispatch, merged tomainasb45c71e85abefore this branch was cut — this branch is based on top of it.)7. Notes the dispatch did not ask about
assertSystemRowWriteGateruns first and refuses on provenance before name is ever considered. Ordering is load-bearing and documented at the call site.transfer/purgecarry nonamepayload and cannot mint a collision; they are outside the gate by construction, not by omission.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/mainmerged in at6e66f516)pnpm --filter @objectstack/plugin-security test— 56 files, 1099 passed, 0 failedpnpm --filter @objectstack/plugin-security typecheck— EXIT=0scripts/pm/dispatch-gates.mjsover 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.mjspnpm check:type-check-debt(package script:--self-testAND--re-measure, built closure first) — EXIT=0 at both heads: the fix commitebdbe261(33 entries re-measured in 240.3s) and the merged head6e66f516(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.minorfor@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-registrationgreen).Generated by Claude Code