Skip to content

fix(security): enforce static readonly fields on the UPDATE write path (#2948) - #2957

Merged
os-zhuang merged 1 commit into
mainfrom
claude/authz-2948-readonly-update
Jul 15, 2026
Merged

fix(security): enforce static readonly fields on the UPDATE write path (#2948)#2957
os-zhuang merged 1 commit into
mainfrom
claude/authz-2948-readonly-update

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

目的

关闭 #2948 —— 字段的静态 readonly: true 在服务端 update 时从未被强制:record-validator 只是把 readonly 列从校验里跳过,引擎也只剥离条件式 readonlyWhen从不剥离静态 readonly。因此一个非系统(用户上下文)的 update 可以覆盖任意 readonly 列——审计戳、provenance、系统计算值等。

#2946 已封堵跨租户 organization_id 面;本 PR 封堵更广的租内完整性面

改动

engine.update 现在对非 system 上下文剥离调用方提供的静态-readonly 字段写入,覆盖单条 + 多行两条路径(剥离而非拒绝,与 readonlyWhen 对称)。两道护栏保证合法写不受影响:

  • 仅剥离调用方提供的键 — 剥离基于「hooks/middleware 运行之前」快照的 caller keys,因此审计 hook 的服务端戳(updated_by/updated_at)与写 middleware 的戳存活;只有客户端显式伪造的 readonly 字段被丢弃。
  • system 上下文豁免isSystem 写(import / seed replay / approvals / lifecycle hook)合法地设置 readonly 列,跳过剥离。

新增 stripReadonlyFields helper(rule-validator.ts)+ 单元测试,以及引擎级集成测试(用户伪造被剥离、服务端戳存活、system 写放行)。

关键设计点(评审重点)

  • 执行顺序#2946 的 org_id 不可变拒绝跑在 plugin-security middleware(包住整个 body),在本剥离点之前触发——本剥离不会把那处 reject 降级为静默 strip。
  • 审计戳存活sys_stamp_audit_update hook 在 beforeUpdate(剥离点之前)写 updated_by;因它不在 caller-supplied 快照里,故被保留。可接受边界:客户端显式伪造 updated_by 时该请求不更新「最后修改人」——安全。

验证

  • @objectstack/objectql 全量:66 files / 852 tests passed
  • 新增单元 + 集成测试(rule-validator.test.ts / plugin.integration.test.ts)passed ✓
  • ESLint(改动文件)✓ · turbo build(objectql + deps)✓

changeset:@objectstack/objectql: minor(安全加固,非破坏——单组织及任何不伪造 readonly 列的写零变化)。

关联:#2946(org_id 跨租户面)· #2937 · ADR-0092(身份表写守卫)· tracking #2920

🤖 Generated with Claude Code

https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs


Generated by Claude Code

#2948)

A field's static `readonly: true` was never enforced server-side on update: the
record validator only skipped read-only columns from validation, and only the
conditional `readonlyWhen` variant was stripped from the write payload. A
non-system update could therefore overwrite any readonly column — audit stamps,
provenance, or other system-computed values. (#2946 already closed the
cross-tenant organization_id face; this is the broader in-tenant integrity face.)

engine.update now strips caller-supplied writes to statically-readonly fields
for non-system contexts, on both the single-id and multi-row paths (strips,
does not reject — symmetric with readonlyWhen). Two guards keep legit writes
intact:
- caller-supplied only: the strip runs against a snapshot of the keys the
  caller sent BEFORE hooks/middleware ran, so audit-hook stamps (updated_by/
  updated_at) and write-middleware stamps survive; only a client that forged a
  readonly field has it dropped.
- system-context exempt: isSystem writes (import, seed replay, approvals,
  lifecycle) legitimately set readonly columns and skip the strip.

Adds a stripReadonlyFields helper + unit tests and an engine-level integration
test (user forge stripped, server stamp survives, system write allowed).
objectql suite: 852 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 15, 2026 10:03am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectos/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)
  • content/docs/releases/v9.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 15, 2026 10:13
@os-zhuang
os-zhuang merged commit e0b049a into main Jul 15, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/authz-2948-readonly-update branch July 15, 2026 10:13
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Jul 17, 2026
…ix its declared contract (objectstack-ai#3003)

objectstack-ai#3003 reproduced the pre-objectstack-ai#2948 gap in production terms: approval/status/amount
columns "protected" only by static `readonly: true` were forged with one direct
same-session REST PATCH on a draft record, self-approving a 4-stage approval on
the released 15.0.0 (RECORD_LOCKED only guards pending flows, and the strip that
closes this — stripReadonlyFields, objectstack-ai#2957 — landed after that release cut). The
engine fix is already on main; what was still missing is everything that keeps
the contract from silently regressing or being misread again:

- dogfood proof (showcase-static-readonly.dogfood.test.ts, @proof:
  readonly-static-write): over real HTTP on the showcase app, a non-admin
  owner's PATCH forging `lead_score` returns 200 with the persisted value kept
  while sibling editable fields land, an all-forged payload is a no-op, and
  INSERT may still seed the column (the documented readonlyWhen-symmetric
  exemption).
- authz conformance row `readonly-static-write` (+ HIGH_RISK) so the proof is
  mandatory — deleting it fails CI, not review.
- ADR-0054 proof-registry class bound to `field/readonly`, so the liveness
  ledger's `live` classification must carry the proof.
- contract text: FieldSchema.readonly described itself as "Read-only in UI" —
  exactly the misreading objectstack-ai#3003 documents. The Zod description, the field
  liveness ledger (readonly + readonlyWhen), and the hand-written field docs
  now state the server-side strip (non-system UPDATE, insert exempt).

Closes objectstack-ai#3003.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants