fix(metadata-protocol): strip static readonly on INSERT at the data-write ingress (#3043)#3162
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 103 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…-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
force-pushed
the
claude/static-readonly-insert-exemption-xbjl5j
branch
from
July 18, 2026 04:59
cfe9cf9 to
23e36bc
Compare
readonly on INSERT too, not just UPDATE (#3043)readonly on INSERT at the data-write ingress (#3043)
os-zhuang
marked this pull request as ready for review
July 18, 2026 05:46
This was referenced Jul 18, 2026
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>
This was referenced Jul 18, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
关闭 #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-authadapter、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.create→callData→createData)、批量 import——而可信内部写入方(better-auth adapter、metadata repo、seed loader)直接调engine.insert,绕过此处。在入口拦截可一次性覆盖所有 caller/agent 路径(含 MCP,回应了 review 中的 agent 关切),又不误伤合法在创建时播种 readonly 列的内部写入方。关键性质
owner_id/organization_idstamp 稍后在engine.insert内注入),故服务端 stamp 不受影响。defaultValue(伪造的approval_status→draft,而非 NULL)。isSystem豁免;静默(HTTP 2xx);批量/import 逐行剥离;readonlyWhen保持 INSERT 豁免。managedBy或sys_命名空间)有各自的字段写入治理,静默剥离不得抢先——例如 ADR-0086 对sys_permission_set伪造managed_by:'package'/package_id是硬拒绝 403,安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004 拒绝伪造owner_id,而这些列本身是readonly;若在此剥离会把本应被拒绝的 payload 静默吞掉。安全/设计:静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种(比 #3003 少一步) #3043 的威胁面是 app 审批/状态字段,从不涉及sys_——这与applySystemFields的平台/自定义边界一致。行为变化(需评审确认)
非 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。showcase-static-readonly.dogfood.test.ts:非 system POST 伪造showcase_contact.lead_score端到端被剥离(不落库);同时two-doors-permission的 ADR-0086 provenance 伪造仍返回 403(平台对象 guard 未被抢先)。readonly-static-write行扩展到 INSERT 面(入口位置 + 平台对象豁免)。field.zod的readonlydescribe(+ 重新生成 reference doc)、spec liveness note 同步更新。本地验证
@objectstack/metadata-protocol全套 + 新入口单测;@objectstack/rest298 passed@objectstack/speccheck:docs(258 files in sync);相关包 build/typecheck 通过🤖 Generated with Claude Code