Skip to content

Commit d687f08

Browse files
authored
Merge pull request #3044 from objectstack-ai/claude/fix-readonlywhen-multi-3042
fix(security): enforce readonlyWhen on the multi-row UPDATE path (#3042)
2 parents ef3fdd7 + e9a2885 commit d687f08

5 files changed

Lines changed: 268 additions & 20 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(security): enforce `readonlyWhen` on the multi-row UPDATE path (#3042)
6+
7+
Conditional `readonlyWhen` field locks were stripped only on the single-id
8+
UPDATE path; the bulk `update({ multi: true, where })` path enforced static
9+
`readonly` (#2948) but never `readonlyWhen`. A programmatic/embedded caller (or
10+
a plugin) issuing a multi-row update in a user context could therefore write a
11+
field its own `readonlyWhen` predicate should have locked — the conditional
12+
lock held for a `PATCH /data/:object/:id` but not for a bulk where-predicate
13+
update. (The external REST/SDK `updateMany` endpoint was unaffected: it loops
14+
single-id `engine.update` calls, which already strip `readonlyWhen`.)
15+
16+
`engine.update` now, on the multi-row path and only when the payload actually
17+
writes a `readonlyWhen` field, reads the row-scoped match set with the same
18+
composed AST the write binds (one query) and drops any field whose predicate is
19+
TRUE for at least one matched row — a single bulk payload cannot keep a field
20+
for some rows and drop it for others, so a field locked in any target row is
21+
fail-safe-dropped for the batch (narrow the `where` to reach the rows where it
22+
is unlocked). A conditional field NO matched row locks is written normally, so a
23+
legitimate bulk edit is unaffected. Symmetric with the single-id
24+
`stripReadonlyWhenFields` and with the static-`readonly` bulk strip; INSERT
25+
stays exempt. No change for any single-id update or any object without
26+
`readonlyWhen` fields.

packages/objectql/src/engine.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import type { Expression } from '@objectstack/spec';
3131
import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
3232
import { bindHooksToEngine } from './hook-binder.js';
3333
import { validateRecord, normalizeMultiValueFields, coerceBooleanFields } from './validation/record-validator.js';
34-
import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyFields } from './validation/rule-validator.js';
34+
import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, stripReadonlyFields } from './validation/rule-validator.js';
3535
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
3636

3737
interface FormulaPlanEntry { name: string; expression: Expression; }
@@ -2443,19 +2443,13 @@ export class ObjectQL implements IDataEngine {
24432443
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
24442444
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>);
24452445
validateRecord(updateSchema, hookContext.input.data as Record<string, unknown>, 'update');
2446-
// Multi-row update: per-row prior state is not fetched (one query
2447-
// per matched row would be unbounded). state_machine /
2448-
// cross_field rules are skipped here; warn so the gap is visible.
2446+
// Multi-row update: per-row prior state is not fetched for the
2447+
// object-level state_machine / cross_field / script rules (they
2448+
// need one prior per row, and a single bulk payload cannot diverge
2449+
// per row). Warn so the gap stays visible.
24492450
if (needsPriorRecord(updateSchema as any)) {
24502451
this.logger.warn('Object-level validation rules (state_machine/cross_field/script) are not enforced on multi-row updates', { object });
24512452
}
2452-
// [#2948] Same static-`readonly` write guard on the bulk path —
2453-
// a forged read-only column in a multi-row update is dropped for
2454-
// non-system callers (a foreign `organization_id` is additionally
2455-
// rejected upstream by the tenant write wall, #2946).
2456-
if (!opCtx.context?.isSystem) {
2457-
hookContext.input.data = stripReadonlyFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, suppliedKeys, this.logger) as any;
2458-
}
24592453
// [#2982] Consume the middleware-composed AST seeded above, so
24602454
// the injected row-scoping (RLS write filter, sharing's
24612455
// editable-rows filter) actually binds the driver operation. Fail
@@ -2470,6 +2464,26 @@ export class ObjectQL implements IDataEngine {
24702464
`(a hook cleared the target id after the security filter was composed).`,
24712465
);
24722466
}
2467+
// [#3042] Enforce conditional `readonlyWhen` on the bulk path too.
2468+
// Unlike static `readonly` (below), a `readonlyWhen` lock is PER
2469+
// ROW, so read the row-scoped match set with the SAME AST the write
2470+
// binds (one query, and only when the payload actually writes a
2471+
// `readonlyWhen` field) and drop any field locked in ≥1 matched row
2472+
// — a bulk write can't keep it for some rows and drop it for others,
2473+
// so a field locked in any target row is fail-safe-dropped for all
2474+
// (narrow `where` to reach the unlocked rows). Symmetric with the
2475+
// single-id `stripReadonlyWhenFields`; INSERT stays exempt.
2476+
if (hasReadonlyWhenInPayload(updateSchema as any, hookContext.input.data as Record<string, unknown>)) {
2477+
const priorRows = await driver.find(object, ast, hookContext.input.options as any);
2478+
hookContext.input.data = stripReadonlyWhenFieldsMulti(updateSchema as any, hookContext.input.data as Record<string, unknown>, priorRows, this.logger) as any;
2479+
}
2480+
// [#2948] Same static-`readonly` write guard on the bulk path —
2481+
// a forged read-only column in a multi-row update is dropped for
2482+
// non-system callers (a foreign `organization_id` is additionally
2483+
// rejected upstream by the tenant write wall, #2946).
2484+
if (!opCtx.context?.isSystem) {
2485+
hookContext.input.data = stripReadonlyFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, suppliedKeys, this.logger) as any;
2486+
}
24732487
result = await driver.updateMany(object, ast, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
24742488
} else {
24752489
throw new Error('Update requires an ID or options.multi=true');

packages/objectql/src/plugin.integration.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,4 +1322,76 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
13221322
expect(updates[0].locked).toBe('legitimate-migration-value');
13231323
});
13241324
});
1325+
1326+
// #3042 — conditional `readonlyWhen` must be enforced on the BULK
1327+
// (updateMany) path too, not only the single-id path. The bulk strip reads
1328+
// the matched rows' prior state and drops a field locked in ≥1 of them.
1329+
describe('readonlyWhen write enforcement on multi-row UPDATE (#3042)', () => {
1330+
async function bootWithBulkCapture(priorRows: Record<string, any>[]) {
1331+
const bulkUpdates: Record<string, any>[] = [];
1332+
let findAst: any = null;
1333+
const mockDriver = {
1334+
name: 'rw-capture', version: '1.0.0',
1335+
connect: async () => {}, disconnect: async () => {},
1336+
find: async (_o: string, ast: any) => { findAst = ast; return priorRows; },
1337+
findOne: async () => null,
1338+
create: async (_o: string, d: any) => ({ id: 'rec-1', ...d }),
1339+
update: async (_o: string, _i: any, d: any) => ({ id: _i, ...d }),
1340+
updateMany: async (_o: string, _ast: any, d: any) => { bulkUpdates.push({ ...d }); return [{ ...d }]; },
1341+
delete: async () => true, syncSchema: async () => {},
1342+
};
1343+
await kernel.use({
1344+
name: 'rw-capture-plugin', type: 'driver', version: '1.0.0',
1345+
init: async (ctx) => { ctx.registerService('driver.rw-capture', mockDriver); },
1346+
});
1347+
await kernel.use(new ObjectQLPlugin());
1348+
await kernel.bootstrap();
1349+
const objectql = kernel.getService('objectql') as any;
1350+
const obj: ObjectSchema = {
1351+
name: 'rw_obj', label: 'RW Obj', datasource: 'rw-capture',
1352+
fields: {
1353+
status: { name: 'status', label: 'Status', type: 'text' },
1354+
note: { name: 'note', label: 'Note', type: 'text' },
1355+
// locked once the row is paid (conditional, per-row)
1356+
tax_rate: { name: 'tax_rate', label: 'Tax Rate', type: 'number', readonlyWhen: "record.status == 'paid'" } as any,
1357+
},
1358+
};
1359+
objectql.registry.registerObject(obj, 'test', 'test');
1360+
return { objectql, bulkUpdates, getFindAst: () => findAst };
1361+
}
1362+
1363+
it('drops a readonlyWhen field locked in ≥1 matched row, but keeps a sibling editable field', async () => {
1364+
// Match set: one draft (unlocked) + one paid (locked). The bulk payload
1365+
// cannot diverge per row, so tax_rate is fail-safe-dropped for the batch.
1366+
const { objectql, bulkUpdates, getFindAst } = await bootWithBulkCapture([
1367+
{ id: 'a', status: 'draft', tax_rate: 5 },
1368+
{ id: 'b', status: 'paid', tax_rate: 7 },
1369+
]);
1370+
await objectql.update(
1371+
'rw_obj',
1372+
{ tax_rate: 999, note: 'bulk edit' },
1373+
{ multi: true, where: { status: { $in: ['draft', 'paid'] } }, context: { userId: 'user-9' } },
1374+
);
1375+
expect(bulkUpdates.length).toBe(1);
1376+
const data = bulkUpdates[0];
1377+
expect(data).not.toHaveProperty('tax_rate'); // conditionally-locked forge stripped
1378+
expect(data.note).toBe('bulk edit'); // editable sibling still written
1379+
// the pre-read was row-scoped with the same predicate the write binds
1380+
expect(getFindAst()?.where).toEqual({ status: { $in: ['draft', 'paid'] } });
1381+
});
1382+
1383+
it('KEEPS the readonlyWhen field when NO matched row locks it', async () => {
1384+
const { objectql, bulkUpdates } = await bootWithBulkCapture([
1385+
{ id: 'a', status: 'draft', tax_rate: 5 },
1386+
{ id: 'c', status: 'sent', tax_rate: 6 },
1387+
]);
1388+
await objectql.update(
1389+
'rw_obj',
1390+
{ tax_rate: 999 },
1391+
{ multi: true, where: { status: { $in: ['draft', 'sent'] } }, context: { userId: 'user-9' } },
1392+
);
1393+
expect(bulkUpdates.length).toBe(1);
1394+
expect(bulkUpdates[0].tax_rate).toBe(999); // legitimate bulk edit unaffected
1395+
});
1396+
});
13251397
});

packages/objectql/src/validation/rule-validator.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
needsPriorRecord,
77
legalNextStates,
88
stripReadonlyWhenFields,
9+
stripReadonlyWhenFieldsMulti,
10+
hasReadonlyWhenInPayload,
911
stripReadonlyFields,
1012
} from './rule-validator.js';
1113
import { ValidationError } from './record-validator.js';
@@ -57,6 +59,60 @@ describe('stripReadonlyWhenFields (B2)', () => {
5759
});
5860
});
5961

62+
// #3042 — `readonlyWhen` on the BULK (updateMany) path. One payload is applied
63+
// to every matched row, so the strip evaluates the predicate against EACH
64+
// matched row's prior state and drops a field locked in ≥1 of them.
65+
describe('hasReadonlyWhenInPayload (#3042 gate)', () => {
66+
it('is TRUE when the payload writes a readonlyWhen field', () => {
67+
expect(hasReadonlyWhenInPayload(invoiceFields, { amount: 999 })).toBe(true);
68+
});
69+
it('is FALSE when the payload touches no readonlyWhen field', () => {
70+
expect(hasReadonlyWhenInPayload(invoiceFields, { status: 'draft' })).toBe(false);
71+
});
72+
it('is FALSE for an object with no readonlyWhen fields at all', () => {
73+
expect(hasReadonlyWhenInPayload({ fields: { x: { type: 'number' } } }, { x: 1 })).toBe(false);
74+
});
75+
});
76+
77+
describe('stripReadonlyWhenFieldsMulti (#3042)', () => {
78+
it('drops the field when it is locked in EVERY matched row', () => {
79+
const out = stripReadonlyWhenFieldsMulti(invoiceFields, { amount: 999 }, [
80+
{ status: 'paid', amount: 100 },
81+
{ status: 'paid', amount: 200 },
82+
]);
83+
expect(out).toEqual({});
84+
});
85+
86+
it('drops the field when it is locked in AT LEAST ONE matched row (fail-safe for the batch)', () => {
87+
// One draft (unlocked) + one paid (locked). A single bulk payload cannot
88+
// write to the draft and skip the paid row, so the locked field is dropped
89+
// for the whole batch.
90+
const out = stripReadonlyWhenFieldsMulti(invoiceFields, { amount: 999 }, [
91+
{ status: 'draft', amount: 100 },
92+
{ status: 'paid', amount: 200 },
93+
]);
94+
expect(out).toEqual({});
95+
});
96+
97+
it('KEEPS the field when NO matched row locks it (legitimate bulk edit unaffected)', () => {
98+
const out = stripReadonlyWhenFieldsMulti(invoiceFields, { amount: 999 }, [
99+
{ status: 'draft', amount: 100 },
100+
{ status: 'sent', amount: 200 },
101+
]);
102+
expect(out).toEqual({ amount: 999 });
103+
});
104+
105+
it('keeps the field when the match set is empty (0 rows updated anyway)', () => {
106+
const d = { amount: 999 };
107+
expect(stripReadonlyWhenFieldsMulti(invoiceFields, d, [])).toBe(d);
108+
});
109+
110+
it('returns the same object when no readonlyWhen field is in the payload', () => {
111+
const d = { status: 'draft' };
112+
expect(stripReadonlyWhenFieldsMulti(invoiceFields, d, [{ status: 'paid' }])).toBe(d);
113+
});
114+
});
115+
60116
// #2948 — static `readonly:true` write enforcement (caller-supplied only).
61117
const stampedFields = {
62118
fields: {

packages/objectql/src/validation/rule-validator.ts

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,7 @@ export function stripReadonlyWhenFields(
183183
let result = data;
184184
for (const [name, def] of Object.entries(fields)) {
185185
if (!def?.readonlyWhen || !(name in data)) continue;
186-
const res = ExpressionEngine.evaluate<boolean>(toExpression(def.readonlyWhen), {
187-
record: merged,
188-
previous: previous ?? undefined,
189-
});
190-
if (!res.ok) {
191-
logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate — change allowed through`);
192-
continue;
193-
}
194-
if (res.value === true) {
186+
if (isReadonlyWhenLocked(def, merged, previous ?? undefined, name, logger)) {
195187
if (result === data) result = { ...data };
196188
delete (result as Record<string, unknown>)[name];
197189
logger?.warn?.(`Field '${name}' is read-only (readonlyWhen) — ignoring incoming change`);
@@ -200,6 +192,94 @@ export function stripReadonlyWhenFields(
200192
return result;
201193
}
202194

195+
/**
196+
* Evaluate one field's `readonlyWhen` predicate against a (merged) record.
197+
* TRUE ⇒ the field is locked for that record and the incoming change must be
198+
* dropped. A broken predicate is fail-open (returns `false` — the change is
199+
* allowed through), matching the strip's historical behaviour. Shared by the
200+
* single-id ({@link stripReadonlyWhenFields}) and bulk
201+
* ({@link stripReadonlyWhenFieldsMulti}) strips.
202+
*/
203+
function isReadonlyWhenLocked(
204+
def: ConditionalFieldDef,
205+
merged: Record<string, unknown>,
206+
previous: Record<string, unknown> | undefined,
207+
name: string,
208+
logger?: EvaluateRulesOptions['logger'],
209+
): boolean {
210+
const res = ExpressionEngine.evaluate<boolean>(toExpression(def.readonlyWhen!), {
211+
record: merged,
212+
previous,
213+
});
214+
if (!res.ok) {
215+
logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate — change allowed through`);
216+
return false;
217+
}
218+
return res.value === true;
219+
}
220+
221+
/**
222+
* True when the UPDATE payload writes at least one field that declares a
223+
* `readonlyWhen` predicate. A cheap gate the engine uses to decide whether the
224+
* bulk update path must fetch its matched rows for
225+
* {@link stripReadonlyWhenFieldsMulti} — no `readonlyWhen` field in the payload
226+
* ⇒ no fetch, no cost.
227+
*/
228+
export function hasReadonlyWhenInPayload(
229+
objectSchema: { fields?: Record<string, ConditionalFieldDef> } | undefined | null,
230+
data: Record<string, unknown> | undefined | null,
231+
): boolean {
232+
const fields = objectSchema?.fields;
233+
if (!fields || !data) return false;
234+
for (const [name, def] of Object.entries(fields)) {
235+
if (def?.readonlyWhen && name in data) return true;
236+
}
237+
return false;
238+
}
239+
240+
/**
241+
* Multi-row counterpart of {@link stripReadonlyWhenFields} (#3042). A bulk
242+
* `updateMany` applies ONE payload to every matched row, but `readonlyWhen` is a
243+
* PER-ROW predicate — a single merged prior is not enough. Given the matched
244+
* rows' prior state (the engine reads them with the SAME row-scoped AST the
245+
* write binds — one query), a `readonlyWhen` field is dropped from the batch
246+
* when its predicate is TRUE for AT LEAST ONE matched row: a bulk write cannot
247+
* lock the field for some rows and write it for others, so a field locked in any
248+
* target row is fail-safe-dropped for all (narrow the `where` to reach the rows
249+
* where it is unlocked). A field NO matched row locks is written normally — a
250+
* legitimate bulk edit of an unlocked conditional field is unaffected. A broken
251+
* predicate is fail-open for that row. INSERT is exempt (update path only),
252+
* symmetric with the single-id strip.
253+
*
254+
* Returns the same object when nothing is stripped, else a shallow copy with the
255+
* locked keys removed.
256+
*/
257+
export function stripReadonlyWhenFieldsMulti(
258+
objectSchema: { fields?: Record<string, ConditionalFieldDef> } | undefined | null,
259+
data: Record<string, unknown> | undefined | null,
260+
priorRows: ReadonlyArray<Record<string, unknown>> | undefined | null,
261+
logger?: EvaluateRulesOptions['logger'],
262+
): Record<string, unknown> | undefined | null {
263+
const fields = objectSchema?.fields;
264+
if (!fields || !data) return data;
265+
const rows = priorRows ?? [];
266+
let result = data;
267+
for (const [name, def] of Object.entries(fields)) {
268+
if (!def?.readonlyWhen || !(name in data)) continue;
269+
const lockedInSomeRow = rows.some((row) =>
270+
isReadonlyWhenLocked(def, { ...(row ?? {}), ...data }, row ?? undefined, name, logger),
271+
);
272+
if (lockedInSomeRow) {
273+
if (result === data) result = { ...data };
274+
delete (result as Record<string, unknown>)[name];
275+
logger?.warn?.(
276+
`Field '${name}' is read-only (readonlyWhen) in ≥1 matched row — ignoring incoming change on bulk update`,
277+
);
278+
}
279+
}
280+
return result;
281+
}
282+
203283
/**
204284
* Strip CALLER-SUPPLIED writes to statically `readonly: true` fields from an
205285
* UPDATE payload (#2948). Unlike `readonlyWhen` (conditional, handled above), a

0 commit comments

Comments
 (0)