Repository navigation
fix(plugin-security)!: a package-declared position is refused at the data door, as the metadata door already refuses it - #22378
Conversation
…-held declared positions The declared-position seeder wrote its rows with no provenance stamp, so they carried the object default (admin) and the system-row write gate did not protect them: a data-door edit of a package-declared position's label answered 200 and the next boot wrote the declaration back over it. The metadata door refuses the same edit (403 NOT_OVERRIDABLE). A position a code package holds -- asked of the registry's artifact lookup, the one the metadata door refuses a save from -- is now stamped managed_by 'package' on insert, and an existing row lacking a managed value (an upgraded deployment's) is corrected in place, managed_by and nothing else. The gate is unchanged. An environment-authored definition keeps an unmanaged row. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…r and keeps its declared label across a cold boot Over the real showcase composition under single, two boots of one database file: the declared row carries package provenance, a data-door label edit is refused with the gate's code and leaves the row unchanged, an upgraded deployment's admin-stamped row is re-stamped at boot and refused the same way. Controls: administrator- and environment-authored positions stay editable, a built-in's refusal is unchanged, and the seeder's own system write still refreshes a declared row's display text. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…ge-declared position Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a21a8bc36e10e87e336c58264a91b36c7e1fdc1a && git checkout a21a8bc36e10e87e336c58264a91b36c7e1fdc1a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 117d34de3fe5af61f684d7ab5d24c4dadebdda97 c345f93b739c35b22607dc4159fd5f83ea6e13bb && git checkout -B drift-repro 117d34de3fe5af61f684d7ab5d24c4dadebdda97 && git merge --no-ff c345f93b739c35b22607dc4159fd5f83ea6e13bb
node scripts/docs-audit/affected-docs.mjs --json 117d34de3fe5af61f684d7ab5d24c4dadebdda97
|
…clared-position-provenance
…sition passes the system-row write gate
A package position row is locked the way a packaged permission set is: a
patch touching only active and/or is_default on a sys_position row
stamped package (or its legacy config spelling) passes, single-id or
filtered, as permission-set-projection's write door passes a bare
{ active } patch on a packaged set. Definition columns (name, label,
description, delegatable), any other column, delete and the other
lifecycle verbs stay refused with the gate's code and message. Platform
rows and sys_capability get no carve-out; the provenance-stamp refusal
is unchanged.
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ersists while its definition stays locked
Deactivate, Activate and Set as Default answer 200 and persist across a
cold boot; a label edit, a delegatable edit and a delete answer 403
PERMISSION_DENIED with the row unchanged; an updateMany row-state patch
lands and an updateMany label patch is refused; a built-in refuses even a
bare { active } patch. The changeset now states that definition edits and
delete are refused and row state stays switchable.
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…row-state carve-out Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
… gate's #7505 cases, on its ledgered store double A new test file carried a second findOne engine double the pinned ledger does not record; housing the cases in store-fault-fail-closed.test.ts, which already tests assertSystemRowWriteGate over a ledgered double, covers the same carve-out without a ledger change. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22360
Clause-②: no (narrowing)
Rework round 1 — supersedes the sections below where they disagree
Seat REWORK
6071670550on #22360. Headc345f93b73, which mergesorigin/main16096e8d7b.assertSystemRowWriteGate(security-plugin.ts, the gate region only) gains carve-out (d). Anupdateof asys_positionrow stampedpackage(orconfig, its legacy pre-A4 spelling, whichSYSTEM_ROW_PROVENANCEguards as the same owner) passes when its patch is ONE object naming onlyactiveand/oris_default(PACKAGE_POSITION_ROW_STATE_COLUMNS). This matches permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的active存储列喂进了 #4001 之后严格化的 permission spec #4669's carve-out for packaged permission sets and feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196's Q2 = A.name,label,description,delegatable), any other column, a mixed or empty patch;platformrow (even a bare{ active });sys_capability;200and persist across a reboot. A label ordelegatableedit answers403, and so does a delete.store-fault-fail-closed.test.ts, on its ledgered double. The ablation of the carve-out turns exactly the 3 admit cases red.main's build, 5 are red. The dist ablation turns 3 red.delegatable) and delete are refused; row state stays switchable; the remedy stays.plugin-security188 files / 3915 tests passed;--rana derived zero.What this changes
The declared-position seeder (
bootstrapDeclaredPositions,@objectstack/plugin-security) now stampsmanaged_by: 'package'on the row of a position a code package holds:adminby the object default. That write carriesmanaged_byand nothing else, apart from the label and description refresh the seeder always made."A code package holds the name" is asked of the engine registry's artifact lookup (
getArtifactItem). That is the lookup the metadata door refuses a save from with403 NOT_OVERRIDABLE, so the data door now refuses what the metadata door refuses, and nothing more.assertSystemRowWriteGateis unchanged: it already refuses an admin-door update or delete of apackagerow, as it does for the built-ins. A position the environment authored through the metadata door has no package, so its row stays unmanaged.Diff:
bootstrap-declared-positions.ts, its unit test, one dogfood pin, one changeset. Nothing inpackages/spec,packages/core, the gate, the built-in seeder, the permission-set seeder, or the in-flight position write-through of #15196 stage S7.Reproduction (H1), on
origin/mainb460153912Showcase,
singleposture, two boots of one database file. Scratch probe, deleted and never committed.mainmanaged_by: admin, organization nullmanaged_by: packageregistry, packagecom.example.showcase(=getArtifactItem)PUT /meta/position/managerNOT_OVERRIDABLENOT_OVERRIDABLEPATCH /data/sys_position/IDlabel ofmanagerPERMISSION_DENIED, row unchangedactive: falseoncontributorPERMISSION_DENIEDdelegatable: trueonexecPERMISSION_DENIEDDELETEonauditorPERMISSION_DENIEDeveryonelabel editPERMISSION_DENIEDPERMISSION_DENIEDassertSystemRowWriteGatejudged the declared rows as admin-authored before (managed_by: adminis not in its managed set) and judges thempackagenow.The stamp and #2909 T2 (H2)
T2's text locks that a re-seed only refreshes
label/descriptionand never touches the authoritative fields (bindings,delegatable, and the like). The stamp does not conflict with it:managed_bykey.managed_byisreadonly, and the gate refuses an admin-door payload namingplatform/package, soadminon a declared row was only ever the object default. The authoritative columns keep their values (pinned:active,is_default,delegatablesurvive the re-stamp).Value:
package, the spelling the gate refuses and the one the permission-set and capability seeders stamp. It needs nothing fromnormalize-managed-by.ts:packageis canonical and the normalizer only rewritessystem/config/user. A row already carrying a gate-managed value (platform,package, legacysystem/config) is never re-stamped.Measured: a Setup-created position whose name a package declares later gets locked. A Setup row (
p22360_taken, organization-bound) was created and edited (200). A third boot, whose stack declares that name, then ran the seeder. Onmainthe seeder relabelled the row and left itadmin, so the next edit answered 200. On this branch the row is re-stampedpackageand the next edit answers 403PERMISSION_DENIED. The seeder matches by name, and it already took such a row over for its label; the fix was not widened (see Acceptance notes).Readers of
sys_position.managed_by(H3)assertSystemRowWriteGate: the one answer that changes. It refuses update, delete, transfer, restore and purge on the stamped rows, and a filter-scoped write whose filter matches one.reserved_identity_namevalidation rule onsys_position: it exempts onlyplatform/system, so its answer is unchanged. The seeder never writes a built-in name.normalize-managed-by.ts: scans legacy values only. Unchanged.cleanup-package-permissions.ts, the only plugin-security entry on the protocol's uninstall seam; the other entry is runtime's package jobs): it selectssys_permission_setbypackage_id+managed_by: 'package', and deletessys_position_permission_setbypermission_set_idandsys_audience_binding_suggestionbypackage_id. It never reads or deletessys_position, so it deletes no row it did not delete before.resolve-authz-context, sharing services: they readsys_position(name,active, organization) but nevermanaged_by.managed_byis in the object'shighlightFields, so the record header now reads Package. The Activate, Deactivate and Set as Default actions carry nomanaged_bycondition and answer 403 on these rows, as they already do on a built-in. objectui at the pinned.objectui-shaa58626c8: the only row-levelmanaged_byreader isrecordDelete, and it acts onsys_permission_setonly.Pins: red on
main, green with the change, ablationsUnit (
src/bootstrap-declared-positions.test.ts, source-resolved, realSchemaRegistry), 7 new cases:b460153912blob (on-disk hash checked) gives 4 failed, 16 passed: a new row stamped; an upgraded admin row corrected with exactly{ id, managed_by }; display refresh and stamp in one write; the no-artifact-lookup registry branch. The other three are controls that pass onmainby design: a second pass writes nothing; a gate-managed value is kept; an environment-authored definition stays unmanaged.node scripts/ablation-replace.mjs, anchor 1 to 0, blob85be9d2dtocff57413) gives 2 failed, 18 passed: exactly the two re-stamp cases.git diff HEAD. The tree with the change gives 20 passed.Dogfood (
declared-position-provenance.dogfood.test.ts; plugin-security resolves throughdist/):dist/(ablation-dist-preflight --absentpassed on the new marker): 3 failed, 4 passed. The red cases are the declared row's provenance, the refused edit with the row unchanged, and the cold boot where the upgraded row is re-stamped and refused. The 4 controls pass: the precondition (NOT_OVERRIDABLEat the metadata door), the administrator-authored position, the built-in refusal, and the administrator- and environment-authored rows after the boot.dist/reading: the call is absent) gives 1 failed, 6 passed: the cold-boot case.Changeset
.changeset/22360-declared-position-package-provenance.md:@objectstack/plugin-security: minor. It carries a BREAKING banner,Clause-②: no (narrowing), the remedy (change it in the package, or clone it under a new name), and ADR-0087not-required (no-migration-prescription). The breaking change ships asminorunder the launch-window convention. Verdict lines:check-changeset-no-major --base origin/main: "✓ This diff introduces nomajorbump."pull_requestevent carrying this body's declaration line: "✓ LEVEL AXIS: this PR declares clause-②no (narrowing), and no package whosepackages/**/src/**it moves is gradedpatch."check-adr-0087-registration --base origin/main: "✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition."Verification (HEAD
8c3e68a2b5)@objectstack/plugin-securityfull suite: 186 files, 3891 passed, 45 skipped.typecheckpassed (tsc on src and scripts, pluscheck:test-typecheck;--listFilescounts the edited test).@objectstack/dogfoodtypecheckpassed (--listFilescounts the pin).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 69 commands, identical to the claim's reading, all exit 0.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (8 packages had nodist/); after building them (turbo, all cached) it answered exit 0.--ran: "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN".pnpm check:error-status-conformance(by hand): "✓ every derivable runtime status is documented, and every documented status is reachable."--format jsonreports 3 files, 0 errors, 0 warnings.--print-configshows none ignored and noparserOptions.project/projectService, so type-aware linting is off and this diff cannot move a verdict on any file it does not touch.origin/main: the 2 commits since the branch point touchservice-storage, the spec error-code ledger and one storage dogfood file, none of this diff's packages or behaviour.Acceptance notes
activeanddelegatableedits used to persist. So Setup's Activate, Deactivate and Set as Default on such a position now answer 403, as they do on a built-in, and so does a delete. The changeset says so. The triage ruling coversdelegatableexplicitly, and ADR-0131 D6/D3 (managed items read-only, clonable) covers the rest; the report raises it for the seat to confirm.objects/sys-position.object.ts(the comment abovereserved_identity_name) still says the declared seeder "stamps no provenance at all (the row defaults toadmin)". The rule's verdict is unchanged, becausepackageis not exempt, but that premise is now stale. The file is outside this PR's fence and is left as is.6d127ed8c1, no PR yet) has a dogfood case, "Q2", that expectsPATCHofmanager's label to answer 200. With this change onmainit answers 403PERMISSION_DENIED. Whichever lands later reconciles.Generated by Claude Code