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

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 15, 2026 10:03am

Request Review

@github-actionsgithub-actionsBot 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 mainJul 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).
Closesobjectstack-ai#3003.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude