Skip to content

Commit e1484bc

Browse files
authored
Merge pull request #2984 from objectstack-ai/claude/attachments-v1-followups-8jhvin
feat(attachments): v2 follow-ups — read visibility, authed downloads, edit-on-parent, lifecycle, enforce-or-remove (#2970)
2 parents 99755b5 + 94b8e44 commit e1484bc

17 files changed

Lines changed: 712 additions & 57 deletions
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).
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(attachments): edit-on-parent attach, upload-session lifecycle, trash=false (#2970 items 3-5)
7+
8+
Closes the remaining enforce-or-remove / lifecycle items of #2970:
9+
10+
- **Edit-on-parent for attach (item 3, Salesforce parity).** Creating a
11+
`sys_attachment` now requires EDIT access to the parent record (via the
12+
sharing service's `canEdit`), not merely read — public-model parents are
13+
unchanged (canEdit is true for any member), private/owner-scoped parents
14+
require the caller to own/edit them. Degrades to read visibility when no
15+
sharing service is present.
16+
- **`sys_upload_session` lifecycle (item 4).** Abandoned / terminal chunked
17+
upload sessions are reaped by the platform LifecycleService (`transient`;
18+
TTL 1d past `expires_at`; retention 7d for terminal statuses). Row reap
19+
only — a reap guard that aborts backend multipart uploads for partial S3
20+
sessions is a filed follow-up.
21+
- **`sys_attachment.enable.trash``false` (item 5, ADR-0049).** The flag is
22+
`dead` in the liveness ledger (no engine soft-delete reader) and attachment
23+
deletes are hard (the reap guard reclaims a file's bytes once its last join
24+
row is gone, so a restore would dangle) — declare the honest state rather
25+
than claim a restore capability the runtime does not provide.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
---
4+
5+
feat(attachments): sys_attachment read inherits parent-record visibility (#2970)
6+
7+
Follow-up to #2755. The create/delete gates landed, but a member could still
8+
LIST `sys_attachment` rows (file_name, size, parent_id) pointing at records
9+
they cannot read — an information leak, since attachment access derives from
10+
the PARENT record (Salesforce ContentDocumentLink semantics). `sys_attachment`
11+
is a public system object with no owner field, so the sharing/RLS static
12+
predicates never narrowed it.
13+
14+
`installAttachmentReadVisibility` registers a `sys_attachment`-scoped engine
15+
**middleware** (not a find-hook) so it filters `find`, `findOne`, `count`, and
16+
`aggregate` identically — critically, the list `total` (which comes from
17+
`engine.count()`, never the find path) is filtered too, so it cannot leak the
18+
count of hidden rows. Generalizing ADR-0055 `controlled_by_parent` to the
19+
polymorphic parent, each read resolves the visible parent ids per
20+
`parent_object` through the caller-scoped engine (the parent's own RLS/OWD/
21+
sharing apply) and ANDs a `$or` of `{ parent_object, parent_id: { $in } }`
22+
into the query; no visible parent ⇒ a deny-all sentinel. Fails closed on any
23+
compute error. System / context-less internal reads are not narrowed.

examples/app-showcase/src/data/objects/project.object.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export const Project = ObjectSchema.create({
1919
icon: 'folder-kanban',
2020
description: 'A delivery project for an account.',
2121

22+
// [#2727 / #2970] Opt in to the generic Attachments panel on the record
23+
// detail page — projects carry briefs, SOWs, and deliverable files. This is
24+
// the showcase's files-enabled object for browser-dogfooding the attachments
25+
// surface (upload / list / download / delete + parent-derived access).
26+
enable: { files: true },
27+
2228
fields: {
2329
name: Field.text({ label: 'Project Name', required: true, searchable: true, maxLength: 200 }),
2430
// `relatedList*` is the read-side mirror of inline editing: the Account's

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

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,31 @@ describe('attachments permission matrix (#2755)', () => {
199199
expect(allowed.status).toBeLessThan(300);
200200
});
201201

202+
// ── (item 3) edit-on-parent: read is not enough to attach ────────────
203+
it('(item 3) a member who can READ but not EDIT the parent cannot attach — yet can still list its attachments', async () => {
204+
// att_readonly is public_read: every member reads it, only the owner edits.
205+
const ro = await ql.insert('att_readonly', { name: 'ro', owner_id: adminId }, { context: { ...SYS } });
206+
207+
// memberA can READ the record…
208+
const canRead = await stack.apiAs(memberATok, 'GET', `/data/att_readonly/${ro.id}`);
209+
expect(canRead.status).toBe(200);
210+
211+
// …but attaching requires EDIT (Salesforce parity) → 403.
212+
const file = await uploadFile(stack, memberATok);
213+
const denied = await attach(memberATok, 'att_readonly', ro.id, file);
214+
expect(denied.status).toBe(403);
215+
expect(((await denied.json()) as any).code).toBe('ATTACHMENT_PARENT_ACCESS');
216+
217+
// The owner (admin) can attach, and memberA — who can read the parent —
218+
// then sees that attachment in the list (read-visibility, item 1).
219+
const adminFile = await uploadFile(stack, adminTok);
220+
const attached = await attach(adminTok, 'att_readonly', ro.id, adminFile);
221+
expect(attached.status).toBeLessThan(300);
222+
const list = await stack.apiAs(memberATok, 'GET', '/data/sys_attachment');
223+
const rows = ((await list.json()) as any).records ?? [];
224+
expect(rows.some((r: any) => r.file_id === adminFile)).toBe(true);
225+
});
226+
202227
// ── (a) delete gate ──────────────────────────────────────────────────
203228
it('(a, DOGFOOD FINDING pin) the everyone baseline carries NO delete bit: an ungranted member cannot delete even their OWN attachment (403 PERMISSION_DENIED)', async () => {
204229
// ADR-0090 D5 / #2753: `member_default` is the anchor-bound baseline and
@@ -246,34 +271,79 @@ describe('attachments permission matrix (#2755)', () => {
246271
expect(ownDelete.status).toBeLessThan(300);
247272
});
248273

249-
// ── (c) known gap: listing does not inherit parent visibility ────────
250-
it('(c, KNOWN GAP pin) a member can LIST sys_attachment rows pointing at records they cannot read', async () => {
274+
// ── (c) attachment LIST inherits parent visibility (#2970 item 1) ────
275+
it('(c) a member CANNOT list/read sys_attachment rows whose parent record they cannot read', async () => {
251276
const adminFile = await uploadFile(stack, adminTok);
252277
const created = await attach(adminTok, 'att_secret', secretId, adminFile);
253278
expect(created.status).toBeLessThan(300);
279+
const secretRow = await ql.findOne('sys_attachment', { where: { file_id: adminFile }, context: SYS });
254280

255281
// memberB cannot read the att_secret PARENT…
256282
const parentRead = await stack.apiAs(memberBTok, 'GET', `/data/att_secret/${secretId}`);
257283
expect([403, 404]).toContain(parentRead.status);
258284

259-
// …but CAN see the join row (file name, size, parent id) through the
260-
// generic list path. Attachment read visibility does not yet inherit
261-
// parent-record visibility — enforce-or-remove follow-up filed with
262-
// #2755; flip this pin to a denial assertion when inheritance lands.
285+
// …and now the join row is filtered out of the generic list too (the
286+
// read-visibility middleware inherits the parent's visibility, and the
287+
// list `total` is filtered identically via count()).
263288
const list = await stack.apiAs(memberBTok, 'GET', '/data/sys_attachment');
264289
expect(list.status).toBe(200);
265-
const rows = ((await list.json()) as any).records ?? [];
266-
const leaked = rows.some((r: any) => r.parent_object === 'att_secret' && r.parent_id === secretId);
267-
expect(leaked, 'KNOWN GAP: join rows of invisible parents are listable').toBe(true);
290+
const body = (await list.json()) as any;
291+
const rows = body.records ?? [];
292+
expect(
293+
rows.some((r: any) => r.id === secretRow.id),
294+
'attachment of an invisible parent must not be listable',
295+
).toBe(false);
296+
// total must not leak the hidden row's existence either.
297+
expect(rows.every((r: any) => r.parent_object !== 'att_secret' || r.parent_id !== secretId)).toBe(true);
298+
299+
// A by-id read of the hidden attachment is a 404/403, not a leak.
300+
const byId = await stack.apiAs(memberBTok, 'GET', `/data/sys_attachment/${secretRow.id}`);
301+
expect([403, 404]).toContain(byId.status);
302+
303+
// Control: memberB CAN still see attachments on a record they can read.
304+
const okFile = await uploadFile(stack, memberBTok);
305+
const okAttach = await attach(memberBTok, 'att_case', caseAId, okFile);
306+
expect(okAttach.status).toBeLessThan(300);
307+
const okList = await stack.apiAs(memberBTok, 'GET', '/data/sys_attachment');
308+
const okRows = ((await okList.json()) as any).records ?? [];
309+
expect(okRows.some((r: any) => r.file_id === okFile)).toBe(true);
268310
});
269311

270-
// ── (e-read) known gap: anonymous downloads ──────────────────────────
271-
it('(e-read, KNOWN GAP pin) anonymous download of a committed file id succeeds (capability URL)', async () => {
272-
const fileId = await uploadFile(stack, memberATok);
273-
const res = await stack.api(`/storage/files/${fileId}/url`);
274-
// Anyone holding a fileId can mint a download URL without a session.
275-
// Authenticated downloads / signed-link gating is a filed follow-up.
276-
expect(res.status).toBe(200);
312+
// ── (e-read) attachment downloads inherit parent visibility (#2970 item 2) ──
313+
it('(e-read) attachments download requires auth AND read access to a parent record', async () => {
314+
// A file attached to the admin-owned, private att_secret record.
315+
const adminFile = await uploadFile(stack, adminTok);
316+
const linked = await attach(adminTok, 'att_secret', secretId, adminFile);
317+
expect(linked.status).toBeLessThan(300);
318+
319+
// Anonymous → 401 (was a 200 capability URL before #2970).
320+
const anon = await stack.api(`/storage/files/${adminFile}/url`);
321+
expect(anon.status).toBe(401);
322+
expect(((await anon.json()) as any).code).toBe('AUTH_REQUIRED');
323+
324+
// memberB is authenticated but cannot read att_secret and is not the
325+
// owner → 403.
326+
const denied = await stack.apiAs(memberBTok, 'GET', `/storage/files/${adminFile}/url`);
327+
expect(denied.status).toBe(403);
328+
expect(((await denied.json()) as any).code).toBe('ATTACHMENT_DOWNLOAD_DENIED');
329+
330+
// The owner (admin) → 200 with a signed URL.
331+
const owner = await stack.apiAs(adminTok, 'GET', `/storage/files/${adminFile}/url`);
332+
expect(owner.status).toBe(200);
333+
expect(((await owner.json()) as any).url).toBeTruthy();
334+
335+
// Parent-inherited read: a file on the PUBLIC att_case record is
336+
// downloadable by any member who can read that record — even a
337+
// non-uploader (memberB downloads memberA's attachment).
338+
const caseFile = await uploadFile(stack, memberATok);
339+
const caseLink = await attach(memberATok, 'att_case', caseAId, caseFile);
340+
expect(caseLink.status).toBeLessThan(300);
341+
const inherited = await stack.apiAs(memberBTok, 'GET', `/storage/files/${caseFile}/url`);
342+
expect(inherited.status).toBe(200);
343+
344+
// The stable 302 endpoint is gated the same way (anon → 401).
345+
const anon302 = await stack.api(`/storage/files/${adminFile}`);
346+
expect(anon302.status).toBe(401);
277347
});
278348

279349
// ── Part 1: sys_file orphan lifecycle, end to end ─────────────────────
@@ -405,6 +475,31 @@ describe('attachments permission matrix (#2755)', () => {
405475
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
406476
expect(await ql.findOne('sys_file', { where: { id: data.fileId }, context: SYS })).toBeNull();
407477
});
478+
479+
it('(item 4) abandoned sys_upload_session rows are reaped past their expiry window', async () => {
480+
// Initiate a chunked upload (creates a sys_upload_session) but never
481+
// complete it.
482+
const init = await stack.api('/storage/upload/chunked', {
483+
method: 'POST',
484+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${memberATok}` },
485+
body: JSON.stringify({ filename: 'big.bin', mimeType: 'application/octet-stream', totalSize: 10_485_760 }),
486+
});
487+
expect(init.status).toBe(200);
488+
const { uploadId } = ((await init.json()) as any).data;
489+
const session = await ql.findOne('sys_upload_session', { where: { id: uploadId }, context: SYS });
490+
expect(session?.id, 'session row created').toBeTruthy();
491+
492+
// Backdate expires_at past the 1d TTL grace.
493+
await ql.update(
494+
'sys_upload_session',
495+
{ id: uploadId, expires_at: new Date(Date.now() - 2 * DAY_MS) },
496+
{ context: { ...SYS } },
497+
);
498+
499+
const report = await lifecycle.sweep();
500+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
501+
expect(await ql.findOne('sys_upload_session', { where: { id: uploadId }, context: SYS })).toBeNull();
502+
});
408503
});
409504
});
410505

packages/dogfood/test/fixtures/attachments-fixture.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ export const AttNoFiles = ObjectSchema.create({
6060
},
6161
});
6262

63+
export const AttReadonly = ObjectSchema.create({
64+
name: 'att_readonly',
65+
label: 'Attachment Readonly',
66+
pluralLabel: 'Attachment Readonlys',
67+
// public_read: every member can READ, only the owner can EDIT — the case
68+
// that distinguishes edit-on-parent (#2970 item 3) from read visibility.
69+
sharingModel: 'public_read',
70+
enable: { files: true },
71+
fields: {
72+
name: Field.text({ label: 'Name', required: true }),
73+
owner_id: Field.text({ label: 'Owner' }),
74+
},
75+
});
76+
6377
/**
6478
* The domain grant a real app ships when it turns the attachments panel on
6579
* for members: `member_default` (the `everyone` anchor baseline) carries NO
@@ -93,5 +107,5 @@ export const attachmentsFixtureStack = defineStack({
93107
description:
94108
'Three-object app exercising the #2755 attachment permission matrix: parent visibility, uploader/editor delete, enable.files gate.',
95109
},
96-
objects: [AttCase, AttSecret, AttNoFiles],
110+
objects: [AttCase, AttSecret, AttNoFiles, AttReadonly],
97111
});

packages/platform-objects/src/audit/sys-attachment.object.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,12 @@ export const SysAttachment = ObjectSchema.create({
132132
trackHistory: false,
133133
searchable: true,
134134
apiEnabled: true,
135-
trash: true,
135+
// [#2970 item 5 / ADR-0049] `trash` is `dead` in the liveness ledger (no
136+
// engine soft-delete reader) and attachment deletes ARE hard (#2755): the
137+
// reap guard reclaims a file's bytes once its last join row is gone, so a
138+
// "restore" would dangle. Declare `false` — the honest state — rather than
139+
// claim a restore capability the runtime does not provide.
140+
trash: false,
136141
mru: false,
137142
clone: false,
138143
},

packages/services/service-storage/src/attachment-access-hooks.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,31 @@ describe('attachment access — beforeInsert (parent visibility + provenance)',
9999
).resolves.toBeUndefined();
100100
await expect(beforeInsert(insertCtx({ parent_object: 'x', parent_id: 'r' }, {}))).resolves.toBeUndefined();
101101
});
102+
103+
// #2970 item 3 — with a sharing service present, attach requires EDIT
104+
// (canEdit), not merely read visibility.
105+
it('with sharing: rejects attaching when the caller cannot EDIT the parent (even if readable)', async () => {
106+
const canEdit = vi.fn(async () => false);
107+
const { beforeInsert } = install({ sharing: { canEdit } });
108+
const ctx = insertCtx(
109+
{ parent_object: 'att_readonly', parent_id: 'r1', file_id: 'f1' },
110+
{ userId: 'u1', visible: ['att_readonly/r1'] }, // readable, but canEdit=false
111+
);
112+
await expect(beforeInsert(ctx)).rejects.toMatchObject({
113+
code: 'ATTACHMENT_PARENT_ACCESS',
114+
status: 403,
115+
});
116+
expect(canEdit).toHaveBeenCalledWith('att_readonly', 'r1', expect.objectContaining({ userId: 'u1' }));
117+
});
118+
119+
it('with sharing: allows attaching when the caller CAN edit the parent', async () => {
120+
const { beforeInsert } = install({ sharing: { canEdit: async () => true } });
121+
const ctx = insertCtx(
122+
{ parent_object: 'att_case', parent_id: 'r1', file_id: 'f1' },
123+
{ userId: 'u1', visible: [] }, // not readable via api, but canEdit=true governs
124+
);
125+
await expect(beforeInsert(ctx)).resolves.toBeUndefined();
126+
});
102127
});
103128

104129
describe('attachment access — beforeDelete (uploader or parent editor)', () => {
6.53 KB
Binary file not shown.

packages/services/service-storage/src/attachment-lifecycle.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,27 @@ export interface AttachmentLifecycleEngine {
3737
handler: (ctx: any) => void | Promise<void>,
3838
options?: { object?: string; packageId?: string },
3939
): void;
40+
/** Onion-model data middleware (runs for find/findOne/count/aggregate AND
41+
* writes) — the only seam that filters `count()` (→ list `total`)
42+
* identically to `find()`. Used for polymorphic parent-visibility on reads.
43+
* Optional: only the read-visibility installer needs it. */
44+
registerMiddleware?(
45+
fn: (ctx: AttachmentReadMiddlewareCtx, next: () => Promise<void>) => Promise<void>,
46+
options?: { object?: string },
47+
): void;
4048
find(object: string, options: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
4149
findOne(object: string, options: Record<string, unknown>): Promise<Record<string, unknown> | null>;
4250
update(object: string, data: Record<string, unknown>, options: Record<string, unknown>): Promise<unknown>;
4351
}
4452

53+
/** Minimal shape of the engine `OperationContext` the read middleware reads. */
54+
export interface AttachmentReadMiddlewareCtx {
55+
object: string;
56+
operation: 'find' | 'findOne' | 'insert' | 'update' | 'delete' | 'count' | 'aggregate';
57+
ast?: { object?: string; where?: unknown } & Record<string, unknown>;
58+
context?: { userId?: string; tenantId?: string; positions?: string[]; permissions?: string[]; isSystem?: boolean } & Record<string, unknown>;
59+
}
60+
4561
export interface AttachmentLifecycleLogger {
4662
info(msg: string, meta?: unknown): void;
4763
warn(msg: string, meta?: unknown): void;

0 commit comments

Comments
 (0)