Repository navigation
fix(objectql)!: a non-system write may name, in a lookup or user field, only a record the caller can read - #30672
objectstack-fleet[bot] wants to merge 22 commits into
Conversation
… the caller can read The write's reference check now asks the caller's own read door for every `lookup` and `user` value a non-system caller supplies, exactly as it already did for a `master_detail` header: a target the read door does not return answers `reference_not_found`, the answer of a target that exists nowhere. System writes keep their exemption; a value the row already stores is not judged again. Claude-Session: https://claude.ai/code/session_01ADHkmSZhbvtSoFM7YTkq6B Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01ADHkmSZhbvtSoFM7YTkq6B Co-authored-by: Claude <[email protected]>
…edits for the caller-read rule Claude-Session: https://claude.ai/code/session_01ADHkmSZhbvtSoFM7YTkq6B Co-authored-by: Claude <[email protected]>
…e record Claude-Session: https://claude.ai/code/session_01ADHkmSZhbvtSoFM7YTkq6B Co-authored-by: Claude <[email protected]>
The parked implementation edited engine.ts bodies that main has since moved into facets: the reference-check docblock now lives in engine-referential-integrity.ts and the caller-readable reference helper in engine-references-and-master-detail.ts. The resolution keeps main's layout and applies the parked change where the code now lives. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…on rules A reference a caller cannot read answers reference_not_found on every write door (insert, update by id, bulk update, and the validate preview), whatever a validation rule would answer. The one-hop predicate read's docblock states the rule that replaces the retired write-without-reading-the-parent persona. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…scope covers A delegation's admin scope over a business-unit subtree (ADR-0090 D12) also grants read on the units inside that scope, so delegated administration can name the units it administers. The read is admitted only for a read of sys_business_unit the caller's sets do not grant, on the caller's own behalf, and is bounded to the union of the subtrees its scopes cover. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…eference check A write that is not a system write has its references checked before the record's validation rules; a system write keeps the order it had, so its answer is unchanged. One predicate, referencesCheckedBeforeRules, is read by insert, update by id, bulk update and the validate preview. Pins: each door answers reference_not_found for an unreadable reference a rule would trip on, and for one that exists nowhere, while a readable one still answers the rule; a system write and a flow run's system write keep the rule's answer; a read of the target that fails is refused, and a reference to an unregistered object is admitted. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
The engine's reference check now reads a user or lookup target through the caller's own read door, so suites whose subject is another predicate on the same write provision sys_user with a row for each user they name, and give their callers the org_user_ids the runtime fills from sys_member. The write-preview parity controls read the related account, and a new block drives the editor who cannot: both doors answer the lookup's reference_not_found, never the rule. The tenant-scope suite counts the rule's related reads alone, and a user the caller cannot read is now answered on the reference check. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
The records-forms predicate-relationship-traversal item takes revision 7: a member who cannot read the lookup parent may not name it, and is answered reference_not_found for a tripping and a non-tripping parent alike. The dogfood case is rewritten to that rule, and the changeset states the refused writes and the delegate's business-unit read. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…reference check The write's reference check reads each lookup or user value it names through the caller's read door, projected to id alone. The read-count pins measure what the audit summary reads, so that read is left out of their counters. The log line for a failed caller read names a referenced record rather than a master-detail header, as the read now serves lookup and user values too. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…ds its scope's units The error catalog's reference callout and the field reference's referential integrity section state that a non-system write may name, in a lookup, master_detail or user field, only a record its caller can read, before the record's validation rules. The delegated administration page states that an admin scope grants read on the business units inside its subtree. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…first With the reference check before the record's validation rules, a non-system write that names a master-detail header it cannot read, or one that exists nowhere, answers the field's reference_not_found rather than the rule's unevaluable refusal; a header the row already stores is still bound by the rules, and a system write keeps the rules first. The header-read counts add the reference check's read door, and the stored-metadata suites count only reads that ask for more than id. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…nd the delegate-read pins type their options The system-context page's reference-check row names referencesCheckedBeforeRules, and its site register is regenerated. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
The reference check composes #30205's ban check with the caller-read rule: a `lookup` or `user` value naming a `sys_user` row asks the caller's read door first (callerReadableReferenceRow), then the one elevated probe that answers the row's existence and its ban. A user the caller cannot read answers reference_not_found, and its ban is not read. The banned-user rig gives its caller the read of a user's id a member has, and pins both. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
… rule is what answers it A non-system write now meets the reference check, and with it the ban check, before the record's validation rules; the control's peer keeps `banned` true for the rule to read, with a ban no longer in force. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 3 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 35 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 b6eef4c978eb120244c4e43b1b0e825f3634f9ba && git checkout b6eef4c978eb120244c4e43b1b0e825f3634f9ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a 4ba91079fb120a94b2557e75eff3e84bfabb9659 && git checkout -B drift-repro 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a && git merge --no-ff 4ba91079fb120a94b2557e75eff3e84bfabb9659
node scripts/docs-audit/affected-docs.mjs --json 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a
|
Revision 7 of records-forms.predicate-relationship-traversal, the dogfood case and the objectql pins say what the write answers: a reference the caller cannot read is reference_not_found, before the record's validation rules. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
|
security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg Security read at
|
Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…writer reads A grant's holder is a `user` value, so a non-system write naming it must name a user its caller can read; the suite provisions its three users, as the sibling grant suites do, so the set the row names is what it measures. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
…e probe names A write may name, in a lookup or user field, only a record the writer can read (#29029). The verifier's permission sets now read every record of each object a probed detail's required reference names outside its master chain, and the fixture's manager position reads the people a contract's signer names. Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <[email protected]>
|
domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 Superseded · 2026-11-06T00:31Z This PR's code continues on #30747 (branch |
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6CYQjMkN514R78sCrTT
维护者速读
lookup/user字段里只能引用调用方自己读得到的记录,读不到的一律答reference_not_found(与master_detail同一条规则),且先于记录校验规则;委派管理员的 adminScope 覆盖的业务单元子树,委派人可读这些单元,范围外一个不读。record.crm_account.type); replaces the removedos.lookupdeclaration (#18318, batch #148) #18682 的旧角色退役。sys_business_unit的读、只在调用方自己的权限集不给读时生效。回滚即撤本 PR。packages/objectql/src/engine.ts(三条写路径的顺序)、packages/plugins/plugin-security/src/delegated-admin-gate.ts的delegatedUnitReadScope、security-plugin-authz-middleware.ts第 2/3 步。pnpm --filter @objectstack/objectql exec vitest run src/engine-unreadable-reference-target.test.ts,预期 99/99 绿。Warning
BREAKING — accept-set narrowing at the write's reference check (
@objectstack/objectql), served by every write door. After the upgrade, a write that is not a system write is refused400 VALIDATION_FAILEDwith the field's localizedreference_not_foundwhen alookuporuservalue it supplies names a record the caller cannot read.Fixes #29029
Clause-②: yes (narrowing) — the write's reference check narrows: a non-system
lookuporuservalue naming a record the caller cannot read is refused. The business-unit read widens: a delegated admin reads the business units inside the admin scope it holds, and no other.Metadata-protocol: none
Skills-docs: none — no file under
skills/**teaches a reference to a record its writer cannot read, or a delegate's business-unit read (a grep ofskills/forreference_not_foundfinds only the permission-set catalog refusal, unchanged);content/docsstates the rule on four pages (below).What changes
The rule. A write that is not a system write may name, in a
lookup,userormaster_detailfield, only a record its caller's own read door returns. An unreadable reference target always answersreference_not_found, before the record's validation rules. A record the caller cannot read answers exactly as a record that exists nowhere, and so does one whose read by the caller fails. System writes (isSystem) stay exempt, keep the order they had and give the answer they gave; a flow run's elevated write keeps its existence check. A reference to an object the registry does not hold is not judged.treekeeps the existence check alone. Alookuporuservalue naming a user the caller cannot read answersreference_not_foundand its ban is not asked; a user the caller can read is judged for its ban as before (#30205).The delegate read. An admin scope over a business-unit subtree (ADR-0090 D12) grants read on the business units inside that subtree, and on no unit outside it. It applies to a read of
sys_business_unitthat the caller's own permission sets do not grant, on the caller's own behalf, bounded to the union of the subtrees the caller's held scopes cover. A caller with asys_business_unitgrant reads as that grant says, as before.packages/objectql/src/engine-referential-integrity.ts—lookupanduserjoinmaster_detailon the caller's read door;referencesCheckedBeforeRules, the one predicate every write door and the validate preview read for the order.packages/objectql/src/engine.ts,engine-validate.ts— insert, update by id, bulk update and the validate preview check references before the record's validation rules unless the write is a system write.packages/objectql/src/engine-references-and-master-detail.ts,engine-master-detail-related.ts— the caller-readable reference helpers (callerReadableReferenceExists, andcallerReadableReferenceRowfor the user reference whose ban is also asked), and the formula/objectql: relationship traversal in predicates — a validation rule or visibility predicate reads one hop through a lookup (record.crm_account.type); replaces the removedos.lookupdeclaration (#18318, batch #148) #18682 docblock now states the rule.packages/plugins/plugin-security/src/delegated-admin-gate.ts,security-plugin-authz-middleware.ts—scopedBusinessUnitIdsanddelegatedUnitReadScope; the middleware admits the read and narrows it toid INthe scoped units.docs/qa/platform-checklist/areas/records-forms.json—records-forms.predicate-relationship-traversaltakes revision 7, written to the rule.content/docs/api/error-catalog.mdx,content/docs/data-modeling/fields.mdx,content/docs/permissions/delegated-administration.mdx— each states the rule where a sentence would otherwise have gone false.content/docs/permissions/system-context.mdx— row 23 says where a system write's reference check runs, and the generated site register namesreferencesCheckedBeforeRules..changeset/29029-lookup-user-caller-read.md—@objectstack/objectqland@objectstack/plugin-security, patch.Tests
packages/objectql/src/engine-unreadable-reference-target.test.ts(99):lookupanduser× insert / batch insert / update by id / bulk update — an unreadable target is refused as one that exists nowhere; a refused read of the target object answers the same; a multi-valued field with one unreadable member; a system write; a stored value carried back; the read door before the elevated probe. Added: on all four doors and forlookup,userandmaster_detail, an unreadable reference answersreference_not_foundbefore the record's validation rules while a readable one reaches them; a reference to a record that exists nowhere answers the same before them; the validate preview agrees; controls — a system write and a flow run's system write keep the rules first, and a flow run's write keeps its reference check; a read of the target that fails is refused; a reference to an unregistered object is admitted.packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts(33), three postures: a delegate reads exactly the units its scope covers (find,findOne,count); assigning a unit in scope is admitted; a unit outside the scope it cannot read is refusedreference_not_found; controls — a writer with no scope, and an organization admin, unchanged.packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts: the controls' callers read the related account; a new block drives the editor who cannot, and both doors answer the lookup'sreference_not_found.sys_userwith a row per user they name (__tests__/rig-users.testkit.ts) and give organization callers theorg_user_idsthe runtime fills fromsys_member;predicate-related-read-tenant-scopecounts the rule's related reads alone, and the peer its rule reads carries an expired ban, so the rule, not the ban check, answers it; four plugin-audit suites leave the write's id-only reference-check read out of their read counters.packages/objectql/src/engine-banned-user-reference.test.ts(11): the rig's caller reads a user's id, as a member does; the ban is read elevated; added: a user the caller cannot read isreference_not_found, and its ban is not read.packages/qa/dogfood/test/predicate-relationship-traversal.dogfood.test.ts: the member who cannot read the parent is answeredreference_not_foundonparent, and nothing is stored.Ablations: each through scripts/ablation-replace.mjs, restored blob equal to HEAD, git diff HEAD empty
The objectql pins import
./engine.jsfrom source and plugin-security's vitest aliases@objectstack/objectqlto source, so each mutation is read fromsrc/.delegatedUnitReadScopereturns{}instead of{ id: { $in: [...ids] } }: delegated-admin posture suite 6 failed | 27 passed — the exact-read pin and the outside-unitreference_not_foundpin × 3 postures.null: 21 failed | 12 passed; the no-scope and organization-admin controls green.referencesCheckedBeforeRulesreturnsfalse: 17 failed | 82 passed — every before-the-rules pin on four doors × three field types, the exists-nowhere pins and the preview pin; controls green.true: 4 failed | 95 passed — the flow-run system-write order control × four doors.false: 1 failed | 98 passed — the unregistered-object pin.engine-banned-user-referenceandengine-unreadable-reference-target— the two ban pins on the caller's read and 15lookup/userpins.Deviations
mainduring this card and changed the same reference check. Its test rig gave a non-system caller no read ofsys_userat all, and one of its pins held that such a caller is still answeredreference_user_banned; ruling 6663849489 answers that callerreference_not_found. The rig now gives its caller the read of a user's id a member has, that pin now holds that the ban is read elevated, and a new pin holds the ruling's answer. Ablation 7 above: 17 red.content/docs/permissions/system-context.mdxandpackages/plugins/plugin-security/src/__tests__/rig-users.testkit.tssit outside the named file surface: the system-context census needs a row namingreferencesCheckedBeforeRules, and the tenant-audit census would otherwise count the testkit's system insert as an application write.Acceptance notes
packages/services/service-automation/src/runas-system-reference-check.integration.test.tsstill namesmasterDetailHeaderExistsin a comment, outside this card's surface.delegatorSets) is not widened. Explain andcanReadObjectdo not reflect the delegate read.Verification
All on head
75a7c44d96(origin/main14c67d446emerged at93fce6c797; the dogfood closure was rebuilt after the merge; the one commit after it edits one plugin-security test).Suites, typecheck, lint
@objectstack/objectql: Test Files 557 passed (557), Tests 11155 passed (11155).@objectstack/plugin-security: Test Files 245 passed (245), Tests 5412 passed, 45 skipped (5457).@objectstack/plugin-audit: Test Files 73 passed (73), Tests 1145 passed (1145).pnpm --filter <package> typecheckfor objectql, plugin-security (its test program included) and plugin-audit: exit 0.pnpm lint: exit 0.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackover the changed paths: 97 families, each run with its exit code captured before any pipe, all exit 0.--ran: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN.The first pass, before the last fixes, had six non-zero exits:
check-tenant-audit-censusand its self-test: the new testkit'ssys_userinsert counted as an application write call site. The testkit moved tosrc/__tests__/.check:system-context-census:referencesCheckedBeforeRulesreadsisSystemand no row named it. Row 23 now does, and the register is regenerated.check:query-options-erasure: threeas anyon engine options in a new posture test. They are typed now.check:skill-examplesandcheck:dual-build-cjs-loads: exit 3 until the workspace was built, exit 0 after.Full dogfood battery on
75a7c44d96under the verify lock:VERDICT command-exit 0, Test Files 420 passed, 3 skipped (423), Tests 3504 passed, 36 skipped (3540).🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT