Repository navigation
fix(objectql): file-field hydration asks canReadObject before reading sys_file, so a record read logs no access denial - #24434
Conversation
…aller read resolveFileReferences read sys_file as the caller and took the refused arm on the throw, so plugin-security logged an access-denied WARN on every ordinary record read by a reader without sys_file read. The engine now asks the security service's canReadObject first, through the slot ObjectQLPlugin already wires, and serves a refused caller the same refused arm without the read. Unasked (no service, no canReadObject, a throwing ask) the read goes to the middleware as before. The refused arm moves into serveRefusedFileHydration, shared by the ask and the catch. Claude-Session: https://claude.ai/code/session_01YXbt8cZ1Adcz5iV8AdwBXJ Co-authored-by: Claude <noreply@anthropic.com>
Booted pin on the real SecurityPlugin and StorageServicePlugin: a member's by-id and list reads of a record holding its own file log no security denial and serve the body the no-ask read serves byte for byte; the no-ask arm still logs the denial (armed); a direct sys_file read is still 403 PERMISSION_DENIED with one denial logged; a sys_file reader is unchanged. Claude-Session: https://claude.ai/code/session_01YXbt8cZ1Adcz5iV8AdwBXJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXbt8cZ1Adcz5iV8AdwBXJ Co-authored-by: Claude <noreply@anthropic.com>
…le-hydration-no-warn
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 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 04233b5cf0b4bf37b57699043a03eb4e1000c409 && git checkout 04233b5cf0b4bf37b57699043a03eb4e1000c409
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e22defb13c171900e2c1c54d1d15c24ed85fe91b df035c8343a18c3197f7112454e30a6272a6b13e && git checkout -B drift-repro e22defb13c171900e2c1c54d1d15c24ed85fe91b && git merge --no-ff df035c8343a18c3197f7112454e30a6272a6b13e
node scripts/docs-audit/affected-docs.mjs --json e22defb13c171900e2c1c54d1d15c24ed85fe91b
|
Contract reviewServed-tier: What this record read:
① Derived judgmentsThe ask: right, and it only narrows.
So no reader gains a file it could not read before. A reader
It does not log on a denial. On an internal error it logs at The refused arm: moved verbatim.
Both the up-front refusal and the existing Measured, on a booted composition, on one boot:
The pins: real controls. The objectql pin adds 7 cases to ② Semver levelOne ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24335. 4 files, +541 / −39. Not governed (check-governed-merges.mjs --pr 24434: 0 of 4 paths hit the register). |
| Scope | packages/objectql/src/engine.ts: resolveFileReferences asks callerMayReadSysFile (the security service's canReadObject, through the existing slot) before the caller's sys_file read. The refused arm moves verbatim into serveRefusedFileHydration, shared with the PERMISSION_DENIED catch. The objectql pin and a dogfood pin, declared to domain:cli. One patch changeset. plugin-security is untouched. |
| Seat's own read | canReadObject at the head admits isSystem, denies a principal-less context and empty sets, and mirrors the middleware's object-level arms. It does not log a denial. So the ask only narrows, and system reads keep their hydration. |
| Measured | On one boot: a member's by-id and list reads go from 1 denial WARN to 0, with byte-identical bodies. The direct sys_file read is still 403 with its one denial. A sys_file reader is unchanged. The ask costs about 6.4 µs per request, never per row. |
| Ablation | Removing the ask turned the WARN-count pins red, 2 of 5. The restore read back as the HEAD blob. |
| Review | TIER_DEFAULT record of record 6258712209 on this head: PASS, in-seat. |
| Declaration | Clause-②: no on the claim, the PR body and the changeset. |
| Gates | Dev: 74 derived, 72 run with exit 0, 2 NOT MEASURED. Those two (check:query-options-erasure, check:type-check-debt) are repo-wide scans killed or timed out locally; their CI lanes are green on this head. Typecheck exit 0 for objectql and the dogfood package. |
| CI | At this read on df035c8343: everything green or a rostered skip, except Test Core 3/6 and 4/6, still running. Auto-merge waits for them. |
Disposition:
open_questions: both accepted. A, ask before reading. A, a throwing ask reads as the caller, so it never widens.deviations: all accepted, as the record's ③ judges them.out_of_scope_findings: thepublicFormGrantasymmetry narrows only, and is unreached; noted, not filed.
Landing: ordinary queue (pr_ready + automerge_enable through the relay), behind the two running Test Core shards. Fixes #24335 then closes the card.
Generated by Claude Code
Fixes #24335
Clause-②: no (no accept set or public surface moves: the served bodies and every refusal are unchanged; owes the seat's review of record at
TIER_DEFAULTbefore the queue)What changed
resolveFileReferences(packages/objectql/src/engine.ts) readsys_fileas the caller and took the refused arm on the throw. A member with read on the record's object and withoutsys_fileread therefore made asys_fileaccess attempt on every by-id and list read, and plugin-security logged[Security] Access denied: operation 'find' on object 'sys_file' is not permitted for positions [org_member, everyone]at WARN for each one.The engine now asks before it reads, as the parent-record read gate does ("A skipped object is not an access attempt",
parent-record-read-gate.ts):callerMayReadSysFileasks the registered security service'scanReadObject('sys_file', caller). It goes through the slot the engine already resolves (resolveReadableIdsSecurity, wired byObjectQLPlugin) and uses the caller's envelope minus its operation-private keys (withoutOperationPrivateKeys, asreadParentDerivedFilesalready does). No new security API.false: the refused arm is served without the read.true: the caller's read goes ahead exactly as before, and the middleware still decides.canReadObject, or an ask that throws): the read goes to the middleware as before. The ask can only narrow what is read.serveRefusedFileHydration. It is shared by the ask and by the existingPERMISSION_DENIEDcatch, which stays for any refusal the admission did not foresee.plugin-securityis not touched: its refusal, its log line and its level are unchanged.Why this shape (four axes)
sys_fileread hits this on every record read (the WARN reproduced below on the booted composition).canReadObjectis its own object-level gate, arm for arm. The alternative, a context flag marking the hydration read internal, would cross intoplugin-security(domain:services) and add a bypass marker.FileRefusedValueSchema) is unchanged.Measurements (M, booted composition)
New dogfood pin
packages/qa/dogfood/test/file-field-hydration-no-denial.dogfood.test.ts. It runs on a real org-boundbootStackwith the realSecurityPlugin, the realStorageServicePlugin(local adapter, real bytes), and one record object holding a field-owned file. "Before" is measured on the same boot withcanReadObjectwithdrawn from the service, which is the engine's pre-change path.GET /data/fhn_doc/IDsys_filereadon object 'sys_file')res.text()), file hydratedGET /data/fhn_docsys_filereadGET /data/fhn_doc/IDand listsys_filereadGET /data/sys_file/ID403 PERMISSION_DENIED, unchangedRun on
df035c834:pnpm --filter @objectstack/dogfood exec vitest run test/file-field-hydration-no-denial.dogfood.test.ts, 5 passed.Ablation (one, of the ask): done with
scripts/ablation-replace.mjs(anchor hit once, blob0186fa3c→b21c19e7). The ask was disabled, objectql was rebuilt, andablation-dist-preflightfound the marker in 4dist/files. The two member WARN-count pins went red with exactly the issue's line:expected [ "[Security] Access denied: operation 'find' on object 'sys_file' is not permitted for positions [org_member, everyone]" ] to deeply equal [](2 failed, 3 passed). Restore: blob back to0186fa3c,git diff HEADempty, rebuilt,--absentpreflight clean, 5 passed.Cost of the ask: one
canReadObjectper hydration call, which is once per request for both by-id and list (measured: 1 ask per by-id read, 1 per list read), never per row. It is a cached permission check with no store read: 6.4 µs per ask (mean of 300 iterations on a shared box). The refusedsys_fileread it replaces cost about 1.2 ms. A reader withsys_fileread pays the extra ask. End-to-end by-id latency on the shared box was within noise.Pins beside it
packages/objectql/src/engine-file-hydrate-refused.test.tsreuses that file's existing driver double, so no new double is added. Itsbootgains asecurityslot option and an attempts log on the refusing middleware. New cases:sys_fileread and serves the refused marker. The ask is aboutsys_file, carries the caller's envelope and no__keys.sys_fileread is stillPERMISSION_DENIED/ 403 and still reaches the security layer.canReadObject, or an ask that throws, the engine reads as before; a record with no file id asks nothing.Pin sweep: the existing pins on the refused arm and its
debugline (engine-file-hydrate-refused.test.ts,engine-file-hydrate-parent-derived.test.ts, plugin-securityfile-field-hydration-refused.test.ts, service-storagefield-owned-file-hydration-door-agreement.test.ts) wire no security service into the slot, so they still exercise the read-then-refuse arm, and they stay green unmodified.Verification record (head
df035c834, after mergingorigin/maine22defb13)pnpm --filter @objectstack/objectql typecheck: exit 0.pnpm --filter @objectstack/dogfood typecheck: exit 0. Both were run before and after the merge.pnpm --filter @objectstack/objectql exec vitest run: 445 files, 8366 tests passed.objectql,plugin-authandplatform-objects).node scripts/pm/dispatch-gates.mjs --commands(no paths), 74 families. All 72 runnable families exit 0 ondf035c834.--ranreconciliation:74 derived, 72 run, 2 NOT-MEASURED, 0 UNRUN.check:query-options-erasure(repo-wide eslint scan). The first local attempt was in flight when the container killed the worker (exit 137), and the retry hit the 280 s local window. CI'sLint & Repo Gatesruns it.check:type-check-debt(repo-wide tsc census). It hit the same window and is left to CI.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 unbuilt packages). It was built for and re-run, and passes: 111 require entry points load.node scripts/check-platform-checklist.mjs --changed-from origin/main: OK, 0 lost.Acceptance notes
canReadObjectmirrors the middleware's object-level arms but not its public-form grant (publicFormGrant), which admitsfindonly on the form's own target object. A form targetingsys_fileis not a realistic declaration, so no such caller reaches this path. Noted, not filed.packages/spec/distwithout its.d.ts, and a spec rebuild restored it. This was local build state, not a finding.domain:cliat review, per the claim.Generated by Claude Code