diff --git a/.changeset/docs-3003-readonly-server-enforced.md b/.changeset/docs-3003-readonly-server-enforced.md new file mode 100644 index 0000000000..ae46cf9667 --- /dev/null +++ b/.changeset/docs-3003-readonly-server-enforced.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): `readonly` is server-enforced on UPDATE, not a UI-only affordance (#3003) + +The `readonly` field property was described as "Read-only in UI", which #3003 +proved to be exactly how integrators read it — approval/status/amount columns +protected only by `readonly: true` were forged with a direct REST `PATCH`, +self-approving a multi-stage approval on the released 15.0.0. Since #2948 the +engine strips caller-supplied writes to statically-readonly fields from every +non-system UPDATE (single-id and multi-row, symmetric with `readonlyWhen`; +INSERT may still seed the column). The schema description and the field +liveness ledger now state the server-side contract, and a dogfood conformance +proof (`showcase-static-readonly.dogfood.test.ts` + an authz-matrix row) pins +it end-to-end so it cannot silently regress to renderer-only. diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index bfa4d1075f..44770458f8 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -280,7 +280,7 @@ These properties are available on all field types: | `description` | `string` | — | Developer documentation | | `inlineHelpText` | `string` | — | Help text shown in UI | | `hidden` | `boolean` | `false` | Hide from default views | -| `readonly` | `boolean` | `false` | Prevent editing | +| `readonly` | `boolean` | `false` | Prevent editing — hidden from create/edit forms AND server-enforced: non-system `UPDATE` writes to the field are silently dropped (insert may still seed it) | | `sortable` | `boolean` | `true` | Allow sorting by this field | | `group` | `string` | — | Group name for organizing in forms (e.g. `'billing'`) | diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index a6d011e76c..c437e1c1ec 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -27,7 +27,7 @@ These properties apply to **all** field types and are validated by the base `Fie | `unique` | `boolean` | `false` | Enforces database-level uniqueness constraint | | `multiple` | `boolean` | `false` | Stores value as array (applicable for select, lookup, file, image) | | `hidden` | `boolean` | `false` | Excluded from default UI rendering | -| `readonly` | `boolean` | `false` | Blocks user edits in UI forms | +| `readonly` | `boolean` | `false` | Blocks user edits in UI forms; server-enforced on update — a non-system write to the field is silently dropped from the payload (insert exempt) | | `sortable` | `boolean` | `true` | Whether field appears in list view sort options | | `index` | `boolean` | `false` | Creates standard database index | | `externalId` | `boolean` | `false` | Marks field as external ID for upsert operations | diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index d12b0a4dfb..48fa69beb3 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -77,6 +77,10 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.' }, { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, + { 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', + 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)', + proof: 'showcase-static-readonly.dogfood.test.ts', + 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).' }, // ── ADR-0057 — ERP authorization core (enforced + e2e proven) ────────── { id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced', diff --git a/packages/dogfood/test/authz-conformance.test.ts b/packages/dogfood/test/authz-conformance.test.ts index d79545b15f..c52d897c46 100644 --- a/packages/dogfood/test/authz-conformance.test.ts +++ b/packages/dogfood/test/authz-conformance.test.ts @@ -80,6 +80,10 @@ const HIGH_RISK = [ // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the // same object data as REST `/data` through a sibling entry point. 'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data', + // #2948/#3003 — write-integrity face: without the strip, `readonly: true` + // is false compliance (declared ≠ enforced) and approval/status columns are + // one direct PATCH away from self-approval. + 'readonly-static-write', ]; describe('ADR-0056 D10 — authorization conformance matrix', () => { diff --git a/packages/dogfood/test/showcase-static-readonly.dogfood.test.ts b/packages/dogfood/test/showcase-static-readonly.dogfood.test.ts new file mode 100644 index 0000000000..17f05fd442 --- /dev/null +++ b/packages/dogfood/test/showcase-static-readonly.dogfood.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// @proof: readonly-static-write +// +// #2948 / #3003 — static `readonly: true` is SERVER-enforced on UPDATE, not a +// UI-only affordance. The #3003 field report: an approval-flow object declared +// `approval_status` / `approval_stage` as `readonly: true`, the create/edit +// forms never rendered them — and a logged-in, non-admin user forged all of +// them (plus an amount column) with one direct REST PATCH from the same +// session, self-approving a 4-stage approval. The strip added for #2948 +// (`stripReadonlyFields`, objectql/engine.ts) closes exactly that: on a +// non-system UPDATE, caller-supplied writes to statically-readonly fields are +// silently dropped (HTTP 200, persisted value kept) — symmetric with the +// `readonlyWhen` strip. INSERT is deliberately exempt (a create may seed a +// readonly column: defaultValue, import, migration), matching `readonlyWhen`. +// +// Proven here on the REAL showcase app over HTTP: `showcase_contact.lead_score` +// is the stand-in for the #3003 approval/status/amount columns — readonly, +// "computed by scoring rules — not user-editable", never on the create form. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJ = '/data/showcase_contact'; +const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; +const recordOf = (b: any) => b?.record ?? b?.data ?? b; + +describe('showcase: static readonly write enforcement (#2948 / #3003)', () => { + let stack: VerifyStack; + let token: string; + let contactId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); + token = await stack.signUp('ro-worker@verify.test'); + + // INSERT exemption (documented contract, symmetric with `readonlyWhen`): + // a create MAY seed a readonly column — the scoring pipeline, an import, + // or a migration legitimately writes the initial value. + const created = await stack.apiAs(token, 'POST', OBJ, { + name: 'Readonly Probe', + email: 'ro-probe@verify.test', + lead_score: 10, + }); + expect(created.status).toBeLessThan(300); + contactId = idOf(await created.json()); + expect(contactId).toBeTruthy(); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + it('INSERT may seed the readonly field (documented exemption)', async () => { + const res = await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`); + expect(res.status).toBe(200); + expect(recordOf(await res.json()).lead_score, 'insert-seeded value persisted').toBe(10); + }); + + it('a direct PATCH forging the readonly field is silently stripped — sibling editable fields still land', async () => { + // The #3003 move: same logged-in session, straight to the REST API with a + // payload the UI would never produce. + const forge = await stack.apiAs(token, 'PATCH', `${OBJ}/${contactId}`, { + lead_score: 99999, + notes: 'legitimate edit in the same payload', + }); + // The strip is SILENT by contract (like `readonlyWhen`): 200, not 4xx. + expect(forge.status, 'strip is silent — the request succeeds').toBe(200); + + const after = recordOf(await (await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`)).json()); + expect(after.lead_score, 'forged readonly value must NOT persist').toBe(10); + expect(after.notes, 'editable field from the same payload still lands').toBe( + 'legitimate edit in the same payload', + ); + }); + + it('a PATCH carrying ONLY the forged readonly field is a no-op on the record', async () => { + const forge = await stack.apiAs(token, 'PATCH', `${OBJ}/${contactId}`, { lead_score: -1 }); + expect(forge.status).toBe(200); + + const after = recordOf(await (await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`)).json()); + expect(after.lead_score, 'readonly value survives an all-forged payload').toBe(10); + }); +}); diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 04f45b96b9..a7454d941d 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -74,7 +74,8 @@ }, "readonlyWhen": { "status": "live", - "note": "renderer CEL." + "evidence": "packages/objectql/src/validation/rule-validator.ts", + "note": "renderer CEL + server write path: stripReadonlyWhenFields drops UPDATE writes when the predicate is TRUE against {...previous, ...data} (B2); INSERT exempt." }, "visibleWhen": { "status": "live", @@ -86,7 +87,9 @@ }, "readonly": { "status": "live", - "note": "renderer." + "evidence": "packages/objectql/src/validation/rule-validator.ts", + "proof": "packages/dogfood/test/showcase-static-readonly.dogfood.test.ts#readonly-static-write", + "note": "renderer + server write path: stripReadonlyFields drops non-system UPDATE writes to the field (#2948/#3003 — was renderer-only, i.e. false compliance for approval/status columns); INSERT exempt, symmetric with readonlyWhen." }, "hidden": { "status": "live", diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 9581104567..2c28ab87dc 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -143,6 +143,19 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ // a declaration whose sweeper stopped running is dead surface (ADR-0049). ledgerBindings: [{ type: 'object', path: 'lifecycle' }], }, + { + id: 'readonly-static-write', + label: 'Static readonly write enforcement', + summary: + 'a statically `readonly: true` field cannot be forged by a non-system UPDATE — the write is stripped server-side, not merely hidden by the form (#2948/#3003: approval/status/amount columns "protected" only by readonly were one direct PATCH away from self-approval).', + proofId: 'readonly-static-write', + proofRef: 'packages/dogfood/test/showcase-static-readonly.dogfood.test.ts#readonly-static-write', + bound: true, + // `readonly` was renderer-only until #2948 — declared ≠ enforced is exactly + // the false-compliance class ADR-0049 closes. The proof pins the server-side + // strip (forge dropped, sibling edit lands, insert exempt) over real HTTP. + ledgerBindings: [{ type: 'field', path: 'readonly' }], + }, { id: 'form-widget', label: 'Form layout / section / widget', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index ab0260152f..2162afd463 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -104,6 +104,7 @@ describe('registry invariants', () => { expect([...BOUND_PROOF_PATHS.keys()].sort()).toEqual( [ 'field/type', + 'field/readonly', 'flow/nodes.type', 'flow/runAs', 'permission/rowLevelSecurity.using', diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 4b950998c7..b2b379beab 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -613,7 +613,7 @@ export const FieldSchema = lazySchema(() => z.object({ /** Security & Visibility */ hidden: z.boolean().default(false).describe('Hidden from default UI'), - readonly: z.boolean().default(false).describe('Read-only in UI'), + readonly: z.boolean().default(false).describe('Read-only — never editable in forms, AND server-enforced on UPDATE: a non-system write to this field is silently dropped from the payload (#2948/#3003; symmetric with `readonlyWhen`). INSERT may still seed it (defaultValue, import).'), /** * [ADR-0066 D3] Capabilities required to READ/EDIT this field. A field