Repository navigation
Commit 3b8ea15
fix(storage)!: an unclaimed file is served only within its organization (#25704)
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.
- **The download doors** (`@objectstack/service-storage`,
`storage-routes.ts`, `authorizeDownload`). `GET
/api/v1/storage/files/:fileId/url` and `GET
/api/v1/storage/files/:fileId` still ask for a signed-in caller first,
and answer `401 AUTH_REQUIRED` without one. Then they hand an unclaimed
file to the download authorizer, as they already do for every other
private file. A `deny` answers `403 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.
- **The download authorizer** (`storage-service-plugin.ts`,
`buildFileReadAuthorizer` calling the new module-private
`isWithinCallerOrganization`). 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, under `group`, their organization access
set. This package writes no organization comparison of its own. The
context's organization is the one `resolveRequestExecutionContext`
vetted 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 that `deny` verdict
itself, before the read.
What stays the same:
- The uploader is served their own upload. This is the owner rule every
private file already follows.
- The `single` posture serves every signed-in caller, as before.
- `acl: 'public_read'` stays anonymous.
- Attachments-scope and field-owned files keep their parent-derived
verdicts.
- An anonymous caller still gets `401 AUTH_REQUIRED`.
- A kernel with no auth service binds no authorizer and still serves
these files without a check.
`registerStorageRoutes`' exported `authorizeFileRead` option 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 `isolated` posture, with the organizations runtime
loaded) at `b77bb37eb2`, 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 at `34c018bbad`: 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
carries `Clause-②: no` with 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.
- One `patch` changeset on `@objectstack/service-storage`, titled
`fix(storage)!:`. It carries the BREAKING section (Before / Now / Fix)
and the ADR-0087 disposition `not-required (no-migration-prescription)`.
- This PR touches no governed surface. `skip-changeset` is not applied.
- It does not touch `file-reference-lifecycle.ts` (PR #25682 is open
there), so the two PRs are file-disjoint.
## Tests (head `34c018bbad`)
- New
`packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts`,
with 15 tests. It uses a real `ObjectQL` and `SqlDriver` (sqlite
`:memory:`) and the real `StorageServicePlugin` booted as it ships, so
the door, the session gate, the authorizer and
`resolveRequestExecutionContext` are 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_member` fixture rows; empty for every other
unregistered object) and the `tenancy` service.
- `isolated`: refused (`403 FILE_DOWNLOAD_DENIED` at 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 gets `401`.
- Controls under `isolated`: `public_read` is served to anyone, and an
attached file keeps `ATTACHMENT_DOWNLOAD_DENIED` for 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, its `deny` answers `403
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-config` with the root config over
`packages/services/service-storage/src`: exit 0.
- Full dogfood battery (owed for the `(narrowing)` arm): `pnpm --filter
@objectstack/dogfood test` under the shared lock, with no shard and no
path, on this head. Its counts are in the `os-dev-report` comment on
#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 no `dist/`
is involved.
- **Leg 1, the door's organization scoping removed for the unclaimed
class.** 10 tests went red: all five refusal pins of the new file (three
under `isolated`, the refusal-comparison pin, and one under `group`) and
the five route-level pins of the unclaimed class. 54 stayed green: every
served pin, both controls and the `single` block. Restored: blob
`b34c59e7fdcb` equals HEAD's, and `git diff HEAD` is empty.
- **Leg 2, the wall's `deny` exit 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: blob `1b929b2e9d08` equals HEAD's, and
`git diff HEAD` is empty.
## Acceptance notes
- **The uploader is served from any organization.** This is the owner
shortcut the authorizer already applies to every private file. Should an
unclaimed upload be readable by its uploader alone, or by the whole
organization? Neither the upload door nor ADR-0104 settles that. This PR
keeps the organization scope (plus the uploader) and routes the question
to the seat.
- **A platform administrator** reads the unclaimed files of the
organization they act in, like any member. The door holds no permission
sets, so it grants no wall exemption. The engine makes the same choice
for a system read.
- **A file row with no organization** (invalid state on a walled
deployment under ADR-0131) is in no organization's reach under a walled
posture. Only its uploader is served it.
## Gates
The local subset is the full `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` list for this diff,
reconciled with `--ran`. Every family CI runs is read from this PR's
check-runs. Both are in the `os-dev-report` comment on #25693.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0dc4b5d commit 3b8ea15
5 files changed
Lines changed: 602 additions & 42 deletions
File tree
- .changeset
- packages/services/service-storage/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
481 | 481 | | |
482 | 482 | | |
483 | 483 | | |
484 | | - | |
| 484 | + | |
485 | 485 | | |
486 | 486 | | |
487 | 487 | | |
488 | | - | |
489 | | - | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
490 | 492 | | |
491 | 493 | | |
492 | 494 | | |
| |||
542 | 544 | | |
543 | 545 | | |
544 | 546 | | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
545 | 551 | | |
546 | 552 | | |
547 | | - | |
548 | | - | |
549 | | - | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
550 | 558 | | |
551 | | - | |
552 | | - | |
| 559 | + | |
| 560 | + | |
553 | 561 | | |
554 | | - | |
555 | | - | |
556 | | - | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
557 | 566 | | |
558 | 567 | | |
559 | | - | |
560 | | - | |
| 568 | + | |
| 569 | + | |
561 | 570 | | |
562 | | - | |
563 | | - | |
564 | | - | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
565 | 575 | | |
566 | 576 | | |
567 | 577 | | |
568 | 578 | | |
569 | 579 | | |
570 | 580 | | |
571 | | - | |
572 | | - | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
573 | 586 | | |
574 | 587 | | |
575 | 588 | | |
| |||
624 | 637 | | |
625 | 638 | | |
626 | 639 | | |
627 | | - | |
| 640 | + | |
628 | 641 | | |
629 | 642 | | |
630 | 643 | | |
| |||
656 | 669 | | |
657 | 670 | | |
658 | 671 | | |
659 | | - | |
660 | | - | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
661 | 675 | | |
662 | 676 | | |
663 | 677 | | |
| |||
668 | 682 | | |
669 | 683 | | |
670 | 684 | | |
671 | | - | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
672 | 705 | | |
673 | 706 | | |
674 | 707 | | |
| |||
705 | 738 | | |
706 | 739 | | |
707 | 740 | | |
708 | | - | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
709 | 744 | | |
710 | 745 | | |
711 | 746 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
292 | 292 | | |
293 | 293 | | |
294 | 294 | | |
295 | | - | |
296 | | - | |
297 | | - | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
298 | 301 | | |
299 | 302 | | |
300 | 303 | | |
301 | 304 | | |
302 | 305 | | |
303 | 306 | | |
304 | 307 | | |
305 | | - | |
306 | | - | |
| 308 | + | |
| 309 | + | |
307 | 310 | | |
308 | | - | |
309 | | - | |
| 311 | + | |
| 312 | + | |
310 | 313 | | |
311 | 314 | | |
312 | 315 | | |
| |||
316 | 319 | | |
317 | 320 | | |
318 | 321 | | |
319 | | - | |
320 | | - | |
321 | | - | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
322 | 328 | | |
323 | 329 | | |
324 | 330 | | |
325 | 331 | | |
326 | | - | |
| 332 | + | |
327 | 333 | | |
328 | 334 | | |
329 | 335 | | |
| |||
505 | 511 | | |
506 | 512 | | |
507 | 513 | | |
508 | | - | |
| 514 | + | |
509 | 515 | | |
510 | | - | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
511 | 519 | | |
512 | 520 | | |
513 | 521 | | |
| |||
528 | 536 | | |
529 | 537 | | |
530 | 538 | | |
531 | | - | |
532 | | - | |
533 | | - | |
534 | | - | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
535 | 545 | | |
536 | 546 | | |
537 | 547 | | |
| |||
581 | 591 | | |
582 | 592 | | |
583 | 593 | | |
584 | | - | |
| 594 | + | |
585 | 595 | | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
586 | 601 | | |
587 | 602 | | |
588 | 603 | | |
589 | | - | |
| 604 | + | |
590 | 605 | | |
591 | 606 | | |
592 | 607 | | |
| |||
0 commit comments