Skip to content

Commit 9347701

Browse files
committed
docs(service-storage): changeset and the attachments-access page for the signed-in download of an unclaimed file
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 720d6d3 commit 9347701

2 files changed

Lines changed: 29 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
---
4+
5+
fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at the two storage download routes, not a metadata change: no spec key, export, option, response field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers those routes serve for one file class: a caller with no session is now refused a file that has neither an attachments scope nor a field owner, while a signed-in caller is served as before. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these routes and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The storage download routes, both the one that answers a signed URL and the stable one that redirects to the bytes, now require a signed-in caller for a file that has neither an attachments scope nor a field owner: an upload no record has claimed. That is the class an avatar or an organization logo stored as a URL belongs to, and so is a picked file not yet saved to its record. ADR-0104 made the anonymous capability URL an opt-in, `acl: 'public_read'`; this was the one class still served anonymously by default.
14+
15+
- **Refused now:** a caller with no session, with `401 AUTH_REQUIRED`, the answer the upload routes and the attachments gate already give an unauthenticated caller. No signed URL is minted for the refused caller.
16+
- **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `<img src>` and `<a href>` send the session cookie the sign-in set, so a signed-in page keeps rendering these files. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-record verdicts. A deployment with no `auth` service, whose storage routes run without a session resolver, keeps these downloads open as before and says so once in its log.
17+
18+
What changes for you. Before this release, anyone holding such a file's id could download it; now, sign in first. A file that must render before sign-in (on a sign-in page, in an email, on a public page) needs `acl: 'public_read'` on its `sys_file` row.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ record, and issue a **short-lived signed URL**:
111111

112112
| Code | Status | When |
113113
| --- | --- | --- |
114-
| `AUTH_REQUIRED` | 401 | Anonymous download of an attachments-scope file, or a credential the organization wall refuses (see below) |
114+
| `AUTH_REQUIRED` | 401 | Anonymous download of any file not marked `acl: 'public_read'` (see below), or a credential the organization wall refuses |
115115
| `ATTACHMENT_DOWNLOAD_DENIED` | 403 | The caller is neither the file's owner nor able to read any record it is attached to |
116116

117117
**The 401 is the generic "unauthenticated" answer, and it has always covered
@@ -132,10 +132,15 @@ anonymous case does: the response is byte-identical to sending no credential at
132132
all, and the reason is written to the server log instead. The check itself lives
133133
in the shared API-key admission path, not in the attachments gate.
134134

135-
The gate is scoped to attachments files on purpose: **non-attachments files**
136-
(avatars, `Field.image` thumbnails, org logos) keep their stable, anonymous
137-
capability URL, because they are embedded in `<img src>` which cannot carry a
138-
bearer token. Their discovery is already gated by access to the owning record.
135+
The parent-record check applies to files that have a parent: attachments-scope
136+
files, and files a record's `file` / `image` field owns, which are judged against
137+
that one record (`FILE_DOWNLOAD_DENIED`, 403, when it cannot be read). A file with
138+
**neither** — an upload no record has claimed, such as an avatar or an
139+
organization logo stored as a URL — has no parent to check, so its download
140+
requires only a signed-in caller (`AUTH_REQUIRED`, 401, otherwise). A browser's
141+
`<img src>` sends the session cookie set at sign-in, so a signed-in page keeps
142+
rendering these files. Only a file marked `acl: 'public_read'` is served to a
143+
caller with no session: mark a file that way when it must render before sign-in.
139144

140145
The upload entry points (presigned / chunked) likewise require a session when
141146
an auth service is wired, and stamp `owner_id` on the new `sys_file`.
@@ -174,7 +179,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
174179
| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
175180
| List / read | inherits parent read visibility | *(filtered out)* |
176181
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |
177-
| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
182+
| Download | session + owner-or-parent-read (attachments scope); session only for a file with no parent; none for `acl: 'public_read'` | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
178183

179184
## See also
180185

0 commit comments

Comments
 (0)