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-zhuangos-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/speccheck:docs(258 files in sync);相关包 build/typecheck 通过

🤖 Generated with Claude Code

@vercel

vercelBot commented Jul 18, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 18, 2026 5:03am

Request Review

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

github-actionsBot 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-zhuangforce-pushed the claude/static-readonly-insert-exemption-xbjl5j branch from cfe9cf9 to 23e36bcCompareJuly 18, 2026 04:59
@os-zhuangos-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 mainJul 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude