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(objectql): enforce static readonly on INSERT too, not just UPDATE (#3043)
#2948/#3003 made static `readonly: true` fields server-enforced on UPDATE (a
non-system PATCH forging `approval_status: 'approved'` is silently stripped),
but INSERT was deliberately exempt. For approval/status/verdict columns that
exemption was the SHORTER attack: instead of the #3003 draft-then-PATCH move, a
non-system caller could POST a record already `approval_status: 'approved'` in
one step — and the UPDATE-only strip never reached it.
`stripReadonlyFields` now runs on the INSERT path as well, symmetric with the
UPDATE strip:
- Caller-supplied only: the keys the caller sent are snapshotted BEFORE the
security middleware auto-stamps owner_id / organization_id, so those server
stamps (and any beforeInsert-hook write to a read-only column) survive.
- Before defaults: the strip runs before applyFieldDefaults, so a stripped
field falls back to its declared defaultValue (a forged approval_status
re-seeds to `draft`, not NULL).
- System-context exempt: imports, seed replay, migrations and lifecycle hooks
carry isSystem and still seed read-only columns.
- Silent (HTTP 2xx), per-row on batch inserts. `readonlyWhen` stays
INSERT-exempt (a conditional lock needs a prior record).
Behavior change: a non-system INSERT can no longer seed a readonly column from
the caller's payload — flows that legitimately write read-only columns at
creation (import, migration, programmatic seeding) must run system-context, the
same requirement the UPDATE strip already imposed.
Contract + proof surfaces updated: field.zod `readonly` describe, spec liveness
note, public-form server-managed-fields rationale, authz-conformance matrix row
(readonly-static-write), engine #3043 integration suite, and the
showcase-static-readonly dogfood proof (insert-forge now stripped end-to-end).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5fRY4ctobCeFpBba1oGrz
note: 'Surface posture: system (trusted-implicit), pre-wiring — no end-user transport exists (handleUpgrade unimplemented, no REST subscribe route, client RealtimeAPI is a placeholder); the only subscribers are server-internal plugins (webhook auto-enqueuer, knowledge sync). Structural defect: Subscription carries no principal, matchesSubscription filters only by object+eventTypes (RealtimeSubscriptionOptions.filter is declared but never read), and the engine publishes the FULL after-row — so any future external subscriber would receive record bodies cross-tenant that its own find would hide. ADMISSION REQUIREMENT before any WebSocket/SSE/subscribe transport ships: per-recipient RLS/FLS/tenant re-check on delivery (subscription carries the subscriber ExecutionContext) OR id-only payload + client re-fetch. The transport tripwire probes in authz-conformance.test.ts turn a wired transport into an UNCLASSIFIED surface → red CI until this row is upgraded with the enforcement site.'},
{id: 'readonly-static-write',summary: 'static `readonly: true` stripped from non-system UPDATE payloads (#2948 / #3003 — a direct PATCH cannot forge approval/status/amount columns the UI never renders)',state: 'enforced',
95
-
enforcement: 'objectql/engine.ts update — stripReadonlyFields on both the single-id and multi-row paths (caller-supplied keys only, so audit-hook/middleware server stamps survive; isSystem exempt; symmetric with the readonlyWhen strip)',
94
+
{id: 'readonly-static-write',summary: 'static `readonly: true` stripped from non-system UPDATE (#2948 / #3003) AND INSERT (#3043) payloads — neither a direct PATCH nor a direct POST can forge approval/status/amount columns the UI never renders',state: 'enforced',
95
+
enforcement: 'objectql/engine.ts — stripReadonlyFields on the update single-id + multi-row paths (#2948) and on the insert path (#3043, before applyFieldDefaults so a stripped field re-seeds to its defaultValue); caller-supplied keys only (insert snapshots them before the security middleware stamps owner/tenant), so server stamps survive; isSystem exempt; symmetric with the readonlyWhen strip',
note: 'The #3003 field report: `readonly: true` used to be UI-only, so a logged-in non-admin self-approved a 4-stage approval (approval_status/approval_stage/confirmed_total) with one same-session REST PATCH on a draft record — RECORD_LOCKED only guards pending flows, and the draft never entered one. The strip is SILENT (HTTP 200, persisted value kept — reject-vs-strip decided in #2948 for readonlyWhen symmetry). INSERT is deliberately exempt (create may seed a readonly column: defaultValue, import, migration), also symmetric with readonlyWhen. Engine-level unit/integration proof in objectql/plugin.integration.test.ts (#2948 suite: forge stripped, server stamp survives, system context allowed).'},
97
+
note: 'The #3003 field report: `readonly: true` used to be UI-only, so a logged-in non-admin self-approved a 4-stage approval (approval_status/approval_stage/confirmed_total) with one same-session REST PATCH on a draft record — RECORD_LOCKED only guards pending flows, and the draft never entered one. #3043 is the INSERT face: the same non-admin could skip the draft entirely and POST a record already `approval_status:"approved"` — a step SHORTER than #3003, and one the UPDATE strip never reached. The strip is SILENT on both paths (HTTP 2xx, forged value dropped; a stripped INSERT field falls back to its defaultValue). `readonlyWhen` stays INSERT-exempt (a conditional lock needs a prior record). System-context writes (import, seed replay, migration) still seed readonly columns. Engine-level unit/integration proof in objectql/plugin.integration.test.ts (#2948 UPDATE suite + #3043 INSERT suite: forge stripped, default re-seeded, server stamp survives, system context allowed, batch rows covered).'},
0 commit comments