Repository navigation
fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller - #22439
Conversation
…and no field owner requires a signed-in caller The two download doors now ask the session resolver for a file that has neither an attachments scope nor a field owner, unless the file is acl public_read (ADR-0104). No session answers 401 AUTH_REQUIRED, the pair the routes' other unauthenticated refusals answer. A kernel with no session resolver keeps serving these downloads and says so once. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…ts scope and no field owner on a booted showcase Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…the signed-in download of an unclaimed file Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…claimed-download-signed-in
📓 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:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 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 9f9f20034e86bb5cd0b59fff8d332e2b14df3c42 && git checkout 9f9f20034e86bb5cd0b59fff8d332e2b14df3c42
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 081e6a09d4042cc66a972baca3af51bc1e2c82af 42fe8d498ce437d0c261ef6f191b15cbd2258d92 && git checkout -B drift-repro 081e6a09d4042cc66a972baca3af51bc1e2c82af && git merge --no-ff 42fe8d498ce437d0c261ef6f191b15cbd2258d92
node scripts/docs-audit/affected-docs.mjs --json 081e6a09d4042cc66a972baca3af51bc1e2c82af
|
Fixes #22431
Clause-②: no (narrowing)
Executes ruling
6074960686item 3 (#22146), as triage routed it: a storage download of a file with no scope and no field owner requires a signed-in caller, and a file declaredacl: 'public_read'stays anonymous (ADR-0104). Function level only, as the card requires.What changes
packages/services/service-storage/src/storage-routes.ts,authorizeDownload(the one gate both download routes call). A file with neither an attachments scope nor a field owner (an upload no record has claimed) now needs a session from the existingresolveSession; a caller with none is refused401 AUTH_REQUIRED, the pair the upload gate and the attachments gate already answer. No new code, no spec change.acl: 'public_read'is checked first and stays anonymous for every class. Attachments-scope and field-owned files keep theirauthorizeFileReadverdicts, untouched. A resolver that throws, or a session with no user, fails closed.resolveSessionwired, these downloads stay open as before, and the module now says so once (the existing one-time notice names only the upload routes).resolveSessiondocblock no longer calls download gating "a tracked follow-up", and theauthorizeFileReaddocblock no longer names an organization logo as anonymous.content/docs/permissions/attachments-access.mdxsaid that avatars, image-field thumbnails and organization logos keep an anonymous capability URL. This change makes the avatar and logo half false, and the image-field half has been false since field-owned files were gated. The paragraph and two table rows now state the three classes. This file is outside the dispatch's file fence; see Acceptance notes.@objectstack/service-storage,minor, with the BREAKING banner, the remedy (sign in, or mark the filepublic_read) and an ADR-0087 dispositionnot-required (no-migration-prescription).Measured before building (the card's stop condition)
Measured on
origin/mainb9222dc701on a booted showcase (objectstack dev --fresh,singleposture). The readings stay in the seat's container. Classes only here:401 AUTH_REQUIREDto the same caller, and an anonymous upload was refused401 AUTH_REQUIRED.sys_filerow (StorageMetadataStore.createFile). The copy-on-claim copy is claimed by construction. No seed, branding, theme or import path creates one, and the showcase seeds none. The rows that stay unclaimed come from what clients do with an upload: the console writes an uploaded avatar into the user'simageURL field and an uploaded organization logo into the organization'slogoURL field. Neither is a file-class field, so neither is ever claimed. A picked file stays unclaimed until its record is saved, an abandoned upload stays unclaimed, and so does a file whose owner released it. Nothing in the repository produces apublic_readfile.resolveSessionreads the cookie the same as a bearer header.Tests
storage-routes.test.ts: a new block for the unclaimed class. It covers the refusal at both doors (code, status, envelope, no URL minted, authorizer not consulted), parity with the upload gate's anonymous answer, fail-closed on a throwing resolver and on a user-less session, and a signed-in caller served as before (302, and the presigned TTL read back out of the minted URL). It also coverspublic_readanonymous without a session read, the parent-governed verdicts unchanged and the resolver not consulted, a missing file 404 before the session is asked, and a bare kernel open with one notice. Against the unfixed code: 4 failed / 40 passed (the refusal pins and the notice red; the controls green). With the fix: green.error-envelope.conformance.test.ts: the new refusal joins the driven error branches.storage-unclaimed-download.dogfood.test.ts(one file, booted showcase with the storage plugin): the anonymous refusal at both doors, a bearer caller served, a cookie-only caller served (the transport an image tag uses),public_readanonymous and back, and controls (anonymous upload, attachments-scope download). Against main'sdist: 2 failed / 4 passed. With the fix: 6 / 6.scripts/ablation-replace.mjs, restore trap held): deleting the session-gate call inauthorizeDownloadlanded (anchor 1 → 0, blob changed). After a rebuild,ablation-dist-preflight --absentshowed the call gone from all 4 built files. Unit + conformance went 5 failed / 57 passed, and the dogfood file went 2 failed / 4 passed. Restore: blob equal to HEAD,git diff HEADempty,git status --porcelainempty. The rebuild preflight showed the call present indist/index.jsanddist/index.cjs, then 62 / 62 and 6 / 6. (The mutated build's DTS step failed on the now-unused helper,TS6133. ESM and CJS were rebuilt, and those are what the suites import.)@objectstack/service-storagesuite: 46 files, 782 passed;typecheckexit 0 (withcheck:test-typecheck).sys_file(18 files) at42fe8d498c: 175 passed, 1 skipped (the pre-existingskipIf(!organizationsAvailable)block), exit 0.@objectstack/dogfoodtypecheckexit 0, with the new file in the program.Gates (at
42fe8d498c, after mergingorigin/main05c7c3fa3b)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 98 commands from the six changed paths. All 98 were run, and all exited 0.--ranreconciliation:98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3).pnpm check:error-status-conformance(also by hand, per the dispatch):✓ every derivable runtime status is documented, and every documented status is reachable.check-changeset-no-majorwith this body as the event:✓ This diff introduces no major bump.and✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch.check-adr-0087-registration:✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition(BREAKING+bang+clause-②-narrowing,not-required (no-migration-prescription)).check:nul-bytes:OK (... no raw ASCII control bytes).check:route-envelope,check:doc-authoring,check:cross-package-test-inputsandcheck:test-source-aliasall exited 0.--print-configresolves for every one), the JSON output counts 4 files with 0 errors and 0 warnings, andeslint.config.mjsnever enables type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's run.Acceptance notes
storage-routes.tsand its tests, one dogfood file and one changeset. This PR also correctscontent/docs/permissions/attachments-access.mdx, because the change makes its statement false. The standing dev rules require a published statement this change falsifies to be fixed in the same change. The conflict is named here rather than settled silently. Drop the commit if the seat rules otherwise.mountStorageRoutes' unbound-gates warning and theStorageRoutesMountReport.sessionResolverdocstring (instorage-service-plugin.ts, which PR fix(auth,services): the services-lane in-process session reads stop renewing a cookie session (#22258) #22396 edits and this PR does not touch) still describe the resolver as gating uploads. Both are still true and now incomplete. Carrier: none..changesetgrading:check-changeset-no-majorandcheck-adr-0087-registrationverdicts are under Gates.Generated by Claude Code