Skip to content

Commit 941dec4

Browse files
os-zhuangclaude
andauthored
fix(service-storage): refuse an unscoped multi-delete of sys_attachment (#4757) (#4780)
The `beforeDelete` gate in `installAttachmentAccessHooks` resolved the rows a delete matches by `input.id` or by `input.options.where`, then short-circuited on `if (!rows.length) return`. A delete with NEITHER took neither branch, so the gate returned allow — while the engine seeded `{ object }` as the delete AST and handed that to `driver.deleteMany`, emptying the table. "Nothing to authorize" and "nothing was ever queried" are different verdicts. Fail closed on that shape instead: no id and no `where` throws 403 `ATTACHMENT_DELETE_DENIED`, the posture #4630 gave `sys_comment` in `resolveTargetRows`. Scoped paths are untouched — an id-bound delete, a `where`-bound multi-delete and `where: {}` all still resolve their rows and authorize each one, and a query that genuinely matches nothing still passes. Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny Co-authored-by: Claude <noreply@anthropic.com>
1 parent 84b6e58 commit 941dec4

3 files changed

Lines changed: 128 additions & 4 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
---
4+
5+
fix(service-storage): an UNSCOPED multi-delete of `sys_attachment` is refused instead of authorized (#4757)
6+
7+
`installAttachmentAccessHooks`'s `beforeDelete` gate resolved the rows a delete
8+
matches in two ways — by `input.id`, or by `input.options.where` — and then
9+
short-circuited with `if (!rows.length) return`. A delete carrying **neither**
10+
an id **nor** a `where` took neither branch, so `rows` stayed empty and the gate
11+
returned *allow*. That is not "nothing matched": nothing was ever queried.
12+
13+
The engine reads the same call as a bulk delete over everything — with no
14+
single id it seeds the delete AST as `{ object }` and hands that to
15+
`driver.deleteMany` — so `ql.delete('sys_attachment', { multi: true })` emptied
16+
the whole attachment table with the record-level gate having authorized exactly
17+
zero rows. Neither layer underneath catches it: plugin-sharing composes no
18+
row-scoping predicate for an object with no owner field (`sys_attachment`'s
19+
provenance column is `uploaded_by`), and plugin-security only refuses callers
20+
whose grants lack the delete bit on `sys_attachment` — an app shipping the
21+
domain grant the attachments panel requires passes RBAC and lands here.
22+
23+
The gate now fails **closed** on that shape: no id and no `where` is refused
24+
with 403 `ATTACHMENT_DELETE_DENIED` ("Refusing an unscoped multi-delete of
25+
attachments — scope the delete to the rows you mean"), the posture #4630 gave
26+
`sys_comment` in `resolveTargetRows`. "Nothing to authorize" and "nothing was
27+
ever queried" are different verdicts, and reading the second as the first is
28+
fail-open.
29+
30+
Scoped deletes are unchanged: an id-bound delete, a `where`-bound multi-delete,
31+
and even `where: {}` (which matches every row but is a real query) still resolve
32+
their rows and authorize each one uploader-or-parent-editor as before — a delete
33+
that legitimately matches no row still passes. Only the predicate-less call is
34+
newly refused. If you were relying on `ql.delete('sys_attachment', { multi:
35+
true })` to clear the table, pass a predicate (`{ multi: true, where: {} }`
36+
authorizes row-by-row) or perform the sweep under a system context, which
37+
bypasses the gate as before.

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,73 @@ describe('attachment access — beforeDelete (uploader or parent editor)', () =>
198198
await expect(beforeDelete(deleteCtx({ id: 'a1' }, { isSystem: true, userId: 'x' }))).resolves.toBeUndefined();
199199
await expect(beforeDelete(deleteCtx({ id: 'missing' }, { userId: 'x' }))).resolves.toBeUndefined();
200200
});
201+
202+
// #4757 — no id AND no `where` is not "nothing matched", it is "nothing was
203+
// ever queried": the engine seeds `{ object }` as the delete AST and hands
204+
// it to `driver.deleteMany`, which empties the table. The gate must refuse
205+
// rather than fall through the empty-`rows` short-circuit.
206+
describe('unscoped multi-delete (no id, no where) — #4757', () => {
207+
const unscopedShapes: Array<[string, any]> = [
208+
['options.multi with no where', { options: { multi: true } }],
209+
['no id and no options at all', {}],
210+
['an explicitly null where', { options: { multi: true, where: null } }],
211+
['an explicitly undefined where', { options: { multi: true, where: undefined } }],
212+
];
213+
214+
for (const [label, input] of unscopedShapes) {
215+
it(`refuses ${label} (403 ATTACHMENT_DELETE_DENIED)`, async () => {
216+
const { beforeDelete } = install({ attachments: [row], sharing: { canEdit: async () => true } });
217+
await expect(beforeDelete(deleteCtx(input, { userId: 'uploader' }))).rejects.toMatchObject({
218+
code: 'ATTACHMENT_DELETE_DENIED',
219+
status: 403,
220+
});
221+
});
222+
}
223+
224+
it('refuses even the uploader of every matched row — the AST is unscoped, not row-scoped', async () => {
225+
// The uploader shortcut is per RESOLVED row; with nothing resolved there
226+
// is no row whose ownership could license emptying the table.
227+
const canEdit = vi.fn(async () => true);
228+
const { beforeDelete } = install({
229+
attachments: [{ ...row, uploaded_by: 'uploader' }],
230+
sharing: { canEdit },
231+
});
232+
await expect(
233+
beforeDelete(deleteCtx({ options: { multi: true } }, { userId: 'uploader' })),
234+
).rejects.toMatchObject({ code: 'ATTACHMENT_DELETE_DENIED' });
235+
expect(canEdit).not.toHaveBeenCalled();
236+
});
237+
238+
it('still bypasses for system context and context-less calls', async () => {
239+
const { beforeDelete } = install({ attachments: [row], sharing: { canEdit: async () => false } });
240+
await expect(
241+
beforeDelete(deleteCtx({ options: { multi: true } }, { isSystem: true, userId: 'x' })),
242+
).resolves.toBeUndefined();
243+
await expect(beforeDelete(deleteCtx({ options: { multi: true } }, {}))).resolves.toBeUndefined();
244+
});
245+
246+
// The scoped paths must be untouched by the fix: an id-bound delete and a
247+
// `where`-bound one still authorize row-by-row and still ALLOW when they
248+
// pass. `where: {}` matches every row but is a real query — every matched
249+
// row is authorized, so it stays on the authorize path, not the refuse one.
250+
it('leaves the legitimate scoped paths alone', async () => {
251+
const { beforeDelete } = install({ attachments: [row], sharing: { canEdit: async () => true } });
252+
await expect(beforeDelete(deleteCtx({ id: 'a1' }, { userId: 'stranger' }))).resolves.toBeUndefined();
253+
await expect(
254+
beforeDelete(
255+
deleteCtx({ options: { where: { parent_object: 'att_secret' }, multi: true } }, { userId: 'stranger' }),
256+
),
257+
).resolves.toBeUndefined();
258+
await expect(
259+
beforeDelete(deleteCtx({ options: { where: {}, multi: true } }, { userId: 'stranger' })),
260+
).resolves.toBeUndefined();
261+
});
262+
263+
it('an empty `where` still authorizes every matched row (one failing row denies)', async () => {
264+
const { beforeDelete } = install({ attachments: [row], sharing: { canEdit: async () => false } });
265+
await expect(
266+
beforeDelete(deleteCtx({ options: { where: {}, multi: true } }, { userId: 'stranger' })),
267+
).rejects.toMatchObject({ code: 'ATTACHMENT_DELETE_DENIED' });
268+
});
269+
});
201270
});

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import type {
2525
* - beforeDelete: the caller must be the uploader OR hold edit on the
2626
* parent record (sharing service's `canEdit`; public-model parents are
2727
* editable by design). Fail-closed 403 `ATTACHMENT_DELETE_DENIED`; a
28-
* multi-delete requires EVERY matched row to pass.
28+
* multi-delete requires EVERY matched row to pass, and one carrying
29+
* NEITHER an id NOR a `where` is refused outright (#4757) — the engine
30+
* would hand `deleteMany` an AST over the whole table, and a gate that
31+
* resolved no rows for it would be authorizing exactly that.
2932
*
3033
* System-context operations (engine self-writes, seeds, lifecycle sweeps)
3134
* bypass both gates, as do context-less programmatic calls on bare kernels
@@ -151,9 +154,22 @@ export function installAttachmentAccessHooks(
151154
const row = await engine.findOne('sys_attachment', { where: { id }, context: { ...SYSTEM_CTX } });
152155
if (row) rows.push(row);
153156
}
154-
} else if (ctx?.input?.options?.where) {
157+
} else {
158+
const where = ctx?.input?.options?.where;
159+
if (where === undefined || where === null) {
160+
// #4757 — no id AND no predicate: the engine hands `deleteMany` an
161+
// AST of `{ object }`, i.e. the WHOLE table. Falling through here
162+
// would authorize that by resolving zero rows, so refuse instead.
163+
// "Nothing to authorize" and "nothing was ever queried" are not the
164+
// same verdict; reading the second as the first is fail-open.
165+
// (Mirrors #4630's `resolveTargetRows` for sys_comment.)
166+
forbid(
167+
'ATTACHMENT_DELETE_DENIED',
168+
'Refusing an unscoped multi-delete of attachments — scope the delete to the rows you mean (an id or a where predicate)',
169+
);
170+
}
155171
rows = await engine.find('sys_attachment', {
156-
where: ctx.input.options.where,
172+
where,
157173
limit: MULTI_DELETE_AUTH_LIMIT + 1,
158174
context: { ...SYSTEM_CTX },
159175
});
@@ -164,7 +180,9 @@ export function installAttachmentAccessHooks(
164180
);
165181
}
166182
}
167-
if (!rows.length) return; // nothing matched — nothing to authorize
183+
// Reached only after a real resolve: the query ran and matched no row
184+
// (or the ids name no live row), so there is genuinely nothing to gate.
185+
if (!rows.length) return;
168186

169187
const sharing = getSharing();
170188
const callerCtx = callerContext(ctx);

0 commit comments

Comments
 (0)