Skip to content

Commit d49deb5

Browse files
committed
feat(attachments): sys_attachment read inherits parent-record visibility (#2970)
Follow-up to #2755. The create/delete gates landed, but a member could still LIST sys_attachment rows (file_name, size, parent_id) pointing at records they cannot read — an info leak, since attachment access derives from the parent record. sys_attachment is public with no owner field, so the sharing/RLS static predicates never narrowed it. installAttachmentReadVisibility registers a sys_attachment-scoped engine MIDDLEWARE (not a find-hook): middleware runs for find/findOne/count/ aggregate, so the list total (from engine.count(), never the find path) is filtered identically and cannot leak the hidden-row count — a before/afterFind hook would leave count() unfiltered. Generalizing ADR-0055 controlled_by_parent to the polymorphic parent, each read resolves the visible parent ids per parent_object through the caller-scoped engine (the parent's own RLS/OWD/sharing apply) and ANDs a $or of { parent_object, parent_id: { $in } } into the query; no visible parent → deny-all sentinel; fails closed on compute error; the candidate pre-scan cap logs (not silent) when it truncates. System/context-less internal reads are not narrowed. Dogfood: the (c) matrix pin flips from leak-asserted to a denial — a member cannot list/read/by-id-fetch attachments of an invisible parent, total does not leak the count, and the control proves they still see attachments on records they CAN read. 19 unit tests; full dogfood 256 ✓. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
1 parent e7d5291 commit d49deb5

8 files changed

Lines changed: 259 additions & 11 deletions
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.

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -246,25 +246,42 @@ describe('attachments permission matrix (#2755)', () => {
246246
expect(ownDelete.status).toBeLessThan(300);
247247
});
248248

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 () => {
249+
// ── (c) attachment LIST inherits parent visibility (#2970 item 1) ────
250+
it('(c) a member CANNOT list/read sys_attachment rows whose parent record they cannot read', async () => {
251251
const adminFile = await uploadFile(stack, adminTok);
252252
const created = await attach(adminTok, 'att_secret', secretId, adminFile);
253253
expect(created.status).toBeLessThan(300);
254+
const secretRow = await ql.findOne('sys_attachment', { where: { file_id: adminFile }, context: SYS });
254255

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

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.
260+
// …and now the join row is filtered out of the generic list too (the
261+
// read-visibility middleware inherits the parent's visibility, and the
262+
// list `total` is filtered identically via count()).
263263
const list = await stack.apiAs(memberBTok, 'GET', '/data/sys_attachment');
264264
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);
265+
const body = (await list.json()) as any;
266+
const rows = body.records ?? [];
267+
expect(
268+
rows.some((r: any) => r.id === secretRow.id),
269+
'attachment of an invisible parent must not be listable',
270+
).toBe(false);
271+
// total must not leak the hidden row's existence either.
272+
expect(rows.every((r: any) => r.parent_object !== 'att_secret' || r.parent_id !== secretId)).toBe(true);
273+
274+
// A by-id read of the hidden attachment is a 404/403, not a leak.
275+
const byId = await stack.apiAs(memberBTok, 'GET', `/data/sys_attachment/${secretRow.id}`);
276+
expect([403, 404]).toContain(byId.status);
277+
278+
// Control: memberB CAN still see attachments on a record they can read.
279+
const okFile = await uploadFile(stack, memberBTok);
280+
const okAttach = await attach(memberBTok, 'att_case', caseAId, okFile);
281+
expect(okAttach.status).toBeLessThan(300);
282+
const okList = await stack.apiAs(memberBTok, 'GET', '/data/sys_attachment');
283+
const okRows = ((await okList.json()) as any).records ?? [];
284+
expect(okRows.some((r: any) => r.file_id === okFile)).toBe(true);
268285
});
269286

270287
// ── (e-read) known gap: anonymous downloads ──────────────────────────
5.94 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;
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { installAttachmentReadVisibility } from './attachment-access-hooks.js';
5+
import type { AttachmentLifecycleEngine, AttachmentReadMiddlewareCtx } from './attachment-lifecycle.js';
6+
7+
const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() });
8+
9+
/**
10+
* Fake engine modelling: a sys_attachment table (system pre-scan) + a
11+
* per-parent-object visibility map keyed by userId. A parent find under a
12+
* caller context returns only the ids that user can see.
13+
*/
14+
function install(opts: {
15+
attachments: Array<{ parent_object: string; parent_id: string; id?: string }>;
16+
/** parentObject -> userId -> visible parent ids */
17+
visible: Record<string, Record<string, string[]>>;
18+
}) {
19+
let mw!: (ctx: AttachmentReadMiddlewareCtx, next: () => Promise<void>) => Promise<void>;
20+
const calls = { parentFinds: [] as Array<{ object: string; ids: string[]; userId?: string }> };
21+
22+
const matchWhere = (row: any, where: any): boolean => {
23+
if (!where || typeof where !== 'object') return true;
24+
return Object.entries(where).every(([k, v]) => {
25+
if (v && typeof v === 'object' && Array.isArray((v as any).$in)) {
26+
return (v as any).$in.map(String).includes(String(row[k]));
27+
}
28+
return String(row[k]) === String(v);
29+
});
30+
};
31+
32+
const engine: AttachmentLifecycleEngine = {
33+
registerHook: () => {},
34+
registerMiddleware: (fn) => {
35+
mw = fn as any;
36+
},
37+
find: async (object: string, options: any) => {
38+
if (object === 'sys_attachment') {
39+
// system candidate pre-scan
40+
const rows = opts.attachments.filter((r) => matchWhere(r, options?.where));
41+
return rows.slice(0, options?.limit ?? rows.length) as any;
42+
}
43+
// caller-scoped parent visibility probe
44+
const userId = options?.context?.userId as string | undefined;
45+
const ids: string[] = (options?.where?.id?.$in ?? []).map(String);
46+
calls.parentFinds.push({ object, ids, userId });
47+
const vis = opts.visible[object]?.[userId ?? ''] ?? [];
48+
return ids.filter((id) => vis.includes(id)).map((id) => ({ id })) as any;
49+
},
50+
findOne: async () => null,
51+
update: async () => ({}),
52+
};
53+
installAttachmentReadVisibility(engine, silentLogger());
54+
return { mw, calls };
55+
}
56+
57+
/** Drive a read op through the middleware and return the resulting where. */
58+
async function runRead(
59+
mw: (ctx: AttachmentReadMiddlewareCtx, next: () => Promise<void>) => Promise<void>,
60+
ctxPartial: Partial<AttachmentReadMiddlewareCtx>,
61+
) {
62+
const ctx: AttachmentReadMiddlewareCtx = {
63+
object: 'sys_attachment',
64+
operation: 'find',
65+
ast: { object: 'sys_attachment', where: undefined },
66+
context: { userId: 'u1' },
67+
...ctxPartial,
68+
};
69+
let ran = false;
70+
await mw(ctx, async () => {
71+
ran = true;
72+
});
73+
return { where: ctx.ast?.where, ran };
74+
}
75+
76+
describe('installAttachmentReadVisibility', () => {
77+
const dataset = {
78+
attachments: [
79+
{ id: 'a1', parent_object: 'att_case', parent_id: 'c1' },
80+
{ id: 'a2', parent_object: 'att_case', parent_id: 'c2' }, // invisible to u1
81+
{ id: 'a3', parent_object: 'att_secret', parent_id: 's1' }, // invisible to u1
82+
],
83+
visible: {
84+
att_case: { u1: ['c1'] }, // u1 sees only c1
85+
att_secret: { u1: [] }, // u1 sees no secrets
86+
},
87+
};
88+
89+
it('constrains the query to only visible parents (single parent_object → bare clause)', async () => {
90+
const { mw } = install(dataset);
91+
const { where, ran } = await runRead(mw, {});
92+
expect(ran).toBe(true);
93+
// u1 sees att_case/c1 only; att_secret none → dropped.
94+
expect(where).toEqual({ parent_object: 'att_case', parent_id: { $in: ['c1'] } });
95+
});
96+
97+
it('emits a $or across multiple visible parent objects', async () => {
98+
const { mw } = install({
99+
attachments: [
100+
{ id: 'a1', parent_object: 'att_case', parent_id: 'c1' },
101+
{ id: 'a2', parent_object: 'att_todo', parent_id: 't1' },
102+
],
103+
visible: { att_case: { u1: ['c1'] }, att_todo: { u1: ['t1'] } },
104+
});
105+
const { where } = await runRead(mw, {});
106+
expect(where).toEqual({
107+
$or: [
108+
{ parent_object: 'att_case', parent_id: { $in: ['c1'] } },
109+
{ parent_object: 'att_todo', parent_id: { $in: ['t1'] } },
110+
],
111+
});
112+
});
113+
114+
it('denies all when no candidate parent is visible', async () => {
115+
const { mw } = install({
116+
attachments: [{ id: 'a3', parent_object: 'att_secret', parent_id: 's1' }],
117+
visible: { att_secret: { u1: [] } },
118+
});
119+
const { where } = await runRead(mw, {});
120+
expect(where).toEqual({ id: '__attachment_parent_denied__' });
121+
});
122+
123+
it('ANDs the visibility filter onto an existing where (does not clobber it)', async () => {
124+
// The pre-scan honors the caller's where, so the candidate rows must
125+
// carry the filtered field for this fake to surface them.
126+
const { mw } = install({
127+
attachments: [
128+
{ id: 'a1', parent_object: 'att_case', parent_id: 'c1', mime_type: 'application/pdf' } as any,
129+
{ id: 'a2', parent_object: 'att_case', parent_id: 'c2', mime_type: 'text/plain' } as any,
130+
],
131+
visible: { att_case: { u1: ['c1', 'c2'] } },
132+
});
133+
const existing = { mime_type: 'application/pdf' };
134+
const { where } = await runRead(mw, { ast: { object: 'sys_attachment', where: existing } });
135+
// Only a1 matched the pre-scan (mime_type), and its parent c1 is visible.
136+
expect(where).toEqual({
137+
$and: [existing, { parent_object: 'att_case', parent_id: { $in: ['c1'] } }],
138+
});
139+
});
140+
141+
it('filters count() identically (list total cannot leak the unfiltered count)', async () => {
142+
const { mw } = install(dataset);
143+
const { where } = await runRead(mw, { operation: 'count' });
144+
expect(where).toEqual({ parent_object: 'att_case', parent_id: { $in: ['c1'] } });
145+
});
146+
147+
it('bypasses system context and context-less reads (internal calls)', async () => {
148+
const { mw, calls } = install(dataset);
149+
const sys = await runRead(mw, { context: { isSystem: true } as any });
150+
expect(sys.where).toBeUndefined();
151+
const anon = await runRead(mw, { context: undefined });
152+
expect(anon.where).toBeUndefined();
153+
// no parent probes were issued for the bypassed reads
154+
expect(calls.parentFinds).toHaveLength(0);
155+
});
156+
157+
it('does not touch write operations', async () => {
158+
const { mw } = install(dataset);
159+
const { where } = await runRead(mw, { operation: 'delete', ast: { object: 'sys_attachment', where: { id: 'a1' } } });
160+
expect(where).toEqual({ id: 'a1' }); // unchanged
161+
});
162+
163+
it('leaves an already-empty result set alone (no candidates → no filter)', async () => {
164+
const { mw } = install({ attachments: [], visible: {} });
165+
const { where } = await runRead(mw, {});
166+
expect(where).toBeUndefined();
167+
});
168+
169+
it('ignores self-referential (parent_object=sys_attachment) rows — prevents probe re-entry', async () => {
170+
const { mw, calls } = install({
171+
attachments: [{ id: 'a1', parent_object: 'sys_attachment', parent_id: 'a0' }],
172+
visible: {},
173+
});
174+
const { where } = await runRead(mw, {});
175+
// only a self-ref candidate → nothing to grant → deny all, and NO parent
176+
// probe was issued against sys_attachment.
177+
expect(where).toEqual({ id: '__attachment_parent_denied__' });
178+
expect(calls.parentFinds).toHaveLength(0);
179+
});
180+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@ export type { StorageRoutesOptions } 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';
17-
export { installAttachmentAccessHooks } from './attachment-access-hooks.js';
17+
export { installAttachmentAccessHooks, installAttachmentReadVisibility } from './attachment-access-hooks.js';
1818
export type { AttachmentSharingLike } from './attachment-access-hooks.js';

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,15 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () =>
197197
const ctx = makeCtx();
198198

199199
const hookEvents: string[] = [];
200+
const middlewares: Array<{ object?: string }> = [];
200201
ctx.registerService('objectql', {
201202
registerHook: (event: string, _fn: unknown, opts: any) => {
202203
expect(opts?.object).toBe('sys_attachment');
203204
hookEvents.push(event);
204205
},
206+
registerMiddleware: (_fn: unknown, opts: any) => {
207+
middlewares.push({ object: opts?.object });
208+
},
205209
find: async () => [],
206210
findOne: async () => null,
207211
update: async () => ({}),
@@ -228,6 +232,8 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () =>
228232
expect(guards).toHaveLength(1);
229233
expect(guards[0].object).toBe('sys_file');
230234
expect(typeof guards[0].guard).toBe('function');
235+
// Read-visibility middleware registered on sys_attachment (#2970 item 1).
236+
expect(middlewares).toEqual([{ object: 'sys_attachment' }]);
231237
});
232238

233239
it('degrades silently on a bare kernel (no engine, no lifecycle service)', async () => {

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { S3StorageAdapterOptions } from './s3-storage-adapter.js';
1414
import { StorageMetadataStore } from './metadata-store.js';
1515
import { registerStorageRoutes } from './storage-routes.js';
1616
import { installAttachmentLifecycleHooks, createSysFileReapGuard } from './attachment-lifecycle.js';
17-
import { installAttachmentAccessHooks } from './attachment-access-hooks.js';
17+
import { installAttachmentAccessHooks, installAttachmentReadVisibility } from './attachment-access-hooks.js';
1818
import { SystemFile, SystemUploadSession } from './objects/index.js';
1919
// ADR-0052 §3 ownership: `sys_attachment` (a file↔record link) belongs with the
2020
// storage domain, not the audit/compliance ledger. Definition stays in
@@ -212,6 +212,12 @@ export class StorageServicePlugin implements Plugin {
212212
},
213213
ctx.logger,
214214
);
215+
// Parent-derived READ visibility (#2970 item 1) — list/find/count of
216+
// sys_attachment only returns rows whose parent record the caller can
217+
// read. Middleware (not a hook) so list `total` is filtered too.
218+
if (typeof (engine as any).registerMiddleware === 'function') {
219+
installAttachmentReadVisibility(engine as any, ctx.logger);
220+
}
215221
try {
216222
const lifecycle = ctx.getService<any>('lifecycle');
217223
if (lifecycle && typeof lifecycle.registerReapGuard === 'function') {

0 commit comments

Comments
 (0)