Skip to content

Commit 0289d5a

Browse files
committed
feat(attachments): authenticated, parent-scoped downloads (#2970 item 2)
The storage download endpoints (GET /storage/files/:fileId and /files/:fileId/url) were anonymous capability URLs — anyone holding a fileId could mint a download with no session or access check. For scope='attachments', non-public_read files, both endpoints now gate on a new authorizeFileRead seam: 401 AUTH_REQUIRED without a session, 403 ATTACHMENT_DOWNLOAD_DENIED when the caller is neither the file owner nor able to READ a record the file is attached to (parent-derived, resolved through the full caller context via resolveAuthzContext), else a short-lived signed URL (downloadTtl, default 300s). Non-attachments files (field files, avatars, org logos — embedded in <img src> which can't carry a bearer) keep the stable anonymous capability URL; bare kernels/tests without the seam stay open (back-compat). Tests: storage-routes gate unit tests (401/403/allow/public/non-attach/ open); the dogfood matrix (e-read) pin flips from a leak to a denial — anon 401, cross-user 403, owner 200, parent-inherited 200, 302 anon 401. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
1 parent d49deb5 commit 0289d5a

6 files changed

Lines changed: 311 additions & 44 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
---
4+
5+
feat(attachments): authenticated, parent-scoped downloads for attachments files (#2970)
6+
7+
Closes item 2 of #2970. The storage download endpoints (`GET /storage/files/:fileId`
8+
and `/files/:fileId/url`) were anonymous capability URLs — anyone holding a
9+
`fileId` could mint a download without a session or any access check.
10+
11+
For `scope === 'attachments'`, non-`public_read` files, both endpoints now gate
12+
on a new `authorizeFileRead` seam: `401 AUTH_REQUIRED` without a session, `403
13+
ATTACHMENT_DOWNLOAD_DENIED` when the caller is neither the file's owner nor able
14+
to READ a record the file is attached to (parent-derived, resolved through the
15+
full caller context via `resolveAuthzContext`), and otherwise a **short-lived**
16+
signed URL (`downloadTtl`, default 300s). Non-attachments files (field files,
17+
avatars, org logos — embedded in `<img src>` which cannot carry a bearer token)
18+
keep the stable anonymous capability URL, and bare kernels/tests without the
19+
seam wired stay open (back-compat).

packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,41 @@ describe('attachments permission matrix (#2755)', () => {
284284
expect(okRows.some((r: any) => r.file_id === okFile)).toBe(true);
285285
});
286286

287-
// ── (e-read) known gap: anonymous downloads ──────────────────────────
288-
it('(e-read, KNOWN GAP pin) anonymous download of a committed file id succeeds (capability URL)', async () => {
289-
const fileId = await uploadFile(stack, memberATok);
290-
const res = await stack.api(`/storage/files/${fileId}/url`);
291-
// Anyone holding a fileId can mint a download URL without a session.
292-
// Authenticated downloads / signed-link gating is a filed follow-up.
293-
expect(res.status).toBe(200);
287+
// ── (e-read) attachment downloads inherit parent visibility (#2970 item 2) ──
288+
it('(e-read) attachments download requires auth AND read access to a parent record', async () => {
289+
// A file attached to the admin-owned, private att_secret record.
290+
const adminFile = await uploadFile(stack, adminTok);
291+
const linked = await attach(adminTok, 'att_secret', secretId, adminFile);
292+
expect(linked.status).toBeLessThan(300);
293+
294+
// Anonymous → 401 (was a 200 capability URL before #2970).
295+
const anon = await stack.api(`/storage/files/${adminFile}/url`);
296+
expect(anon.status).toBe(401);
297+
expect(((await anon.json()) as any).code).toBe('AUTH_REQUIRED');
298+
299+
// memberB is authenticated but cannot read att_secret and is not the
300+
// owner → 403.
301+
const denied = await stack.apiAs(memberBTok, 'GET', `/storage/files/${adminFile}/url`);
302+
expect(denied.status).toBe(403);
303+
expect(((await denied.json()) as any).code).toBe('ATTACHMENT_DOWNLOAD_DENIED');
304+
305+
// The owner (admin) → 200 with a signed URL.
306+
const owner = await stack.apiAs(adminTok, 'GET', `/storage/files/${adminFile}/url`);
307+
expect(owner.status).toBe(200);
308+
expect(((await owner.json()) as any).url).toBeTruthy();
309+
310+
// Parent-inherited read: a file on the PUBLIC att_case record is
311+
// downloadable by any member who can read that record — even a
312+
// non-uploader (memberB downloads memberA's attachment).
313+
const caseFile = await uploadFile(stack, memberATok);
314+
const caseLink = await attach(memberATok, 'att_case', caseAId, caseFile);
315+
expect(caseLink.status).toBeLessThan(300);
316+
const inherited = await stack.apiAs(memberBTok, 'GET', `/storage/files/${caseFile}/url`);
317+
expect(inherited.status).toBe(200);
318+
319+
// The stable 302 endpoint is gated the same way (anon → 401).
320+
const anon302 = await stack.api(`/storage/files/${adminFile}`);
321+
expect(anon302.status).toBe(401);
294322
});
295323

296324
// ── Part 1: sys_file orphan lifecycle, end to end ─────────────────────

packages/services/service-storage/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type { S3StorageAdapterOptions } from './s3-storage-adapter.js';
1010
export { StorageMetadataStore } from './metadata-store.js';
1111
export type { FileRecord, UploadSessionRecord } from './metadata-store.js';
1212
export { registerStorageRoutes } from './storage-routes.js';
13-
export type { StorageRoutesOptions } from './storage-routes.js';
13+
export type { StorageRoutesOptions, FileReadVerdict } from './storage-routes.js';
1414
export { SystemFile, SystemUploadSession } from './objects/index.js';
1515
export { installAttachmentLifecycleHooks, createSysFileReapGuard } from './attachment-lifecycle.js';
1616
export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './attachment-lifecycle.js';

packages/services/service-storage/src/storage-routes.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,89 @@ describe('Storage REST Routes', () => {
250250
});
251251
});
252252

253+
describe('attachments download gate (#2970 item 2)', () => {
254+
const commit = async (s: StorageMetadataStore, rec: Partial<import('./metadata-store').FileRecord>) =>
255+
s.createFile({
256+
id: rec.id ?? 'f-dl',
257+
key: rec.key ?? `attachments/${rec.id ?? 'f-dl'}.bin`,
258+
name: 'x.bin',
259+
status: 'committed',
260+
acl: rec.acl ?? 'private',
261+
scope: rec.scope ?? 'attachments',
262+
owner_id: rec.owner_id,
263+
...rec,
264+
} as any);
265+
266+
function serverWith(verdict: import('./storage-routes').FileReadVerdict | 'skip', extra: any = {}) {
267+
const server = createMockHttpServer();
268+
const s = new StorageMetadataStore(null);
269+
const authorizeFileRead =
270+
verdict === 'skip' ? undefined : vi.fn(async () => verdict as any);
271+
registerStorageRoutes(server as any, adapter, s, {
272+
basePath: '/api/v1/storage',
273+
authorizeFileRead,
274+
...extra,
275+
});
276+
return { server, store: s, authorizeFileRead };
277+
}
278+
279+
const hit = async (server: any, path: string, fileId: string) => {
280+
const res = createMockRes();
281+
await server._getHandler('GET', path)!(createMockReq({ params: { fileId } }), res);
282+
return res;
283+
};
284+
285+
it('401s an unauthenticated download of an attachments file (both endpoints)', async () => {
286+
const { server, store: s } = serverWith('unauthenticated');
287+
await commit(s, { id: 'a1' });
288+
for (const p of ['/api/v1/storage/files/:fileId/url', '/api/v1/storage/files/:fileId']) {
289+
const res = await hit(server, p, 'a1');
290+
expect(res._status, p).toBe(401);
291+
expect(res._json?.code, p).toBe('AUTH_REQUIRED');
292+
}
293+
});
294+
295+
it('403s when the caller cannot read any parent record', async () => {
296+
const { server, store: s } = serverWith('deny');
297+
await commit(s, { id: 'a2' });
298+
const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'a2');
299+
expect(res._status).toBe(403);
300+
expect(res._json?.code).toBe('ATTACHMENT_DOWNLOAD_DENIED');
301+
});
302+
303+
it('allows the download when authorized, minting a short-lived signed URL', async () => {
304+
const { server, store: s, authorizeFileRead } = serverWith('allow', { downloadTtl: 120 });
305+
await commit(s, { id: 'a3' });
306+
const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'a3');
307+
expect(res._status).toBe(200);
308+
expect(res._json.url).toContain('/_local/raw/');
309+
expect(authorizeFileRead).toHaveBeenCalledOnce();
310+
});
311+
312+
it('never gates a public_read attachments file (authorizer not consulted)', async () => {
313+
const { server, store: s, authorizeFileRead } = serverWith('deny');
314+
await commit(s, { id: 'a4', acl: 'public_read' });
315+
const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'a4');
316+
expect(res._status).toBe(200);
317+
expect(authorizeFileRead).not.toHaveBeenCalled();
318+
});
319+
320+
it('never gates a non-attachments file (avatars / field files stay open)', async () => {
321+
const { server, store: s, authorizeFileRead } = serverWith('deny');
322+
await commit(s, { id: 'a5', scope: 'user', key: 'user/a5.png' });
323+
const res = await hit(server, '/api/v1/storage/files/:fileId', 'a5');
324+
expect(res._status).toBe(302);
325+
expect(authorizeFileRead).not.toHaveBeenCalled();
326+
});
327+
328+
it('stays open when no authorizer is wired (back-compat)', async () => {
329+
const { server, store: s } = serverWith('skip');
330+
await commit(s, { id: 'a6' });
331+
const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'a6');
332+
expect(res._status).toBe(200);
333+
});
334+
});
335+
253336
describe('PUT/GET /_local/raw/:token', () => {
254337
it('should accept raw upload with valid token and serve download', async () => {
255338
// Generate a presigned upload

packages/services/service-storage/src/storage-routes.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import { randomUUID } from 'node:crypto';
44
import type { IHttpServer, IHttpRequest, IHttpResponse, IStorageService } from '@objectstack/spec/contracts';
5-
import type { StorageMetadataStore } from './metadata-store.js';
5+
import type { StorageMetadataStore, FileRecord } from './metadata-store.js';
66
import type { LocalStorageAdapter } from './local-storage-adapter.js';
77

8+
/** Authorization verdict for an attachments-scope download (#2970 item 2). */
9+
export type FileReadVerdict = 'allow' | 'deny' | 'unauthenticated';
10+
811
/**
912
* Options for the storage route registration helper.
1013
*/
@@ -24,6 +27,26 @@ export interface StorageRoutesOptions {
2427
* is a tracked follow-up needing cookie sessions or signed links).
2528
*/
2629
resolveSession?: (req: IHttpRequest) => Promise<{ userId?: string } | null | undefined>;
30+
/**
31+
* Authorize a DOWNLOAD of an `attachments`-scope file (#2970 item 2). When
32+
* wired, the download endpoints (`/files/:fileId` and `/files/:fileId/url`)
33+
* consult this for `scope==='attachments'`, non-`public_read` files only:
34+
* - `unauthenticated` → 401 (no session)
35+
* - `deny` → 403 (session, but cannot read a parent record the file is
36+
* attached to and is not the owner)
37+
* - `allow` → a short-lived signed URL is issued
38+
* Non-attachments files (field files, avatars, org logos) keep the stable
39+
* anonymous capability URL — they are embedded in `<img src>` which cannot
40+
* carry a bearer token, and are out of scope for the attachments leak.
41+
* When absent (bare kernels, tests), all downloads stay open (back-compat).
42+
*/
43+
authorizeFileRead?: (file: FileRecord, req: IHttpRequest) => Promise<FileReadVerdict>;
44+
/**
45+
* TTL (seconds) for the signed URL minted on a GATED attachments download.
46+
* Short by design — the link is followed immediately after an explicit
47+
* click. Default 300 (5 min). Non-gated downloads keep `presignedTtl`.
48+
*/
49+
downloadTtl?: number;
2750
/** Optional logger for the one-time open-mode notice. */
2851
logger?: { info(msg: string): void; warn(msg: string): void };
2952
}
@@ -55,6 +78,40 @@ export function registerStorageRoutes(
5578
const basePath = opts.basePath ?? '/api/v1/storage';
5679
const presignedTtl = opts.presignedTtl ?? 3600;
5780
const sessionTtl = opts.sessionTtl ?? 86400;
81+
const downloadTtl = opts.downloadTtl ?? 300;
82+
83+
// ── Download authorization gate (#2970 item 2) ───────────────────────
84+
// Only `attachments`-scope, non-public files are gated; everything else
85+
// keeps the stable anonymous capability URL (image/avatar embedding).
86+
// Returns the signed-URL TTL to use, or `false` if a response was already
87+
// sent (401/403) and the handler must stop.
88+
const authorizeDownload = async (
89+
file: FileRecord,
90+
req: IHttpRequest,
91+
res: IHttpResponse,
92+
): Promise<number | false> => {
93+
if (file.scope !== 'attachments' || file.acl === 'public_read' || !opts.authorizeFileRead) {
94+
return presignedTtl;
95+
}
96+
let verdict: FileReadVerdict;
97+
try {
98+
verdict = await opts.authorizeFileRead(file, req);
99+
} catch {
100+
verdict = 'deny'; // a failed authz check must never fall open
101+
}
102+
if (verdict === 'unauthenticated') {
103+
res.status(401).json({ error: 'Authentication required to download this file', code: 'AUTH_REQUIRED' });
104+
return false;
105+
}
106+
if (verdict === 'deny') {
107+
res.status(403).json({
108+
error: 'You do not have access to a record this file is attached to',
109+
code: 'ATTACHMENT_DOWNLOAD_DENIED',
110+
});
111+
return false;
112+
}
113+
return downloadTtl;
114+
};
58115

59116
// ── Upload auth gate (#2755) ─────────────────────────────────────────
60117
// `false` ⇒ the 401 was already sent and the handler must stop.
@@ -431,12 +488,15 @@ export function registerStorageRoutes(
431488
return;
432489
}
433490

491+
const ttl = await authorizeDownload(file, req, res);
492+
if (ttl === false) return;
493+
434494
let url: string;
435495
if (storage.getPresignedDownload) {
436-
const desc = await storage.getPresignedDownload(file.key, presignedTtl);
496+
const desc = await storage.getPresignedDownload(file.key, ttl);
437497
url = desc.downloadUrl;
438498
} else if (storage.getSignedUrl) {
439-
url = await storage.getSignedUrl(file.key, presignedTtl);
499+
url = await storage.getSignedUrl(file.key, ttl);
440500
} else {
441501
url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
442502
}
@@ -467,12 +527,15 @@ export function registerStorageRoutes(
467527
return;
468528
}
469529

530+
const ttl = await authorizeDownload(file, req, res);
531+
if (ttl === false) return;
532+
470533
let url: string;
471534
if (storage.getPresignedDownload) {
472-
const desc = await storage.getPresignedDownload(file.key, presignedTtl);
535+
const desc = await storage.getPresignedDownload(file.key, ttl);
473536
url = desc.downloadUrl;
474537
} else if (storage.getSignedUrl) {
475-
url = await storage.getSignedUrl(file.key, presignedTtl);
538+
url = await storage.getSignedUrl(file.key, ttl);
476539
} else {
477540
url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
478541
}

0 commit comments

Comments
 (0)