You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(security): enforce readonlyWhen on the multi-row UPDATE path (#3042)
Conditional `readonlyWhen` field locks were stripped only on the single-id
UPDATE path (`stripReadonlyWhenFields`). The bulk `update({ multi: true, where })`
path enforced static `readonly` (#2948) but never `readonlyWhen` — an asymmetry
with both the single-id path and the static-readonly bulk strip. A programmatic
or embedded caller (or a plugin) issuing a multi-row update in a user context
could write a field its own `readonlyWhen` predicate should have locked.
Scope note: the external REST/SDK `updateMany` endpoint was already safe — it
loops single-id `engine.update` calls, each of which strips `readonlyWhen`. The
gap was the engine's true where-predicate multi path, reached programmatically
(embedded ObjectQL, plugins, internal services) — plus defense-in-depth for any
future route that wires a bulk where-based update.
engine.update now, on the multi-row path and only when the payload actually
writes a `readonlyWhen` field (cheap `hasReadonlyWhenInPayload` gate), reads the
row-scoped match set with the SAME composed AST the write binds (one query) and
drops any field whose predicate is TRUE for at least one matched row via the new
`stripReadonlyWhenFieldsMulti`. A single bulk payload cannot keep a field for
some rows and drop it for others, so a field locked in any target row is
fail-safe-dropped for the batch; a conditional field no matched row locks is
written normally (legitimate bulk edits unaffected). Symmetric with the single-id
strip; INSERT stays exempt. The per-field predicate eval is factored into a
shared `isReadonlyWhenLocked` helper used by both single-id and bulk strips.
Tests: rule-validator unit tests for `hasReadonlyWhenInPayload` and
`stripReadonlyWhenFieldsMulti` (locked-in-all / locked-in-one / locked-in-none /
empty-match / no-field-in-payload), plus an engine integration test driving the
real `objectql.update(..., { multi: true, where })` path (forge stripped when a
matched row is locked, sibling editable field survives, kept when no row locks
it, pre-read is row-scoped with the write's predicate). objectql suite: 898
passed.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
0 commit comments