Repository navigation
fix(plugin-security): canReadObject, getReadFilter and security/explain answer the delegated business-unit read as the read door does - #31142
Conversation
…in answer the delegated business-unit read as the read door does A delegated administrator's read of the business units its admin scopes cover was admitted by the read door only; canReadObject and security/explain still refused it, and getReadFilter did not confine it. The three answers now ask the door's own judgment (delegatedUnitReadAdmission), so they admit the same read and confine it to the same units. Adds the aggregate pin. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bzfJoq2gdSaX2sVcL
… reads every unit Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bzfJoq2gdSaX2sVcL
…it read answers Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bzfJoq2gdSaX2sVcL
…tions instead of erasing them Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bzfJoq2gdSaX2sVcL
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 5f9e49f7f998a5faf614bc90e96e640938d8490e && git checkout 5f9e49f7f998a5faf614bc90e96e640938d8490e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin af3451b08bc39e7b4c163f52397cffe728b3e99d 40c2c8aba3a162e105e7f26d7a4be8ed2f10e4bd && git checkout -B drift-repro af3451b08bc39e7b4c163f52397cffe728b3e99d && git merge --no-ff 40c2c8aba3a162e105e7f26d7a4be8ed2f10e4bd
node scripts/docs-audit/affected-docs.mjs --json af3451b08bc39e7b4c163f52397cffe728b3e99d
|
|
domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B 维护者速读
席位核对ACCEPT · Head-sha:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42383103865 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B Queue red triaged · 2026-11-07T15:07Z Queue build 42383103865 failed in |
Requested by 田騏 · project thread
Fixes #30758
Clause-②: no
Metadata-protocol: none
维护者速读
canReadObject、getReadFilter和security/explain都如实回答“能读,且只能读这几个”,与读门(find/findOne/count/aggregate)一致。canReadObject变 true 时getReadFilter同步收窄到所覆盖单元,绕过中间件的门不会读到范围外单元;回滚即还原本 PR 一个提交。security-plugin-read-filter.ts(收窄,最关键)、explain-engine.ts(object_crud 与记录级判定)、security-plugin-object-admission.ts(第 5 臂的补充)。pnpm --filter @objectstack/plugin-security exec vitest run src/delegated-admin-own-name-set.posture.test.ts,预期 57/57 通过,其中 plugin-security: the delegate business-unit read (#29029) is not reflected by canReadObject / security explain, and its aggregate arm has no pin #30758 新增的 24 个用例覆盖三种租户姿态。What changes
A delegated administrator reads the business units its admin scopes cover (ADR-0090 D12, ruling of record 6663849489). The read door admits that read beside the CRUD grant (
delegatedUnitReadScope, which reachedmainin eb96480, the squash of #31031; #30747 itself closed unmerged). The answers outside the door did not reflect it. This PR makes them ask the door's own judgment; it does not change what the door admits.delegated-admin-gate.ts: the judgment is split out asdelegatedUnitReadAdmission(gate, object, operation, context, sets, delegatorSets).delegatedUnitReadScope(the door's call) now checks only that the read carries a query and hands the rest to it, so there is one judgment and no second copy. Conditions are unchanged: a read (find,findOne,count,aggregate) ofsys_business_unit, own behalf, gate composed, at least one covered unit, fail closed.canReadObject: arm 5 (thefindgrant) accepts, where no set grants, the same admission the middleware makes at step 2. Arms 3, 4 and 6 are unchanged and still run. Object-level "some rows" follows the plugin's existing precedent for every row-restricted read:canReadObjectanswerstrueandgetReadFiltercarries the rows (its docblock: "truenever means unrestricted — the row scope is still getReadFilter's").getReadFilter: where no set grantsfind, the same admission's confinement (anid$infilter over the covered units) is ANDed in beside RLS and controlled-by-parent, as step 3 ANDs it. Without this half, flippingcanReadObjectalone would have let a bypassing door read every unit of the organization (measured below).security/explain: a new optional depdelegatedUnitReadScope(wired to the same judgment).object_crudgrants through it only where the principal's sets do not; therlslayer'sreadFiltercarries the confinement; the record pass reports a unit outside the scope as not visible (decidedBy: object_crud), which matters when an organization administrator explains the delegate.export, writes and on-behalf-of principals are not admitted, as at their doors.Before, measured on this branch's base 5f00374 with the new posture tests, identical in all three postures (
single,group,isolated), delegate scope overbu_east+ subtree,bu_hqoutside:find/findOne/count/aggregatebu_east,bu_east_fieldcanReadObject('sys_business_unit')falsetruegetReadFilterbu_hqincludedbu_east,bu_east_fieldexplainread:allowed/object_crudfalse/deniestrue/grantsexplain'sreadFilterbu_east,bu_east_fieldexplainrecordbu_east/bu_hqTests
One new top-level
describein the existing shared harnessdelegated-admin-own-name-set.posture.test.ts(realObjectQLover SQLite with the realSecurityPlugin), eight cases per posture:canReadObjectadmits the delegate, andgetReadFilterreturns exactly the read door's units.security/explainread: allowed,object_crudgrants,readFilterreturns exactly the door's units.security/explainof one unit, asked by the delegate and by its organization administrator: covered units visible,bu_hqnot.aggregate: inside the scope counts the covered units, awhereonbu_hqcounts none, and agroupByon an FLS-unreadable field is refused by step 2.5b (forbiddenFields: ['external_ref']).canReadObjectand explain alike; an organization administrator's answers are unchanged; acting on behalf of another user (even one who reads every unit) admits nothing anywhere; explain still deniescreateandexport, andcanExportstaysfalse.The
east_adminfixture set gains one field rule (sys_business_unit.external_refunreadable) so 2.5b has a field to refuse.Ablations: every negative pin turned red, then restored by hash
Each leg ran through
scripts/ablation-replace.mjs(anchor hit x1 → x0, replacement x0 → x1), restored withgit checkout HEAD -- PATH, then checked bygit hash-objectagainst the HEAD blob and an emptygit diff HEAD. The test imports the plugin fromsrc, so no build sits between the mutation and the run.getReadFilterdrops the unit scopebu_east, bu_east_field, bu_hqfilterPartsdrops the unit scopereadFilterorganization administrator · bu_hq: expected true to be falseextra.push(delegatedUnitScope)['east:1','east_field:1','hq:1']aggregaterenamed awayforbiddenFieldsundefined(step 2.9 still refuses, without naming the field)canReadObjectpassesnullfor the delegator setsexpected true to be falsenullfor the delegator setsfindinstead of the explained operationcreate: expected true to be falsecanReadObjecttreats a null admission as admittedA first A6 run against a delegator holding no read of
sys_business_unitstayed green: arm 6 (the delegator's own grant) already refuses it. The control now names a delegator who reads every unit, which is the case only the judgment's own on-behalf-of condition refuses; A6 and A6b are red against that version (commit 7792e80).Acceptance notes
canReadObjectis also asked by the engine'sReadableIdscomposition over a master (ADR-0139). An app object that iscontrolled_by_parentwithsys_business_unitas its master would now let a delegate read that object's rows under the covered units, where it read none before. No platform object is such a detail (searchedpackages/platform-objects/src), and ADR-0055 makes reading a detail follow reading its master, so this follows the ruled read rather than widening past it. The seat should confirm this reading.objectReadAdmission's other asker is unchanged: it hands no fallback, so onlycanReadObjectgains the delegated read.reference_not_foundcomment inpackages/spec/src/api/errors.zod.ts.Verification
Local runs on the final head
pnpm --filter @objectstack/plugin-security test:: exit 0 — Test Files 248 passed, Tests 5820 passed, 45 skipped (before and after the merge).pnpm --filter @objectstack/plugin-security typecheck:: exit 0 (test layer: 0 files, 0 errors in the debt ledger).--no-inline-config) on the 7 touched source files :: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 64 families for this diff; all 64 ran on head 2d9ed89 and exited 0.dispatch-gates --ranwith the exit-coded record :: exit 0 — "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). A first pass on an earlier head had four non-zero exits, all resolved before the run above: three exit 3 for missing prerequisites (a shallow clone forcheck-plugin-teardown-shape --self-test, the CLI build closure forcheck:i18n, a fullpnpm buildforcheck:dual-build-cjs-loads), andcheck:query-options-erasureexit 1 because the new test helpers cast three query-option bagsas any; they are typed now (commit 2d9ed89).origin/main(two commits, neither in plugin-security; head 40c2c8a): formula, platform-objects, plugin-auth and service-settings rebuilt, thenpnpm --filter @objectstack/plugin-security typecheckandtest:: exit 0 (248 files, 5820 passed, 45 skipped), and the ratchet and changeset families again on 40c2c8a:Generated by Claude Code
🤖 Generated with Claude Code