Skip to content

fix(metadata-protocol): strip static readonly on INSERT at the data-write ingress (#3043)#3162

Merged
os-zhuang merged 1 commit into
mainfrom
claude/static-readonly-insert-exemption-xbjl5j
Jul 18, 2026
Merged

fix(metadata-protocol): strip static readonly on INSERT at the data-write ingress (#3043)#3162
os-zhuang merged 1 commit into
mainfrom
claude/static-readonly-insert-exemption-xbjl5j

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

背景

关闭 #3043#2948/#3003 让静态 readonly: true 字段在非 system 的 UPDATE 上被引擎服务端剥离(伪造 approval_status: 'approved' 的 PATCH 被静默丢弃),但 INSERT 被有意豁免。对审批/状态/判定类字段,这个豁免其实是更短的一条攻击路径:攻击者无需像 #3003 那样先建 draft 再 PATCH,可以直接 POST 一条已经 approval_status: 'approved' 的新记录——只需一步,且 UPDATE 侧的剥离完全拦不到。

方案(经两轮讨论定型)

最初尝试在引擎 insert() 里剥离(与 #2948 对称),但发现 blast radius 远超计划预估:better-auth adapter、metadata-repo核心框架写入方都用非 system 上下文经 engine.insert 播种 readonly 列(email/凭据、event_seq/id),被剥离后 dev-admin 登录失败、元数据事件日志 NOT NULL 崩溃——core/platform 里约 40+ 处同类调用点。

因此改为在外部数据写入入口 DataProtocol 剥离(createData / createManyData / batchData / cloneData)。这是每个外部程序化 create 的唯一汇聚点——REST CRUD、GraphQL/MCP dispatcher(bridge.createcallDatacreateData)、批量 import——而可信内部写入方(better-auth adapter、metadata repo、seed loader)直接调 engine.insert,绕过此处。在入口拦截可一次性覆盖所有 caller/agent 路径(含 MCP,回应了 review 中的 agent 关切),又不误伤合法在创建时播种 readonly 列的内部写入方。

关键性质

行为变化(需评审确认)

非 system 经数据 API(REST/GraphQL/MCP/import)对自定义对象的 create,不再能从 payload 播种 readonly 列。需要在创建时写只读列的合法流程(导入历史 provenance、迁移、程序化 seed)必须走 system 上下文——与 UPDATE 侧剥离早已施加的要求相同

测试 / 证明

  • 入口单测 metadata-protocol/src/protocol.readonly-insert.test.ts(6 用例):非 system 伪造被剥离 + 默认值回落;system 上下文放行;无 context 默认非 system 剥离;createManyData/batchData 逐行剥离;平台对象(sys_/managedBy)不剥离,交由其自有 guard。
  • dogfood HTTP 证明 showcase-static-readonly.dogfood.test.ts:非 system POST 伪造 showcase_contact.lead_score 端到端被剥离(不落库);同时 two-doors-permission 的 ADR-0086 provenance 伪造仍返回 403(平台对象 guard 未被抢先)。
  • authz-conformance 矩阵 readonly-static-write 行扩展到 INSERT 面(入口位置 + 平台对象豁免)。
  • 契约面:field.zodreadonly describe(+ 重新生成 reference doc)、spec liveness note 同步更新。

本地验证

  • ✅ 全量 dogfood 套件:294 passed / 3 skipped,0 failed(含 sign-in、权限、provenance、readonly 各面)
  • @objectstack/metadata-protocol 全套 + 新入口单测;@objectstack/rest 298 passed
  • @objectstack/spec check:docs(258 files in sync);相关包 build/typecheck 通过

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 18, 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 18, 2026 5:03am

Request Review

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

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, packages/qa, @objectstack/spec.

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

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/cli.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via packages/qa, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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.

…-write ingress (#3043)

#2948/#3003 made static `readonly: true` fields server-enforced on UPDATE (a
non-system PATCH forging `approval_status: 'approved'` is silently stripped in
the engine), but INSERT was 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.

The strip now also runs on INSERT, but at the EXTERNAL data-write INGRESS
(DataProtocol.createData / createManyData / batchData / cloneData) rather than in
the engine. That seam is the single point every external programmatic create
funnels through — the REST CRUD route, the GraphQL/MCP dispatcher (bridge.create
→ callData → createData), and bulk import — while TRUSTED internal writers
(better-auth's adapter, the metadata repository, the seed loader) call
engine.insert directly and bypass it. Enforcing at the ingress protects every
caller/agent path at once without stripping the internal writers that
legitimately seed read-only columns on create (identity provisioning, provenance
stamps, event-log cursors) — the blast radius an engine-level insert strip would
have had.

- Caller-forged only: at the ingress the payload is raw caller input (owner/tenant
  stamps land later inside engine.insert), so only keys the caller sent are
  dropped.
- Re-derives the default: a stripped field falls back to its declared defaultValue
  in the engine (a forged approval_status becomes `draft`, not NULL).
- System-context exempt; silent (HTTP 2xx); per-row on batch/import. readonlyWhen
  stays INSERT-exempt (a conditional lock needs a prior record).
- Author-defined business objects only: platform objects (managedBy set, or the
  sys_ namespace) carry their own field-write governance that a silent strip must
  not pre-empt — ADR-0086 REJECTS (403) a forged managed_by/package_id on
  sys_permission_set, #3004 rejects a forged owner_id; several of those columns
  are readonly. The #3043 threat is app approval/status fields, never sys_ — the
  same boundary applySystemFields uses for ownership.

Behavior change: a non-system create through the data API (REST / GraphQL / MCP /
import) can no longer seed a readonly column on an author-defined object. Flows
that legitimately write read-only columns at creation must run system-context,
the same requirement the UPDATE strip already imposes.

Proof: metadata-protocol protocol.readonly-insert.test.ts (forge stripped, default
re-derived, system exempt, batch per-row, platform objects deferred to their
guards) + the showcase-static-readonly dogfood test (insert-forge stripped e2e
over HTTP). Contract surfaces updated: field.zod readonly describe (+regenerated
reference), spec liveness note, authz-conformance matrix row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5fRY4ctobCeFpBba1oGrz
@os-zhuang
os-zhuang force-pushed the claude/static-readonly-insert-exemption-xbjl5j branch from cfe9cf9 to 23e36bc Compare July 18, 2026 04:59
@os-zhuang os-zhuang changed the title fix(objectql): enforce static readonly on INSERT too, not just UPDATE (#3043) fix(metadata-protocol): strip static readonly on INSERT at the data-write ingress (#3043) Jul 18, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review July 18, 2026 05:46
@os-zhuang
os-zhuang merged commit beaf2de into main Jul 18, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/static-readonly-insert-exemption-xbjl5j branch July 18, 2026 05:46
os-zhuang added a commit that referenced this pull request Jul 18, 2026
…3164) (#3177)

The better-auth ObjectQL adapter wrapped the engine so its READS carried
`isSystem` (to bypass the control-plane org-scope read hook) but its WRITES
passed through with no context. The static-`readonly` UPDATE strip (#2948) runs
on any non-system update, and since the adapter carries no caller context
`!ctx?.isSystem` was true — so the strip SILENTLY DROPPED better-auth's own
writes to readonly `sys_user` columns: `email` (change-email), `banned` /
`ban_reason` / `ban_expires` (admin ban). Those operations returned success but
never persisted.

Rename `withSystemReadContext` → `withSystemContext` (deprecated alias kept one
release) and inject `isSystem` on insert/update/delete as well as reads. Correct
because these are the identity authority's own writes: user-context writes to
`managedBy: 'better-auth'` tables are already rejected upstream by the ADR-0092
identity write guard, so this path only ever carries better-auth's internal
writes.

Found while implementing #3043 (the INSERT-side readonly strip) — this is its
UPDATE-side dual. Also corrects content/docs/data-modeling/fields.mdx, which
still said "insert may still seed it" (stale after #3043 / PR #3162): a readonly
column is now server-enforced on INSERT too, and seeding one at create requires
a system context.

Verified: plugin-auth suite 460 passed (adapter writes now assert isSystem);
dogfood auth/identity/permission regression green (sign-in, org/member reads,
permission seeding, two-doors provenance).


Claude-Session: https://claude.ai/code/session_01P5fRY4ctobCeFpBba1oGrz

Co-authored-by: Claude <noreply@anthropic.com>
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 protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants