Skip to content

Commit f4d7f1d

Browse files
claudeos-zhuang
authored andcommitted
fix(metadata-protocol,rest): the id list is the only thing deleteMany can select on (#3897)
`deleteManyData` built `{ where: { id: { $in: ids } } }` and then spread the caller-supplied `options` OVER it, and the REST route splatted the whole request body into the protocol request. So `{"ids":["a"],"options":{"multi":true,"where":{}}}` reached `engine.delete` as an unscoped bulk delete — widening "delete these records" into "delete everything this caller is allowed to delete" (measured on a stock CRM dev deployment: one id in, all 8 rows gone). The same spread also accepted `context`, i.e. a forged principal where the route is reachable without auth. The engine options are now built from the validated id list alone — caller `options` is a `BatchOptions` bag that carries nothing `engine.delete` consumes, so merging it could only ever smuggle in engine keys. Ids must be scalars, so an operator object cannot reach `where.id` either. The REST route parses the body against `DeleteManyDataRequestSchema` one hop earlier; Zod strips unknown keys, so `options.where`, top-level `where` and a body `context` never survive the ingress. The endpoint also works for the first time: `multi` was never set, so a well-formed `{"ids":[…]}` only ever hit the engine's 'Delete requires an ID or options.multi=true' throw. Deletes now run per id by primary key, which also honours `deleteBehavior` (the bulk branch skips `cascadeDeleteRelations`) and makes the declared `BatchUpdateResponse` — per-id `results`, `atomic`, `continueOnError` — actually deliverable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt
1 parent 1d5dc46 commit f4d7f1d

6 files changed

Lines changed: 468 additions & 11 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
fix(metadata-protocol,rest): the id list is the only thing deleteMany can select on (#3897)
7+
8+
`deleteManyData` built the predicate its endpoint is named after and then spread
9+
the caller's `options` **over** it:
10+
11+
```js
12+
return this.engine.delete(request.object, {
13+
where: { id: { $in: request.ids } },
14+
...request.options // ← lands after `where`, so it can replace it
15+
});
16+
```
17+
18+
`request.options` is caller-supplied — `POST /data/:object/deleteMany` splatted
19+
the whole request body into the protocol request (`{ object, ...req.body }`) —
20+
so one body key rewrote the operation:
21+
22+
```json
23+
{"ids":["a"], "options":{"multi":true,"where":{}}}
24+
```
25+
26+
reached `engine.delete` as an unscoped bulk delete. The engine's write
27+
middleware still composes RLS/sharing predicates onto the AST, so the blast
28+
radius is not automatically the whole table: it is **everything the caller is
29+
allowed to delete**. For an ordinary user with delete permission that is the
30+
difference between the 3 records they asked for and every record they can see;
31+
measured on a stock CRM dev deployment, that payload against one id removed all
32+
8 rows in the object and returned the raw driver count (`8`). The same spread
33+
also accepted `context`, i.e. a forged principal wherever the route is reachable
34+
without auth.
35+
36+
**The id set is now authoritative, structurally.** The engine options are built
37+
from the validated id list and nothing else — caller `options` is a
38+
`BatchOptions` bag (`atomic` / `returnRecords` / `continueOnError` /
39+
`validateOnly`) that carries nothing `engine.delete` consumes, so merging it
40+
could only ever smuggle in engine keys. Ids must be scalars, so an operator
41+
object (`{"ids":[{"$ne":null}]}`) cannot reach `where.id` either; a malformed
42+
list is a `400 VALIDATION_FAILED` instead of a wider delete. The REST route
43+
parses the body against `DeleteManyDataRequestSchema` first, one hop earlier —
44+
Zod object schemas strip unknown keys, so `options.where`, top-level `where` and
45+
a body `context` no longer survive the ingress at all.
46+
47+
**The endpoint also works now.** `deleteManyData` never set `multi`, so a
48+
correctly-formed `{"ids":[…]}` hit the engine's
49+
`'Delete requires an ID or options.multi=true'` throw — only the requests that
50+
triggered the override above ever completed. Deletes now go one id at a time by
51+
primary key, the same shape `batchData`'s `delete` case uses, which closes two
52+
gaps behind that: the bulk branch skips `cascadeDeleteRelations`, so
53+
`deleteBehavior` (`cascade` / `set_null` / `restrict`) was not honoured for the
54+
rows it removed; and the declared `BatchUpdateResponse` contract (per-record
55+
`results`, `atomic`, `continueOnError`) was unimplementable from a bulk row
56+
count. Both are delivered rather than declared.
57+
58+
**Behaviour change.** The endpoint returns a `BatchUpdateResponse`
59+
(`{ success, operation, total, succeeded, failed, results }`) where it
60+
previously returned the driver's raw delete count — on the paths where it
61+
returned anything at all. The caller's execution context is threaded to every
62+
delete, so RLS/FLS now run under the caller here as they do on the single-record
63+
route.

content/docs/api/data-api.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,16 @@ Batch update multiple records.
126126

127127
Batch delete records by ID list.
128128

129-
**Body**: `{ ids: ["1", "2", "3"] }`
129+
**Body**: `{ "ids": ["1", "2", "3"], "options": { "atomic": true } }``options` is
130+
the same `BatchOptions` bag `/batch` takes. The body is validated against the
131+
contract and unknown keys are dropped: the id list is the *only* thing that
132+
selects rows, so no body key can widen the delete into a filter.
133+
134+
**Response**: `BatchUpdateResponse` — one `results` entry per id. Records are
135+
deleted one at a time by primary key, so each honours `deleteBehavior`
136+
(`cascade` / `set_null` / `restrict`) on relations pointing at it. `atomic`
137+
(default) stops the run at the first failure; `atomic: false` with
138+
`continueOnError: true` processes the remaining ids and reports the failures.
130139

131140
---
132141

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#3897] `deleteManyData` used to spread the caller-supplied `options` OVER
4+
// the id predicate it had just built, so a request body could replace `where`
5+
// outright and widen "delete these ids" into "delete everything this caller is
6+
// allowed to delete". The same spread could also smuggle in `context` (a forged
7+
// principal) and `multi`. These tests pin the three properties that make that
8+
// impossible now: the id set is authoritative, caller `options` never reaches
9+
// the engine, and the happy path actually deletes (it used to throw
10+
// `'Delete requires an ID or options.multi=true'` because `multi` was never set).
11+
12+
import { describe, it, expect, vi } from 'vitest';
13+
import { ObjectStackProtocolImplementation } from './protocol.js';
14+
15+
const SCHEMA = { name: 'invoice', fields: { title: { name: 'title', type: 'text' } } };
16+
17+
function makeProtocol(deleteImpl?: (object: string, options: any) => Promise<any>) {
18+
const del = vi.fn(deleteImpl ?? (async () => ({ deleted: true })));
19+
const engine = {
20+
registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) },
21+
delete: del,
22+
};
23+
return { p: new ObjectStackProtocolImplementation(engine as any), del };
24+
}
25+
26+
describe('deleteManyData — the id set is the contract (#3897)', () => {
27+
it('deletes exactly the supplied ids and reports a BatchUpdateResponse', async () => {
28+
const { p, del } = makeProtocol();
29+
const res: any = await p.deleteManyData({ object: 'invoice', ids: ['a', 'b', 'c'] } as any);
30+
31+
expect(del).toHaveBeenCalledTimes(3);
32+
expect(del.mock.calls.map((c) => c[1].where)).toEqual([
33+
{ id: 'a' }, { id: 'b' }, { id: 'c' },
34+
]);
35+
expect(res).toMatchObject({
36+
success: true, operation: 'delete', total: 3, succeeded: 3, failed: 0,
37+
});
38+
expect(res.results).toEqual([
39+
{ id: 'a', success: true }, { id: 'b', success: true }, { id: 'c', success: true },
40+
]);
41+
});
42+
43+
it('the happy path no longer trips the engine\'s "ID or options.multi=true" guard', async () => {
44+
// Stand in for engine.delete's real dispatch: a scalar `where.id` is a
45+
// by-id delete; anything else needs `options.multi` or it throws. The
46+
// pre-#3897 implementation produced `{ id: { $in: [...] } }` with no
47+
// `multi`, so a well-formed request could only ever hit the throw.
48+
const { p } = makeProtocol(async (_object, options) => {
49+
const id = options?.where?.id;
50+
const scalar = typeof id === 'string' || typeof id === 'number';
51+
if (!scalar && !options?.multi) throw new Error('Delete requires an ID or options.multi=true');
52+
return { id };
53+
});
54+
55+
const res: any = await p.deleteManyData({ object: 'invoice', ids: ['a'] } as any);
56+
expect(res.success).toBe(true);
57+
expect(res.failed).toBe(0);
58+
});
59+
60+
it('a body-supplied options.where cannot replace the id predicate', async () => {
61+
const { p, del } = makeProtocol();
62+
// The exploit payload from the issue, verbatim.
63+
await p.deleteManyData({
64+
object: 'invoice',
65+
ids: ['a'],
66+
options: { multi: true, where: {} },
67+
} as any);
68+
69+
expect(del).toHaveBeenCalledTimes(1);
70+
const opts = del.mock.calls[0][1];
71+
expect(opts.where).toEqual({ id: 'a' });
72+
expect(opts.multi).toBeUndefined();
73+
});
74+
75+
it('a body-supplied options.context cannot forge the caller principal', async () => {
76+
const { p, del } = makeProtocol();
77+
await p.deleteManyData({
78+
object: 'invoice',
79+
ids: ['a'],
80+
options: { context: { userId: 'root', roles: ['admin'] } },
81+
} as any);
82+
83+
expect(del.mock.calls[0][1].context).toBeUndefined();
84+
});
85+
86+
it('threads the resolved execution context to every engine delete', async () => {
87+
const { p, del } = makeProtocol();
88+
const ctx = { userId: 'u1' };
89+
await p.deleteManyData({ object: 'invoice', ids: ['a', 'b'], context: ctx } as any);
90+
91+
expect(del.mock.calls[0][1].context).toBe(ctx);
92+
expect(del.mock.calls[1][1].context).toBe(ctx);
93+
});
94+
95+
it('rejects non-scalar ids instead of letting an operator object reach where.id', async () => {
96+
const { p, del } = makeProtocol();
97+
await expect(
98+
p.deleteManyData({ object: 'invoice', ids: [{ $ne: null }] } as any),
99+
).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400 });
100+
expect(del).not.toHaveBeenCalled();
101+
});
102+
103+
it('rejects a missing / non-array ids rather than deleting unscoped', async () => {
104+
const { p, del } = makeProtocol();
105+
await expect(
106+
p.deleteManyData({ object: 'invoice', options: { multi: true } } as any),
107+
).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400 });
108+
expect(del).not.toHaveBeenCalled();
109+
});
110+
111+
it('an empty id list deletes nothing', async () => {
112+
const { p, del } = makeProtocol();
113+
const res: any = await p.deleteManyData({ object: 'invoice', ids: [] } as any);
114+
expect(del).not.toHaveBeenCalled();
115+
expect(res).toMatchObject({ success: true, total: 0, succeeded: 0, failed: 0, results: [] });
116+
});
117+
});
118+
119+
describe('deleteManyData — partial-failure semantics (#3897)', () => {
120+
function failOn(badId: string) {
121+
return makeProtocol(async (_object, options) => {
122+
if (options.where.id === badId) throw new Error('RLS: not visible');
123+
return { id: options.where.id };
124+
});
125+
}
126+
127+
it('stops at the first failure by default and reports it per row', async () => {
128+
const { p, del } = failOn('b');
129+
const res: any = await p.deleteManyData({ object: 'invoice', ids: ['a', 'b', 'c'] } as any);
130+
131+
expect(del).toHaveBeenCalledTimes(2);
132+
expect(res).toMatchObject({ success: false, total: 3, succeeded: 1, failed: 1 });
133+
expect(res.results[1]).toEqual({ id: 'b', success: false, error: 'RLS: not visible' });
134+
});
135+
136+
it('continueOnError keeps going and still marks the batch unsuccessful', async () => {
137+
const { p, del } = failOn('b');
138+
const res: any = await p.deleteManyData({
139+
object: 'invoice',
140+
ids: ['a', 'b', 'c'],
141+
options: { atomic: false, continueOnError: true },
142+
} as any);
143+
144+
expect(del).toHaveBeenCalledTimes(3);
145+
expect(res).toMatchObject({ success: false, total: 3, succeeded: 2, failed: 1 });
146+
});
147+
148+
it('atomic aborts the remaining ids on the first failure', async () => {
149+
const { p, del } = failOn('b');
150+
const res: any = await p.deleteManyData({
151+
object: 'invoice',
152+
ids: ['a', 'b', 'c'],
153+
options: { atomic: true, continueOnError: true },
154+
} as any);
155+
156+
expect(del).toHaveBeenCalledTimes(2);
157+
expect(res.succeeded).toBe(1);
158+
});
159+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3805,13 +3805,92 @@ export class ObjectStackProtocolImplementation implements
38053805
throw new Error('triggerAutomation requires plugin-automation service. Install and register a plugin that provides the "automation" service.');
38063806
}
38073807

3808-
async deleteManyData(request: DeleteManyDataRequest): Promise<any> {
3809-
this.assertObjectRegistered(request.object); // [#3770]
3810-
// This expects deleting by IDs.
3811-
return this.engine.delete(request.object, {
3812-
where: { id: { $in: request.ids } },
3813-
...request.options
3814-
});
3808+
/**
3809+
* Bulk delete by id — the `POST /data/:object/deleteMany` ingress.
3810+
*
3811+
* [#3897] This used to build `{ where: { id: { $in: ids } } }` and then
3812+
* spread `...request.options` OVER it. `options` is caller-supplied (the
3813+
* REST route splats the whole request body into the protocol request), so
3814+
* a body key replaced the predicate the endpoint is named after:
3815+
*
3816+
* {"ids":["a"],"options":{"multi":true,"where":{}}}
3817+
*
3818+
* reached `engine.delete` as an unscoped bulk delete — widening "delete
3819+
* these 3 records" into "delete everything this caller is allowed to
3820+
* delete" (RLS/sharing middleware still composes onto the AST, so the blast
3821+
* radius is the caller's visible set, not the whole table). The same spread
3822+
* could smuggle in `context`, i.e. a forged principal on any deployment
3823+
* where the route is reachable without auth.
3824+
*
3825+
* The fix is structural rather than a re-ordered spread: caller `options`
3826+
* is a `BatchOptions` bag (`atomic` / `returnRecords` /
3827+
* `continueOnError` / `validateOnly`) and carries NOTHING `engine.delete`
3828+
* consumes, so it is never merged into the engine options. The engine call
3829+
* is built here from the validated id list alone, and each id is deleted by
3830+
* scalar primary key — the same shape `batchData`'s `delete` case uses.
3831+
*
3832+
* Deleting per id (instead of one `$in` bulk delete) also fixes the second
3833+
* half of #3897 and two silent gaps behind it:
3834+
* - the endpoint's happy path never worked at all — `deleteManyData` never
3835+
* set `multi`, so a well-formed `{"ids":[…]}` hit engine.ts's
3836+
* `'Delete requires an ID or options.multi=true'` throw, and ONLY the
3837+
* requests that triggered the override above got through;
3838+
* - the bulk branch skips `cascadeDeleteRelations`, so `deleteBehavior`
3839+
* (`cascade` / `set_null` / `restrict`) was not honoured for the rows it
3840+
* removed;
3841+
* - the declared {@link BatchUpdateResponse} contract (per-record results,
3842+
* `atomic` / `continueOnError`) was unimplementable from a bulk row
3843+
* count. It is now actually delivered.
3844+
*/
3845+
async deleteManyData(request: DeleteManyDataRequest & { context?: any }): Promise<BatchUpdateResponse> {
3846+
const { object, options, context } = request;
3847+
this.assertObjectRegistered(object); // [#3770]
3848+
3849+
// Fail CLOSED on anything that is not a list of scalar ids. A non-scalar
3850+
// entry (`{"ids":[{"$ne":null}]}`) must never reach `where.id` as an
3851+
// operator object — that is the same "predicate widening" this endpoint
3852+
// was just hardened against, one layer down.
3853+
const isScalarId = (v: unknown) =>
3854+
(typeof v === 'string' && v.length > 0) || typeof v === 'number' || typeof v === 'bigint';
3855+
const ids = request.ids as unknown;
3856+
if (!Array.isArray(ids) || ids.some((id) => !isScalarId(id))) {
3857+
const err: any = new Error(
3858+
`deleteMany on '${object}' requires 'ids' to be an array of record ids`,
3859+
);
3860+
err.code = 'VALIDATION_FAILED';
3861+
err.status = 400;
3862+
throw err;
3863+
}
3864+
3865+
const results: Array<{ id?: string; success: boolean; error?: string }> = [];
3866+
let succeeded = 0;
3867+
let failed = 0;
3868+
const ctxOpt = context !== undefined ? { context } : {};
3869+
3870+
for (const id of ids) {
3871+
try {
3872+
await this.engine.delete(object, { where: { id }, ...ctxOpt } as any);
3873+
results.push({ id: String(id), success: true });
3874+
succeeded++;
3875+
} catch (err: any) {
3876+
results.push({ id: String(id), success: false, error: err?.message });
3877+
failed++;
3878+
// Same stop semantics as `batchData`: `atomic` aborts the rest on
3879+
// the first failure, and without `continueOnError` a failure ends
3880+
// the run rather than silently ploughing on.
3881+
if (options?.atomic) break;
3882+
if (!options?.continueOnError) break;
3883+
}
3884+
}
3885+
3886+
return {
3887+
success: failed === 0,
3888+
operation: 'delete',
3889+
total: ids.length,
3890+
succeeded,
3891+
failed,
3892+
results,
3893+
} as BatchUpdateResponse;
38153894
}
38163895

38173896
/**

0 commit comments

Comments
 (0)