Repository navigation
fix(storage)!: an unclaimed file is served only within its organization - #25704
Conversation
A file with neither an attachments scope nor a field owner is now judged by the download authorizer like every other private file: after the signed-in check it is served within its organization, as every other organization-scoped row is read. The organization wall's verdict decides the posture's answer and the driver's tenant term decides the reach; no organization comparison is written here. The uploader shortcut is kept, `single` is unchanged and `acl: 'public_read'` stays anonymous. Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude <noreply@anthropic.com>
…laimed branch; changeset Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 10 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 3efe02e1b9189e8381837c82675522ea22db5224 && git checkout 3efe02e1b9189e8381837c82675522ea22db5224
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b1790841bb0bf2c7c696e858de51e8105704471a 34c018bbad650498162ddc8198eb65b554d60958 && git checkout -B drift-repro b1790841bb0bf2c7c696e858de51e8105704471a && git merge --no-ff 34c018bbad650498162ddc8198eb65b554d60958
node scripts/docs-audit/affected-docs.mjs --json b1790841bb0bf2c7c696e858de51e8105704471a
|
席位核对ACCEPT (seat review,
Not landed in this act. The Generated by Claude Code |
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS
Generated by Claude Code |
Fixes #25693
Clause-②: no (narrowing)
Metadata-protocol: none
What this changes
This PR makes one rule true: an unclaimed file is served only within its organization, as every other organization-scoped row is read. An unclaimed file is a private file with neither an attachments scope nor a field owner. It is either an upload that no record has claimed yet, or one whose record let it go.
@objectstack/service-storage,storage-routes.ts,authorizeDownload).GET /api/v1/storage/files/:fileId/urlandGET /api/v1/storage/files/:fileIdstill ask for a signed-in caller first, and answer401 AUTH_REQUIREDwithout one. Then they hand an unclaimed file to the download authorizer, as they already do for every other private file. Adenyanswers403 FILE_DOWNLOAD_DENIED, the status and code the doors already give a non-attachment file the caller may not read. Its message names no record. An allowed download keeps the presigned TTL this class has always been minted with. No error code is added.storage-service-plugin.ts,buildFileReadAuthorizercalling the new module-privateisWithinCallerOrganization). The existing owner shortcut comes first. After it, an unclaimed file is judged in the two places that judge every other organization-scoped row. The first is the organization wall's verdict (computeTenantLayer0Verdict, ADR-0131 D8), asked over the caller's resolved context for the posture in force. The second is the driver's tenant term: the row is read by id under the caller's active organization (tenantId) and, undergroup, their organization access set. This package writes no organization comparison of its own. The context's organization is the oneresolveRequestExecutionContextvetted against current membership. A caller with no organization scope under a walled posture is in no organization's reach, as the wall decides for every other row. The door answers thatdenyverdict itself, before the read.What stays the same:
singleposture serves every signed-in caller, as before.acl: 'public_read'stays anonymous.401 AUTH_REQUIRED.registerStorageRoutes' exportedauthorizeFileReadoption is now also asked about this class. Its doc says so, and the changeset's BREAKING section covers it.Measurement
The card's maintainer word was 「立卡,先实测再修」, so the measurement came first. It ran through the public door on a fresh boot (a real HTTP server under the
isolatedposture, with the organizations runtime loaded) atb77bb37eb2, before any edit. Its one-line result is on the card. The detail is with the seat, off GitHub. The same measurement was repeated on the fixed build at34c018bbad: the rule holds, and the controls are unchanged. That detail is with the seat too.Declarations
Clause-②: no (narrowing). Under a walled posture the doors serve an unclaimed file to fewer callers, and nothing is widened. The claim carriesClause-②: nowith no arm. The dispatch asked for the(narrowing)arm, and AGENTS.md Post-Task Checklist step 3 names that arm for this case, so it is added. The report notes the difference.Metadata-protocol: none. Nothing a metadata author writes changes.patchchangeset on@objectstack/service-storage, titledfix(storage)!:. It carries the BREAKING section (Before / Now / Fix) and the ADR-0087 dispositionnot-required (no-migration-prescription).skip-changesetis not applied.file-reference-lifecycle.ts(PR fix(rest)!: an anonymous public-form submission carries no file #25682 is open there), so the two PRs are file-disjoint.Tests (head
34c018bbad)packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts, with 15 tests. It uses a realObjectQLandSqlDriver(sqlite:memory:) and the realStorageServicePluginbooted as it ships, so the door, the session gate, the authorizer andresolveRequestExecutionContextare all real. Each case calls both doors, and where a file is served it follows the minted URL to the bytes. The stand-ins are the session read (two test headers), the permission store (sys_memberfixture rows; empty for every other unregistered object) and thetenancyservice.isolated: refused (403 FILE_DOWNLOAD_DENIEDat both doors, no URL minted, nothing about the file in the body) to a caller acting in another organization, to a signed-in caller acting in no organization, and to a caller whose session names the file's organization without a membership in it. Served to a colleague in the file's organization and to the uploader. The uploader is also served while acting elsewhere (the owner rule). An anonymous caller gets401.isolated:public_readis served to anyone, and an attached file keepsATTACHMENT_DOWNLOAD_DENIEDfor another organization and is served to a colleague.group: served to a member of the file's organization who is acting in another of their organizations, and refused to a caller with no membership in it.single: served to every signed-in caller whatever organization the session names, and refused (401) to an anonymous caller.storage-routes.test.ts: five existing pins asserted the session-only branch this PR removes. Each was re-judged on its own subject: the unclaimed class now asks the authorizer, itsdenyanswers403 FILE_DOWNLOAD_DENIED, an allowed download keeps the presigned TTL, and the bare-kernel pin binds no authorizer, as a bare kernel binds none. One route-level pin was added: a denied signed-in caller is refused at both doors and no URL is minted.pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2: Test Files 63 passed (63), Tests 1147 passed (1147).pnpm --filter @objectstack/service-storage typecheck: exit 0. The test-layer program (tsconfig.test.json, checked with--listFiles) compiles both test files.eslint --no-inline-configwith the root config overpackages/services/service-storage/src: exit 0.(narrowing)arm):pnpm --filter @objectstack/dogfood testunder the shared lock, with no shard and no path, on this head. Its counts are in theos-dev-reportcomment on files: an authorization-adjacent gap on a file-serving door (detail withheld pending measurement) #25693.Proof (ablation)
Each leg was mutated through
node scripts/ablation-replace.mjs, which checks on disk that the anchor hit and that the blob changed, then restores and checks the result against the HEAD blob. The subject is this package's source, imported relatively by both suites, so nodist/is involved.isolated, the refusal-comparison pin, and one undergroup) and the five route-level pins of the unclaimed class. 54 stayed green: every served pin, both controls and thesingleblock. Restored: blobb34c59e7fdcbequals HEAD's, andgit diff HEADis empty.denyexit removed. Exactly the 2 pins for a caller with no organization scope went red ("acts in no organization", and "names the file's organization without a membership in it"). The other 13 stayed green, so the driver's tenant term alone still refuses another organization. Restored: blob1b929b2e9d08equals HEAD's, andgit diff HEADis empty.Acceptance notes
Gates
The local subset is the full
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstacklist for this diff, reconciled with--ran. Every family CI runs is read from this PR's check-runs. Both are in theos-dev-reportcomment on #25693.Generated by Claude Code