Repository navigation
feat(objectql,service-storage): a refused sys_file reader sees a field-owned file's metadata by the download door's verdict (#22637) - #22697
Conversation
… field-owned verdict for the files the record owns WIP: engine seam (registerFieldOwnedFileReadAuthorizer) and the refused arm's parent-derived half. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…ation Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…field-owned verdict The field-owned arm of buildFileReadAuthorizer moves into one module-private batched function, readableFieldOwners; the door asks it for its one owner and start() registers it on the engine through registerFieldOwnedFileReadAuthorizer. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…; changeset Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…le-metadata-parent-derived
📓 Docs Drift CheckThis PR changes 2 package(s): 8 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 — 22 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 c8a7437e95e190c8107a029cad331b9b35aeba7f && git checkout c8a7437e95e190c8107a029cad331b9b35aeba7f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e250d8f84b2fb154692715a066a95a4f00cbd182 9931bb55d34e72a9d0fc784dcba2d59dbc94f89d && git checkout -B drift-repro e250d8f84b2fb154692715a066a95a4f00cbd182 && git merge --no-ff 9931bb55d34e72a9d0fc784dcba2d59dbc94f89d
node scripts/docs-audit/affected-docs.mjs --json e250d8f84b2fb154692715a066a95a4f00cbd182
|
Contract reviewServed-tier: Reading time 2026-10-10T15:30Z. Read-only throughout: card #22637 (body; triage 6095345451; claim 6097826006; os-dev-report 6098964328), decision card #22624 and ruling 6094990917, ADR-0104 D3 on Gate verdicts on the head. 35 check-runs, all concluded: 33 ① Derived judgmentsEach item names what the diff implies and whether it is right against the ruling's words (a refused reader is served the metadata of a file whose
② Semver level
③ Boundary flagsDev deviations, each answered:
Nothing else in the diff falls outside the card's scope. The PR is a draft; after this record the owning seat flips it ready and arms auto-merge on green. Implemented-by: VERDICT: PASS |
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Fixes #22637
Clause-②: yes
What changes
A reader who may read a record but not
sys_file(the default:member_defaultgrants nosys_fileread) used to get every file field back as{ id, metadataRefused: true }(PR #22620). The download door judges the same file by the record that owns it (ADR-0104 D3) and served that reader the bytes. So the reader could download the file but could not see its name.This executes the maintainer's ruling B on #22624, verbatim as the card records it:
contractand nosys_fileread gets{ "signed_pdf": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }.ref_object/ref_idname the record being read, that reader gets{ id, name, size, mimeType, url }, exactly what asys_filereader gets, when the door's field-owned verdict allows. A delegate-governed object gets the delegate's answer.public_readfile the record does not own, and an owned file the verdict refuses.sys_file's own read rule: a directsys_filequery by the same reader is stillPERMISSION_DENIED(403).public_readhandling.sys_filereader's hydration.One verdict, two callers
ObjectQLholds no service registry (nogetServiceor kernel reference inengine.ts), so the engine cannot reach a declaredfileAccessDelegateitself. Copying "delegate declared, so ask it and fail closed; otherwise read the record" into the engine would be the second predicate the ruling forbids. The verdict stays where it is and is handed over, the same wayregisterHeldFileResolveralready hands over the tombstone holder question:service-storage(storage-service-plugin.ts)buildFileReadAuthorizer(the:1237-:1265range the ruling cites) moves, unchanged, into one module-private batched function,readableFieldOwners(registry, engine, ownerObject, ownerIds, authz). It is not exported from the package.where: { id },fields: ['id'],limit: 1, any row allows), and so is the fail-closed handling of the delegate.start()registers the same function on the engine, besideregisterHeldFileResolver, through the new duck-typed seamregisterFieldOwnedFileReadAuthorizer.objectql(engine.ts)registerFieldOwnedFileReadAuthorizer(typeFieldOwnedFileReadAuthorizer, exported).resolveFileReferences,readParentDerivedFilesmakes two reads.sys_fileread in asudo()-shaped context ({ ...caller, isSystem: true }, so the tenant wall and the transaction ride along). Itswhereasks for the ownership pair itself (id $in,ref_objectthis object,ref_id $inthis result's record ids) and projects only seven columns.__keys (withoutOperationPrivateKeys).servableFileRows, extracted, shared) and render through onetoFileValue.id, or any failure: every id keeps the marker. A failure logs onewarn.Is the ownership match a second predicate? No, by the ruling's own words. "Whose
ref_object/ref_idname the record being read" is the ruling's scope condition, not an access verdict. The verdict is reached without a copy: the door's own function decides allow or deny, including the delegate. The system read is the posture the door already reads a file in (by id, system context, never the caller'ssys_filegrant). It serves only ids a record the caller just read holds.The read is deliberately narrower than the door, by the ruling. The door also serves a file's uploader and any file reachable through a
sys_attachmentparent. Hydration serves only a file owned by the record being read.Measured on
mainfirstThe test was a scratch file in
plugin-security, never committed and deleted after the run. It used realObjectQL(source), the realSecurityPlugin, the realStorageServicePluginandSqlDriver. The reader had read on the record's object and nosys_fileread. Two committed files were seeded: one field-owned by recordd1, and one owned byd9and copied intod2.engine.tsandstorage-service-plugin.tsat243dd3c625(blobs3177422431and0246a5bfe4):d1read{ id, metadataRefused: true },d2read{ id, metadataRefused: true }, and a directsys_filequery gotPERMISSION_DENIED/ 403. The premise holds onmain.d1read{ id, name: 'contract.pdf', size: 1024, mimeType: 'application/pdf', url },d2still read{ id, metadataRefused: true }, and the direct query still gotPERMISSION_DENIED/ 403.HEAD: blob equalsHEAD,git diff HEADis empty, and the porcelain status is clean.Pins
packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts(new). Both surfaces are asked about one fixture: the door isGET /files/:fileId/urlas the reader, and the record read runs as the same reader. The fixture is one realObjectQL+SqlDriver, withStorageServicePluginbooted and wired as it ships.fileAccessDelegatethat refuses: the door returns 403FILE_DOWNLOAD_DENIEDand the read keeps the marker. A delegate that allows: the door returns 200 and the read hydrates, and the delegate was asked by the reader for the owning record. Both decisions are readable by a raw record read, so the delegate is what decides.public_readis unchanged: anonymous door 200, and the refused reader's read keeps the marker.sys_filequery is still refused. A multi-value field is judged per element.sys_filerefusal (middleware throwingPERMISSION_DENIED, the shapeplugin-security'sfile-field-hydration-refused.test.tsmeasures on the real layer), the permission store (unregistered objects read empty), and authentication by header.packages/objectql/src/engine-file-hydrate-parent-derived.test.ts(new) covers the engine's half with a recorded stand-in verdict:__keys, and exactly one systemsys_fileread is made, with the ownership pair in itswhere;sys_filereader reads it, and a projection withoutid;warn);sys_filereader, a directsys_filequery, an empty field.mount-storage-routes,storage-routes,file-read-tenancy-posture-admission,storage-routes.authz-outage-relayand the rest ofservice-storage), plus fix(objectql): a refused sys_file read marks the file field refused instead of reading as no file #22620'sengine-file-hydrate-refused.test.tsandplugin-security/src/file-field-hydration-refused.test.ts.Ablation
node scripts/ablation-replace.mjs(WRAP mode) replaced the anchorconst owned = await this.readParentDerivedFiles(objectName, records, uniqueIds, execCtx);with the same call overrecords.slice(0, 0)plus adebugmarker line. That removes the parent-derived arm. The anchor hit once (1 to 0), and the blob changed from6a9455a5d3tobb576869bc../engine, so it reads source): 10 failed and 6 passed. The first pin received{ id: 'f_owned_1aa', metadataRefused: true }. The failing-read and throwing-verdict cases went red too: with the arm gone, nothing is read, so theirwarnnever comes.@objectstack/objectqlresolves todist/, aKNOWN_UNALIASED_TEST_IMPORTSpair):objectqlwas rebuilt with the mutation, andablation-dist-preflightfound the marker in 4 built files. Result: 4 failed and 5 passed. The field-owned pin received{ id, metadataRefused: true }, and the delegate-allow, owner-side-of-a-copy and multi-value pins failed too. The refusal-side pins stayed green, as they should.HEAD(6a9455a5d3),git diff HEADis empty, and the porcelain status is clean. After a rebuild,ablation-dist-preflight --absentpassed (marker absent from all 14 built files, tree clean), and both files went green again: 16 of 16 and 9 of 9.The ablation ran at
e6e7fbec66. The merge oforigin/mainthat followed did not changeengine.ts, theservice-storagepackage or either new test (thegit diff --statis empty).Verification at
9931bb55d3(head,origin/mainmerged)pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 394 files, 7753 tests passed.--project repo: 1 file, 5 tests passed.pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2: 51 files, 846 tests passed.pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/file-field-hydration-refused.test.ts: 4 passed.typecheck:@objectstack/objectqlpassed (exit 0; test layer 40 files / 234 errors / 65 pinned signatures, unchanged), and@objectstack/service-storagepassed (exit 0; test layer clean).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) derives 102 commands. All 102 ran after a fullturbo run build, each with its exit code captured before any pipe, and all exited 0.--ranreconciles them as 102 derived, 102 run, 0 NOT MEASURED. Two red results on the first pass were real and are fixed:check:query-options-erasure(my tests erased query options, 236 to 240, now 236) andcheck:slot-lookup(agetServicetypedanyin my test).eslint --no-inline-config --format jsonover the 5 changed.tsfiles reports 5 files, 0 errors and 0 warnings.eslint.config.mjs, the configpnpm lint(eslint .) runs.parserOptions.project), so this diff cannot move any untouched file's verdict.Cross-lane paths
Declared before any edit:
domain:services, on [PM seat] domain:services — ⏳ vacant · archive of domain:server seat 1 (#6367) #6021 (comment 6097907456):packages/services/service-storage/src/storage-service-plugin.ts: the extraction and the engine registration;packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts: new.domain:devx, on [PM seat] domain:devx @ objectstack — 🟢 os-elon-musk · 由 os-elon-musk 账户的项目负责 #6023 (comment 6098008938), prose only:content/docs/protocol/objectql/types.mdx: thefileread forms;content/docs/permissions/attachments-access.mdx: the refused-reader clause.No generated page and no
content/docs/releases/path.Acceptance notes
FileRefusedValueSchema's TSDoc (packages/spec, "when the reader is refused thesys_fileread that resolves it") is left as it is. It is now a slight overstatement: the marker means the read was refused and the parent-derived verdict did not allow it. Changing it would pull ingen:schema/gen:docs. The same holds forfileAccessDelegate's TSDoc and.describe()("authorizes downloads"). The delegate is now also consulted for a refused reader's metadata. Neither is false; both are incomplete.sys_fileread, reading a delegate-governed record whose delegate refuses them, sees the marker although the door serves them the bytes (the door's owner arm). The ruling cites the field-owned arm only.idcannot name its owner record, so every id keeps the marker. Not measured: whether any shipped caller projects file fields withoutid.sys_fileread and one verdict call: one batched owner read, or one delegate call per owner. The non-delegate arm re-reads records the caller has just read. That is redundant by construction (the ruling's own reduction), and it is the price of one predicate.SecurityPlugin+StorageServicePlugin+ REST). The scratch measurement above did, uncommitted. A dogfood pin would live inpackages/qa(domain:cli) and is not in this PR. Carrier: none.Generated by Claude Code