Repository navigation
fix(core): lower modifyAllRecords in the managed write clamp where the write guard refuses edit or delete - #30474
Conversation
… transfer where the guard refuses the update clampManagedObjectWrites cleared allowEdit/allowCreate/allowDelete on a guarded managed object that does not opt the verb in, but left modifyAllRecords (which grants edit, delete and transfer on its own) and allowTransfer (an owner change is an update). It now lowers modifyAllRecords to viewAllRecords plus the plain bit of each verb the object still opts in, through one helper shared with the external write clamp, and clears allowTransfer unless edit is opted in. Claude-Session: https://claude.ai/code/session_01Utp6CZUqByp4kJMhCQDWZ2 Co-authored-by: Claude <noreply@anthropic.com>
…allowTransfer as it was Clearing a plain allowTransfer on every guarded object also moved the super-user wildcard admin's transfer answer, which the plugin-security parity table (map vs evaluator) does not forgive. That half goes back to the seat; this change keeps to the card: modifyAllRecords is lowered where the guard refuses edit or delete. Claude-Session: https://claude.ai/code/session_01Utp6CZUqByp4kJMhCQDWZ2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 39 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 e555f95a7013762fa78739f3ef4d0e95db82c8fa && git checkout e555f95a7013762fa78739f3ef4d0e95db82c8fa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f506b79d85790a8f74a762ac4031090c7fbdae25 04b5cbbebd76fae78aeebe51681fe262654477be && git checkout -B drift-repro f506b79d85790a8f74a762ac4031090c7fbdae25 && git merge --no-ff 04b5cbbebd76fae78aeebe51681fe262654477be
node scripts/docs-audit/affected-docs.mjs --json f506b79d85790a8f74a762ac4031090c7fbdae25 |
…the guard refuses the update The super-user wildcard fold copies the transfer bit onto every guarded object it covers, so the platform admin was served can(object, 'transfer') true where the guard maps transfer to edit and refuses it. The clamp now clears allowTransfer unless edit is opted in. The plugin-security agreement table forgives transfer as one of the clamp's own verbs, and gains a row with explicit modifyAllRecords entries on guarded objects. Claude-Session: https://claude.ai/code/session_01Utp6CZUqByp4kJMhCQDWZ2 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Contract reviewServed-tier: Face: declaration ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC 维护者速读(席位终稿)
席位意见:同意落地。能力答案与写守卫对齐,和 #30416 同一做法、共用一个 helper。 席位核对ACCEPT · head
|
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Queue kick, re-queued once · 2026-11-05T14:29Z
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42041889542 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
domain:server#3 · os-bill · session_01Utp6CZUqByp4kJMhCQDWZ2
Requested by Bill · project thread
Fixes #30440
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
current_user.can()和控制台按钮就说可以编辑、删除、转移,但写守卫实际一律拒绝。packages/core/src/security/effective-object-permissions.ts的clampManagedObjectWrites与新 helperlowerModifyAllRecords;packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts一致性表的CLAMPED与新行;测试块[#30440]。pnpm --filter @objectstack/core exec vitest run src/security/effective-object-permissions.test.ts,预期 55 个全绿。What changes
clampManagedObjectWrites(packages/core/src/security/effective-object-permissions.ts) clearedallowEdit/allowCreate/allowDeleteon an object in a guardedmanagedBybucket that does not opt the verb in throughuserActions, but never touchedmodifyAllRecords, which the spec'sobjectPermissionGrantsreads as edit, delete and transfer on its own. A permission set naming such an object with{ modifyAllRecords: true }therefore answeredcurrent_user.can(object, 'edit' | 'delete' | 'transfer')true while the write guard refused every one of those writes.lowerModifyAllRecords(acc, kept): when the entry carriesmodifyAllRecords: trueand a layer refuses at least one of edit / delete / transfer, the bit becomesfalse,viewAllRecordsbecomestrue(the read it implies, so read is unchanged), and each verbkeptnames is carried over as its plainallow*bit.clampManagedObjectWritescalls it with{ allowEdit: edit, allowDelete: delete, allowTransfer: edit }from the object'suserActionsopt-ins.clampManagedObjectWritesalso clears a plainallowTransferon a guarded object that does not opteditin. The super-user wildcard fold copies the transfer bitmodifyAllRecordsgrants onto every entry it covers, so the shipped platform admin was servedcan(object, 'transfer')true on every such object while the guard refused the update. Measured onc5800818: an engine-owned object opting nothing in, wildcard{ allowRead, viewAllRecords, modifyAllRecords }, map entryallowTransfer: true, andassertEngineOwnedWriteAllowed(…, 'update', user)throwsPermissionDeniedError. After the fix the same probe answers transferfalse.packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts(can() over the served map vsPermissionEvaluator.checkObjectPermission, which does not model the guard): itsCLAMPEDset, the cells it forgives in the refuse direction on a guarded object, gainsallowTransfer, and it gains a row[#30440] explicit modifyAllRecords entries on guarded managed objects(sys_useropting edit in,sys_memberopting nothing in, privatesys_secret).clampExternalReadOnlyWrites(Effective object permissions ignore the external write gate, so the console offers New, Import, Edit and Delete on a read-only external object #30370, PR fix(core,objectql): effective object permissions read the external write gate, so a read-only external object is no longer offered New, Import, Edit or Delete #30416) now goes through the same helper with nothing kept; its behaviour is byte-identical (its existing tests are unchanged and green).The partial opt-in choice (dispatch scope item 2).
objectPermissionGrantsanswers edit asread && (allowEdit || modifyAll), delete asread && (allowDelete || modifyAll)and transfer asallowTransfer || modifyAll, so afalsebesidemodifyAllRecordstakes nothing away: an entry cannot keepmodifyAllRecordsand refuse only delete. So:modifyAllRecordscan()sys_automation_run,http_delivery)viewAllRecordseditonly (sys_user,sys_organization,sys_api_key)allowEdit+allowTransferkepteditanddeleteTransfer follows
editbecause an owner change is an update: the engine-owned guard maps it to theeditaffordance, and #30416's external clamp already treats it the same way. What is lost when the bit is lowered on anedit-only object is record-level "all records" scope for edit, which only the server evaluator reads (unchanged here); the served map's consumers read the object-level verbs (current_user.can,MePermissionsProvider's transfer check), and those keep edit and transfer.Tests
New block
[#30440] a guarded managed object grants no verb its write guard refuses, modifyAllRecords includedinpackages/core/src/security/effective-object-permissions.test.ts(the existing harness file for this map), throughbuildEffectiveObjectPermissionsand the spec's realobjectPermissionGrants:{ modifyAllRecords: true }on an engine-owned object opting nothing in: no edit / delete / transfer, same read;sys_user-shaped object (edit opted in): edit and transfer kept, delete gone;modifyAllRecords;modifyAllRecordsgrants;allowTransferon an object not opting edit in answers no transfer; onsys_user(edit opted in) it stays;mainon every object except the folded transfer bit on the object opting nothing in, which now readsfalse(the fold copies granted bits, nevermodifyAllRecords).plugin-security's agreement table: one new row (above) and
allowTransferinCLAMPED; 68/68.Acceptance notes
allowTransferhalf was first handed back as out of scope (first commit47fe789acleared it and red 13 rows of the agreement table;5dd16ba3took it out); the seat chose to take it here and amended the claim (6658639729);04b5cbbeputs it back with the table change.apiOperationsfor the engine-owned object listscreate/update/delete. That annotation readsenable.apiMethodsexposure, a different layer from the guard; noted only.Verification
Measured on main first (c580081), probe deleted after use
The same probe after the fix:
probe_engine_ownedcan edit/delete/transfer = false/false/false, read true;sys_user_likeedit/delete/transfer = true/false/true; the unguarded control unchanged.Tests and ablation
pnpm --filter @objectstack/core test: Test Files 134 passed, Tests 3001 passed (lock VERDICT command-exit 0);pnpm --filter @objectstack/plugin-security typecheckexit 0.pnpm --filter @objectstack/core typecheck: exit 0, test layer OK.current-user-endpoints-effective-objects.test.ts+fold-wildcard-superuser.test.ts20/20; plugin-securityget-effective-object-permissions.test.ts68/68.scripts/ablation-replace.mjs --deleteof thelowerModifyAllRecords(…)call inclampManagedObjectWrites; anchor 1 → 0 on disk; the test imports the source module relatively, so nodist/leg): on5dd16ba3, 2 failed / 52 passed, the two explicit-modifyAllRecordscases; restored blob equals HEAD,git diff HEADempty.04b5cbbe(--deleteofif (!edit) acc.allowTransfer = false;, core rebuilt,ablation-dist-preflight --absentconfirmed the marker gone from all 14 dist files): core file 2 failed / 53 passed (the plain-transfer case and the wildcard CONTROL); the agreement table stays 68/68, as expected: without the clear the map is broader, the evaluator agrees, and the table only flags the refuse direction. Restored blob equals HEAD; core rebuilt, preflight finds the marker back indist/index.jsanddist/index.cjs.04b5cbbe(the table'sCLAMPEDback to create / edit / delete): 14 failed / 54 passed, the 13 super-user rows onsys_member.transfer/sys_secret.transferplus the new[#30440]row; restored blob equals HEAD.Derived gate families on head 04b5cbb: 62 derived, 62 run exit 0
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 62 commands from the 4 changed paths (146 changed lines); each was run on04b5cbbeand its exit code recorded before any pipe;--ranreconciles: "62 derived famil(ies) accounted for, 62 run, 0 NOT-MEASURED". The amendment's test file addedcheck-tenant-audit-census(+--self-test),check:i18nandcheck:i18n-stale-fill.pnpm check:dual-build-cjs-loads(NOT MEASURED on5dd16ba3) now exit 0: 116 require entry points across 71 packages load.node scripts/check-plugin-teardown-shape.mjs --self-testexit 0 (48 cases) aftergit fetch origin 621a487607881c66b2899b7e3477115229a156b4on the shallow clone.5dd16ba3:pnpm --filter @objectstack/spec check:livenessexit 0 (no spec path in the diff).Generated by Claude Code