diff --git a/.changeset/22431-unclaimed-download-signed-in.md b/.changeset/22431-unclaimed-download-signed-in.md
new file mode 100644
index 00000000000..b29ac6dc16d
--- /dev/null
+++ b/.changeset/22431-unclaimed-download-signed-in.md
@@ -0,0 +1,18 @@
+---
+'@objectstack/service-storage': minor
+---
+
+fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller
+
+Clause-②: no (narrowing)
+
+
+
+**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
+
+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.
+
+- **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.
+- **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `
` and `` 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.
+
+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.
diff --git a/content/docs/permissions/attachments-access.mdx b/content/docs/permissions/attachments-access.mdx
index 770d9119cb0..d65734bdbd2 100644
--- a/content/docs/permissions/attachments-access.mdx
+++ b/content/docs/permissions/attachments-access.mdx
@@ -111,7 +111,7 @@ record, and issue a **short-lived signed URL**:
| Code | Status | When |
| --- | --- | --- |
-| `AUTH_REQUIRED` | 401 | Anonymous download of an attachments-scope file, or a credential the organization wall refuses (see below) |
+| `AUTH_REQUIRED` | 401 | Anonymous download of any file not marked `acl: 'public_read'` (see below), or a credential the organization wall refuses |
| `ATTACHMENT_DOWNLOAD_DENIED` | 403 | The caller is neither the file's owner nor able to read any record it is attached to |
**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
all, and the reason is written to the server log instead. The check itself lives
in the shared API-key admission path, not in the attachments gate.
-The gate is scoped to attachments files on purpose: **non-attachments files**
-(avatars, `Field.image` thumbnails, org logos) keep their stable, anonymous
-capability URL, because they are embedded in `
` which cannot carry a
-bearer token. Their discovery is already gated by access to the owning record.
+The parent-record check applies to files that have a parent: attachments-scope
+files, and files a record's `file` / `image` field owns, which are judged against
+that one record (`FILE_DOWNLOAD_DENIED`, 403, when it cannot be read). A file with
+**neither** — an upload no record has claimed, such as an avatar or an
+organization logo stored as a URL — has no parent to check, so its download
+requires only a signed-in caller (`AUTH_REQUIRED`, 401, otherwise). A browser's
+`
` sends the session cookie set at sign-in, so a signed-in page keeps
+rendering these files. Only a file marked `acl: 'public_read'` is served to a
+caller with no session: mark a file that way when it must render before sign-in.
The upload entry points (presigned / chunked) likewise require a session when
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
| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
| List / read | inherits parent read visibility | *(filtered out)* |
| 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 |
-| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
+| 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) |
## See also
diff --git a/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts b/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts
new file mode 100644
index 00000000000..e4f947c8049
--- /dev/null
+++ b/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts
@@ -0,0 +1,180 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// [#22431] A file with neither an attachments scope nor a field owner — an
+// upload no record has claimed, which is how an avatar or an organization logo
+// stored as a URL lives — needs a signed-in caller at both download doors,
+// over a REAL showcase boot. `acl: 'public_read'` stays the one anonymous
+// download (ADR-0104).
+//
+// The package suite (`service-storage/src/storage-routes.test.ts`) pins the
+// gate over a hand-wired resolver. This file is where the composed one runs:
+// the plugin's own `kernel:ready` mount binds the kernel's `auth` service as
+// the resolver, so the answer a caller gets here is the deployment's answer.
+//
+// The half that decides whether the change is safe to ship is the COOKIE case.
+// A browser renders these files through `
` / ``, which can
+// carry no bearer header — only the session cookie the sign-in set. So the
+// signed-in reader is asserted twice: once with the bearer a script sends,
+// once with nothing but that cookie, and both must reach the bytes.
+//
+// Not eligible for the shared showcase project: it boots its own plugins.
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { mkdtempSync, promises as fs } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import showcaseStack from '@objectstack/example-showcase';
+import { bootStack, type VerifyStack } from '@objectstack/verify';
+import { StorageServicePlugin } from '@objectstack/service-storage';
+import { showcaseAppDefaultSecurity } from './showcase-security.js';
+
+const SYS = { isSystem: true } as const;
+const BYTES = 'unclaimed';
+
+/** Strip the origin off an absolute adapter URL so it can be re-injected. */
+const toPath = (url: string): string => url.replace(/^https?:\/\/[^/]+/, '');
+
+describe('[#22431] a download of a file with no attachments scope and no field owner needs a signed-in caller', () => {
+ let stack: VerifyStack;
+ let rootDir: string;
+ let ql: any;
+ let token: string;
+ let cookie: string;
+ /** Uploaded with no scope named — the shape the console's upload adapter sends. */
+ let unclaimed: string;
+ let attached: string;
+
+ const bearer = () => ({ Authorization: `Bearer ${token}` });
+
+ /** The real three-step presigned upload; `scope` omitted unless named. */
+ const upload = async (name: string, scope?: string): Promise => {
+ const presign = await stack.api('/storage/upload/presigned', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...bearer() },
+ body: JSON.stringify({ filename: name, mimeType: 'text/plain', size: BYTES.length, ...(scope ? { scope } : {}) }),
+ });
+ expect(presign.status, 'presign').toBe(200);
+ const { data } = (await presign.json()) as any;
+ const put = await stack.raw(toPath(String(data.uploadUrl)), {
+ method: 'PUT',
+ headers: data.headers ?? { 'content-type': 'text/plain' },
+ body: BYTES,
+ });
+ expect(put.status, 'raw PUT').toBeLessThan(300);
+ const complete = await stack.api('/storage/upload/complete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...bearer() },
+ body: JSON.stringify({ fileId: data.fileId }),
+ });
+ expect(complete.status, 'complete').toBe(200);
+ return String(data.fileId);
+ };
+
+ /** Both doors for one caller: the JSON door and the redirect door. */
+ const doors = async (fileId: string, headers: Record = {}) => ({
+ url: await stack.api(`/storage/files/${fileId}/url`, { headers }),
+ redirect: await stack.api(`/storage/files/${fileId}`, { headers, redirect: 'manual' } as RequestInit),
+ });
+
+ const expectRefused = async (res: Response, label: string) => {
+ expect(res.status, label).toBe(401);
+ const body = (await res.json()) as any;
+ expect(body.success, label).toBe(false);
+ expect(body.error?.code, label).toBe('AUTH_REQUIRED');
+ };
+
+ /** A 302 whose target, followed with NO credential, serves the uploaded bytes. */
+ const expectBytesBehindRedirect = async (res: Response, label: string) => {
+ expect(res.status, label).toBe(302);
+ const location = res.headers.get('location');
+ expect(location, `${label}: a 302 with no Location`).toBeTruthy();
+ const bytes = await stack.raw(toPath(String(location)));
+ expect(bytes.status, label).toBe(200);
+ expect(await bytes.text(), label).toBe(BYTES);
+ };
+
+ beforeAll(async () => {
+ rootDir = mkdtempSync(join(tmpdir(), 'unclaimed-download-'));
+ stack = await bootStack(showcaseStack, {
+ security: showcaseAppDefaultSecurity(),
+ extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })],
+ });
+ ql = await stack.kernel.getServiceAsync('objectql');
+ token = await stack.signIn();
+
+ // The browser transport: the session cookie the sign-in response sets.
+ const signIn = await stack.api('/auth/sign-in/email', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
+ });
+ expect(signIn.status).toBe(200);
+ cookie = signIn.headers
+ .getSetCookie()
+ .map((c) => c.split(';')[0])
+ .filter((pair) => pair.includes('session_token='))
+ .join('; ');
+ expect(cookie, 'the sign-in sets a session cookie').toContain('session_token=');
+
+ unclaimed = await upload('unclaimed.txt');
+ attached = await upload('attached.txt', 'attachments');
+ }, 120_000);
+
+ afterAll(async () => {
+ await stack?.stop();
+ if (rootDir) await fs.rm(rootDir, { recursive: true, force: true });
+ });
+
+ it('the file under test is unclaimed: no attachments scope, no field owner, not public_read', async () => {
+ const row = await ql.findOne('sys_file', { where: { id: unclaimed }, context: SYS });
+ expect(row?.scope).not.toBe('attachments');
+ expect(row?.ref_object ?? null).toBeNull();
+ expect(row?.acl ?? 'private').not.toBe('public_read');
+ });
+
+ it('an anonymous caller is refused 401 AUTH_REQUIRED at both download doors', async () => {
+ const { url, redirect } = await doors(unclaimed);
+ await expectRefused(url, 'the URL door');
+ await expectRefused(redirect, 'the redirect door');
+ expect(redirect.headers.get('location'), 'no capability URL leaks on the refusal').toBeNull();
+ });
+
+ it('a signed-in caller with a bearer token is served as before', async () => {
+ const { url, redirect } = await doors(unclaimed, bearer());
+ expect(url.status).toBe(200);
+ const body = (await url.json()) as any;
+ const bytes = await stack.raw(toPath(String(body.data.url)));
+ expect(await bytes.text()).toBe(BYTES);
+ await expectBytesBehindRedirect(redirect, 'bearer, redirect door');
+ });
+
+ it('a signed-in browser is served through its session cookie alone — what
carries', async () => {
+ const { url, redirect } = await doors(unclaimed, { cookie });
+ expect(url.status, 'the URL door, cookie only').toBe(200);
+ await expectBytesBehindRedirect(redirect, 'cookie only, redirect door');
+ });
+
+ it("acl: 'public_read' keeps the file anonymous, and only that declaration does", async () => {
+ await ql.update('sys_file', { acl: 'public_read' }, { where: { id: unclaimed }, context: SYS });
+ try {
+ const { url, redirect } = await doors(unclaimed);
+ expect(url.status, 'public_read, anonymous URL door').toBe(200);
+ await expectBytesBehindRedirect(redirect, 'public_read, anonymous redirect door');
+ } finally {
+ await ql.update('sys_file', { acl: 'private' }, { where: { id: unclaimed }, context: SYS });
+ }
+ await expectRefused((await doors(unclaimed)).redirect, 'back to private');
+ });
+
+ it('controls: an anonymous upload and an anonymous attachments-scope download stay refused', async () => {
+ const presign = await stack.api('/storage/upload/presigned', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filename: 'anon.txt', mimeType: 'text/plain', size: 1 }),
+ });
+ await expectRefused(presign, 'anonymous upload');
+ const { url, redirect } = await doors(attached);
+ await expectRefused(url, 'attachments-scope, URL door');
+ await expectRefused(redirect, 'attachments-scope, redirect door');
+ });
+});
diff --git a/packages/services/service-storage/src/error-envelope.conformance.test.ts b/packages/services/service-storage/src/error-envelope.conformance.test.ts
index 6817337e344..44f4f38f327 100644
--- a/packages/services/service-storage/src/error-envelope.conformance.test.ts
+++ b/packages/services/service-storage/src/error-envelope.conformance.test.ts
@@ -314,6 +314,19 @@ describe('storage error envelope (#3675)', () => {
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'a2' } });
},
},
+ {
+ // #22431: a file with neither an attachments scope nor a field owner
+ // needs a signed-in caller — the same pair as the two 401s above.
+ name: 'anonymous download of a file with neither an attachments scope nor a field owner',
+ status: 401,
+ code: 'AUTH_REQUIRED',
+ run: async () => {
+ const store = new StorageMetadataStore(null);
+ await committedAttachment(store, 'u1', { scope: 'user', key: 'user/u1.png' });
+ const routes = mount(await tmpAdapter(), store, { resolveSession: async () => null });
+ return drive(routes, 'GET', `${BASE}/files/:fileId`, { params: { fileId: 'u1' } });
+ },
+ },
{
name: 'raw upload against an adapter with no token support',
status: 501,
diff --git a/packages/services/service-storage/src/storage-routes.test.ts b/packages/services/service-storage/src/storage-routes.test.ts
index e413ad83fe2..f2e844ec8ed 100644
--- a/packages/services/service-storage/src/storage-routes.test.ts
+++ b/packages/services/service-storage/src/storage-routes.test.ts
@@ -563,6 +563,155 @@ describe('Storage REST Routes', () => {
});
});
+ // ── [#22431] The unclaimed class: neither an attachments scope nor a field
+ // owner. It has no parent record to derive access from, so the download
+ // asks for one thing only — a signed-in caller — unless the file declares
+ // `acl: 'public_read'` (ADR-0104's one anonymous download).
+ describe('a file with neither an attachments scope nor a field owner needs a signed-in caller (#22431)', () => {
+ const DOORS = ['/api/v1/storage/files/:fileId/url', '/api/v1/storage/files/:fileId'] as const;
+
+ const commit = async (s: StorageMetadataStore, rec: Partial) =>
+ s.createFile({
+ id: rec.id ?? 'u-dl',
+ key: rec.key ?? `user/${rec.id ?? 'u-dl'}.png`,
+ name: 'x.png',
+ status: 'committed',
+ acl: 'private',
+ scope: 'user',
+ ...rec,
+ } as any);
+
+ function serverWith(resolveSession: any, extra: any = {}) {
+ const server = createMockHttpServer();
+ const s = new StorageMetadataStore(null);
+ const authorizeFileRead = vi.fn(async () => 'deny' as const);
+ const logger = { info: vi.fn(), warn: vi.fn() };
+ registerStorageRoutes(server as any, adapter, s, {
+ basePath: '/api/v1/storage',
+ resolveSession,
+ authorizeFileRead,
+ logger,
+ downloadTtl: 120,
+ ...extra,
+ });
+ return { server, store: s, authorizeFileRead, logger };
+ }
+
+ const hit = async (server: any, path: string, fileId: string) => {
+ const res = createMockRes();
+ await server._getHandler('GET', path)!(createMockReq({ params: { fileId } }), res);
+ return res;
+ };
+
+ /** Seconds of life the minted local capability URL carries. */
+ const ttlOf = (url: string) =>
+ adapter.verifyToken(url.split('/').pop()!, 'get').exp - Math.floor(Date.now() / 1000);
+
+ it('refuses an anonymous caller 401 AUTH_REQUIRED at both download doors, minting no URL', async () => {
+ const resolveSession = vi.fn(async () => null);
+ const { server, store: s, authorizeFileRead } = serverWith(resolveSession);
+ await commit(s, { id: 'u1' });
+ const minted = vi.spyOn(adapter, 'getPresignedDownload');
+ for (const door of DOORS) {
+ const res = await hit(server, door, 'u1');
+ expect(res._status, door).toBe(401);
+ expect(res._json?.success, door).toBe(false);
+ expect(res._json?.error?.code, door).toBe('AUTH_REQUIRED');
+ expect(res._headers.Location, door).toBeUndefined();
+ }
+ expect(minted).not.toHaveBeenCalled();
+ expect(resolveSession).toHaveBeenCalledTimes(2);
+ // Not the parent-governed authorizer's file: there is no parent record.
+ expect(authorizeFileRead).not.toHaveBeenCalled();
+ minted.mockRestore();
+ });
+
+ it('answers the same code and status the upload doors give an anonymous caller', async () => {
+ const { server, store: s } = serverWith(async () => null);
+ await commit(s, { id: 'u2' });
+ const download = await hit(server, DOORS[0], 'u2');
+ const upload = createMockRes();
+ await server._getHandler('POST', '/api/v1/storage/upload/presigned')!(
+ createMockReq({ body: { filename: 'a.png', mimeType: 'image/png', size: 3 } }),
+ upload,
+ );
+ expect([download._status, download._json?.error?.code]).toEqual([upload._status, upload._json?.error?.code]);
+ });
+
+ it('fails closed when the session resolver throws, or resolves a session with no user', async () => {
+ for (const resolveSession of [
+ async () => { throw new Error('auth backend down'); },
+ async () => ({ organizationId: 'org-1' }),
+ ]) {
+ const { server, store: s } = serverWith(resolveSession);
+ await commit(s, { id: 'u3' });
+ for (const door of DOORS) {
+ const res = await hit(server, door, 'u3');
+ expect(res._status, door).toBe(401);
+ expect(res._json?.error?.code, door).toBe('AUTH_REQUIRED');
+ }
+ }
+ });
+
+ it('serves a signed-in caller as before: a 302 and a URL carrying the presigned TTL', async () => {
+ const { server, store: s, authorizeFileRead } = serverWith(async () => ({ userId: 'user-7' }), { presignedTtl: 1800 });
+ await commit(s, { id: 'u4' });
+ const url = await hit(server, DOORS[0], 'u4');
+ expect(url._status).toBe(200);
+ expect(url._json.data.url).toContain('/_local/raw/');
+ const ttl = ttlOf(url._json.data.url);
+ expect(ttl).toBeGreaterThan(1700);
+ expect(ttl).toBeLessThanOrEqual(1800);
+ const redirect = await hit(server, DOORS[1], 'u4');
+ expect(redirect._status).toBe(302);
+ expect(redirect._headers.Location).toContain('/_local/raw/');
+ expect(authorizeFileRead).not.toHaveBeenCalled();
+ });
+
+ it("keeps an acl: 'public_read' file anonymous without asking for a session", async () => {
+ const resolveSession = vi.fn(async () => null);
+ const { server, store: s } = serverWith(resolveSession);
+ await commit(s, { id: 'u5', acl: 'public_read' });
+ expect((await hit(server, DOORS[0], 'u5'))._status).toBe(200);
+ expect((await hit(server, DOORS[1], 'u5'))._status).toBe(302);
+ expect(resolveSession).not.toHaveBeenCalled();
+ });
+
+ it('leaves the parent-governed classes to the authorizer: attachments-scope and field-owned verdicts are unchanged', async () => {
+ const resolveSession = vi.fn(async () => ({ userId: 'user-7' }));
+ const { server, store: s, authorizeFileRead } = serverWith(resolveSession);
+ await commit(s, { id: 'g1', scope: 'attachments', key: 'attachments/g1.bin' });
+ await commit(s, { id: 'g2', ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
+ const attached = await hit(server, DOORS[0], 'g1');
+ expect(attached._status).toBe(403);
+ expect(attached._json?.error?.code).toBe('ATTACHMENT_DOWNLOAD_DENIED');
+ const owned = await hit(server, DOORS[0], 'g2');
+ expect(owned._status).toBe(403);
+ expect(owned._json?.error?.code).toBe('FILE_DOWNLOAD_DENIED');
+ expect(authorizeFileRead).toHaveBeenCalledTimes(2);
+ expect(resolveSession).not.toHaveBeenCalled();
+ });
+
+ it('answers a missing file 404 before the session is asked', async () => {
+ const resolveSession = vi.fn(async () => null);
+ const { server } = serverWith(resolveSession);
+ const res = await hit(server, DOORS[0], 'missing');
+ expect(res._status).toBe(404);
+ expect(res._json?.error?.code).toBe('FILE_NOT_FOUND');
+ expect(resolveSession).not.toHaveBeenCalled();
+ });
+
+ it('keeps a kernel with no session resolver open, and says so once', async () => {
+ const { server, store: s, logger } = serverWith(undefined);
+ await commit(s, { id: 'u6' });
+ expect((await hit(server, DOORS[1], 'u6'))._status).toBe(302);
+ expect((await hit(server, DOORS[0], 'u6'))._status).toBe(200);
+ const notices = logger.info.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('no session resolver wired'));
+ expect(notices).toHaveLength(1);
+ expect(notices[0]).toContain('download');
+ });
+ });
+
describe('PUT/GET /_local/raw/:token', () => {
it('should accept raw upload with valid token and serve download', async () => {
// Generate a presigned upload
@@ -644,14 +793,16 @@ describe('Storage REST Routes', () => {
expect(file?.owner_id).toBe('user-42');
});
- it('download routes stay open even with a resolver wired (capability URLs)', async () => {
+ it('download routes are not behind the upload gate: an anonymous read of a missing file is a 404', async () => {
const server = registerWithResolver(async () => null);
const res = createMockRes();
await server._getHandler('GET', '/api/v1/storage/files/:fileId/url')!(
createMockReq({ params: { fileId: 'missing' } }),
res,
);
- expect(res._status).toBe(404); // not 401 — anonymous reads reach the handler
+ // Not 401: the download doors look the file up first and judge the
+ // caller per file class (#22431 — see the unclaimed-class block above).
+ expect(res._status).toBe(404);
});
it('stays open (back-compat) when no resolver is wired', async () => {
diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts
index 5b51e31c872..f73222e1b85 100644
--- a/packages/services/service-storage/src/storage-routes.ts
+++ b/packages/services/service-storage/src/storage-routes.ts
@@ -221,9 +221,19 @@ export interface StorageRoutesOptions {
* progress doors also refuse a caller who is not the file's uploader
* (`isFileUploader`, `403 PERMISSION_DENIED`). When absent (bare kernels,
* tests), the routes stay open — back-compat, logged once — and there is no
- * caller identity for the ownership rule to compare. Download routes are NOT
- * gated here (capability URLs embedded in
/; gating them
- * is a tracked follow-up needing cookie sessions or signed links).
+ * caller identity for the ownership rule to compare.
+ *
+ * [#22431] The two DOWNLOAD routes ask it too, for one file class: a file
+ * with neither an attachments scope nor a field owner (an upload no record
+ * has claimed — an avatar or an organization logo stored as a URL, a pick
+ * not yet saved, a released file). Such a file has no parent record to
+ * derive access from, so the one thing its download requires is a
+ * signed-in caller; none ⇒ `401 AUTH_REQUIRED`, the answer the upload
+ * routes give. A file marked `acl: 'public_read'` stays anonymous
+ * (ADR-0104). A browser's `
` / `` reaches this resolver
+ * with the session cookie its sign-in set, which reads the same as a bearer
+ * header, so a signed-in page keeps rendering these files. Absent: these
+ * downloads stay open as before — logged once.
*/
resolveSession?: (req: IHttpRequest) => Promise;
/**
@@ -241,11 +251,14 @@ export interface StorageRoutesOptions {
* `SERVICE_UNAVAILABLE` rather than flattened into the `deny` 403 — the
* store was unreadable, so no verdict was ever reached. Every other throw
* still fails closed to `deny`.
- * A file with neither an attachments scope nor a field owner — an unclaimed
- * upload, an org logo — keeps the stable anonymous capability URL, as does
- * any file explicitly marked `acl: 'public_read'` (the opt-in for genuinely
- * public embedding, since `
` cannot carry a bearer token).
- * When absent (bare kernels, tests), all downloads stay open (back-compat).
+ * A file with neither an attachments scope nor a field owner is not this
+ * authorizer's to judge — it has no parent record — and needs only a
+ * signed-in caller ({@link StorageRoutesOptions.resolveSession}, #22431).
+ * Any file explicitly marked `acl: 'public_read'` keeps the stable
+ * anonymous capability URL (ADR-0104: the opt-in for genuinely public
+ * embedding, which no sign-in precedes).
+ * When absent (bare kernels, tests), parent-governed downloads stay open
+ * (back-compat).
*/
authorizeFileRead?: (file: FileRecord, req: IHttpRequest) => Promise;
/**
@@ -338,19 +351,59 @@ export function registerStorageRoutes(
return false;
};
+ // ── Download session gate (#22431) ───────────────────────────────────
+ // The one refusal both download doors give a caller with no session —
+ // for a file of the unclaimed class below, and for a parent-governed file
+ // whose authorizer answers `unauthenticated`. `401` / `AUTH_REQUIRED`, the
+ // pair the upload gate answers too, so a client branches on one answer.
+ const refuseAnonymousDownload = (res: IHttpResponse): false => {
+ sendError(res, 401, 'AUTH_REQUIRED', 'Authentication required to download this file');
+ return false;
+ };
+
+ // `true` ⇒ the caller is signed in, or no resolver is wired (bare kernels,
+ // tests: open, as before — said once, here, because the upload gate's own
+ // notice names only the upload routes). `false` ⇒ the 401 was already sent.
+ // A resolver that throws fails closed, exactly as the upload gate does.
+ let warnedOpenDownloads = false;
+ const requireDownloadSession = async (req: IHttpRequest, res: IHttpResponse): Promise => {
+ if (!opts.resolveSession) {
+ if (!warnedOpenDownloads) {
+ warnedOpenDownloads = true;
+ opts.logger?.info(
+ '[storage] no session resolver wired — a file with neither an attachments scope nor a field owner ' +
+ 'downloads without a signed-in caller (bare-kernel mode)',
+ );
+ }
+ return true;
+ }
+ let session: StorageUploadSession | null | undefined;
+ try {
+ session = await opts.resolveSession(req);
+ } catch {
+ session = null;
+ }
+ return session?.userId ? true : refuseAnonymousDownload(res);
+ };
+
// ── Download authorization gate (#2970 item 2, ADR-0104 D3 wave 2) ───
// Two kinds of file are gated, both deriving access from a PARENT record:
// - `attachments`-scope files, via their sys_attachment join rows;
// - field-owned files, via the single record whose field owns them
// (`ref_object`/`ref_id`, ADR-0104 D3 wave 2).
- // `acl: 'public_read'` opts a file back out to the stable anonymous
- // capability URL — needed for genuinely public embedding (`
`
- // cannot carry a bearer token), and now an explicit declaration rather
- // than the silent default it used to be for every field file.
+ // [#22431] Every OTHER file — neither an attachments scope nor a field
+ // owner: an upload no record has claimed — has no parent record to derive
+ // access from, and needs a signed-in caller instead
+ // (`requireDownloadSession` below). It used to be the one class still
+ // served as an anonymous capability URL by default.
+ // `acl: 'public_read'` opts any file back out to the stable anonymous
+ // capability URL — the explicit declaration for genuinely public
+ // embedding, which no sign-in precedes (ADR-0104). A signed-in browser
+ // needs no such opt-out: its `
` carries the session cookie.
//
// Dual-mode safe: a legacy field holds an inline blob or an external URL,
// never a `sys_file` id, so no legacy file has `ref_object` set and none of
- // them start being gated by this change.
+ // them start being gated by the parent-derived arm.
//
// Returns the signed-URL TTL to use, or `false` if a response was already
// sent (401/403, and since #15999 the `503 SERVICE_UNAVAILABLE` an
@@ -360,9 +413,14 @@ export function registerStorageRoutes(
req: IHttpRequest,
res: IHttpResponse,
): Promise => {
+ if (file.acl === 'public_read') return currentLimits().presignedTtl;
const fieldOwned = !!file.ref_object && file.ref_id != null && file.ref_id !== '';
const gated = file.scope === 'attachments' || fieldOwned;
- if (!gated || file.acl === 'public_read' || !opts.authorizeFileRead) {
+ if (!gated) {
+ if (!(await requireDownloadSession(req, res))) return false;
+ return currentLimits().presignedTtl;
+ }
+ if (!opts.authorizeFileRead) {
return currentLimits().presignedTtl;
}
let verdict: FileReadVerdict;
@@ -399,10 +457,7 @@ export function registerStorageRoutes(
}
verdict = 'deny'; // a failed authz check must never fall open
}
- if (verdict === 'unauthenticated') {
- sendError(res, 401, 'AUTH_REQUIRED', 'Authentication required to download this file');
- return false;
- }
+ if (verdict === 'unauthenticated') return refuseAnonymousDownload(res);
if (verdict === 'deny') {
if (fieldOwned) {
sendError(res, 403, 'FILE_DOWNLOAD_DENIED', 'You do not have access to the record this file belongs to');
@@ -1248,7 +1303,10 @@ export function registerStorageRoutes(
// - serves the bytes directly when followed
// The `/url` endpoint above returns JSON. This sibling endpoint resolves
// to the same short-lived signed URL and 302-redirects so it can be used
- // verbatim in any browser context.
+ // verbatim in any browser context. It answers exactly as `/url` does
+ // (`authorizeDownload`): a browser following it is judged by the session
+ // cookie it carries, and only an `acl: 'public_read'` file is served to
+ // a caller with none (#22431).
// ---------------------------------------------------------------------------
httpServer.get(`${basePath}/files/:fileId`, async (req: IHttpRequest, res: IHttpResponse) => {
try {