概述
#2948/#3003 的引擎级 UPDATE readonly 剥离(engine.ts update 路径,!ctx.isSystem 时对 caller 提交的 readonly: true 字段剥离)会误伤 better-auth adapter 的写入。better-auth 的 adapter(plugin-auth/src/objectql-adapter.ts 的 withSystemReadContext)对读注入了 isSystem: true,但写(update/delete)是无上下文透传:
// withSystemReadContext
insert: (m, d) => e.insert(m, d), // 无 context
update: (m, d) => e.update(m, d), // 无 context ← 关键
...
find/findOne/count: e.find(m, asSystem(q)), // 有 isSystem
无 context 的 update 在引擎里命中 !opCtx.context?.isSystem(!undefined?.isSystem === true)→ 剥离生效。而 sys_user 的多个列声明为 readonly: true:email(line 499「change flows through better-auth change-email verification」)、banned/ban_reason/ban_expires(ADR-0092「written by the Ban User action」)等。因此 better-auth 经 adapter 对这些 readonly 列的写入会被静默剥离(HTTP 200 / 无报错,值不变——正是 #3003 式最难发现的静默形态)。
identity-write-guard.ts(ADR-0092 D2)会让 adapter 路径「passes untouched(其 engine 调用不带 caller context)」通过它自己的 guard,但紧接着引擎的 #2948 剥离仍会跑(它在 beforeUpdate hook 之后、按 suppliedKeys 剥离)——guard 放行 ≠ 剥离放行。
复现(机制级,已实证)
用真实引擎(ObjectQLPlugin,#2948 剥离在其 update 路径生效)+ capture 驱动,按 adapter 的确切调用形态 engine.update(model, { id, ...patch })(无第三参 / 无 context)更新 readonly 列:
ql.registry.registerObject({ name: 'ba_user', datasource: 'p', fields: {
name: { type: 'text' },
email: { type: 'text', readonly: true },
banned: { type: 'boolean', readonly: true },
}}, 'test', 'test');
await ql.update('ba_user', { id: 'rec-1', email: 'new@x.test', banned: true }); // 无 context = adapter 形态
// 驱动实际收到的 update payload:
// { id: 'rec-1', updated_at: '…' } ← email / banned 都被剥离
// email present? false
// banned present? false
dogfood HTTP 层未能直接触发用户可见症状:/auth/change-email 在 dev harness 返回 CHANGE_EMAIL_DISABLED,/auth/admin/ban-user 未挂载(404)。即在这些 better-auth 功能启用的部署里症状才会显现;机制在引擎层已确证。
影响
任何经 better-auth adapter 写入 sys_user(及其它 managedBy: 'better-auth' 表)readonly 列的运行时流程:change-email(验证通过后写 email)、admin ban/unban(banned/ban_reason/ban_expires)、以及未来任何 admin-plugin 对 readonly 列的写入。表现为「操作返回成功但数据没变」。
备选修复
- adapter 写入按对象走 system 上下文(推荐,最小):
withSystemReadContext 的 update/delete 也注入 isSystem(与其 find/findOne/count 已有的处理对称)。理由:better-auth 是身份权威,它对自管表的写入本就是 system 写入;identity-write-guard 已把 user-context 写入拦在门外,adapter 路径只有 better-auth 自身的写入。
- 需评估:使 adapter update 变 system 会同时绕过 FLS/owner-transfer 等——但对身份表的 better-auth 自写而言这是期望行为。
- 字段级 update 白名单扩展:复用
registerManagedUpdateWhitelist 思路,但那是给 user 写入开口的;adapter 是另一条路径,方案 1 更贴切。
判据 / 需核实
关联
概述
#2948/#3003 的引擎级 UPDATE readonly 剥离(
engine.tsupdate 路径,!ctx.isSystem时对 caller 提交的readonly: true字段剥离)会误伤 better-auth adapter 的写入。better-auth 的 adapter(plugin-auth/src/objectql-adapter.ts的withSystemReadContext)对读注入了isSystem: true,但写(update/delete)是无上下文透传:无 context 的 update 在引擎里命中
!opCtx.context?.isSystem(!undefined?.isSystem===true)→ 剥离生效。而sys_user的多个列声明为readonly: true:email(line 499「change flows through better-auth change-email verification」)、banned/ban_reason/ban_expires(ADR-0092「written by the Ban User action」)等。因此 better-auth 经 adapter 对这些 readonly 列的写入会被静默剥离(HTTP 200 / 无报错,值不变——正是 #3003 式最难发现的静默形态)。identity-write-guard.ts(ADR-0092 D2)会让 adapter 路径「passes untouched(其 engine 调用不带 caller context)」通过它自己的 guard,但紧接着引擎的 #2948 剥离仍会跑(它在 beforeUpdate hook 之后、按suppliedKeys剥离)——guard 放行 ≠ 剥离放行。复现(机制级,已实证)
用真实引擎(
ObjectQLPlugin,#2948 剥离在其 update 路径生效)+ capture 驱动,按 adapter 的确切调用形态engine.update(model, { id, ...patch })(无第三参 / 无 context)更新 readonly 列:影响
任何经 better-auth adapter 写入
sys_user(及其它managedBy: 'better-auth'表)readonly 列的运行时流程:change-email(验证通过后写email)、admin ban/unban(banned/ban_reason/ban_expires)、以及未来任何 admin-plugin 对 readonly 列的写入。表现为「操作返回成功但数据没变」。备选修复
withSystemReadContext的update/delete也注入isSystem(与其find/findOne/count已有的处理对称)。理由:better-auth 是身份权威,它对自管表的写入本就是 system 写入;identity-write-guard已把 user-context 写入拦在门外,adapter 路径只有 better-auth 自身的写入。registerManagedUpdateWhitelist思路,但那是给 user 写入开口的;adapter 是另一条路径,方案 1 更贴切。判据 / 需核实
change-email/ adminban启用的配置下补一条端到端证明(操作后email/banned实际落库)。readonlyon INSERT at the data-write ingress (#3043) #3162)对称:安全/设计:静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种(比 #3003 少一步) #3043 因同样发现「adapter/内部写入不带 isSystem」而最终把 INSERT 剥离改到了外部入口;本 issue 是 UPDATE 面的对偶——UPDATE 剥离在引擎级,故 adapter 写入直接撞上。关联
readonlyon INSERT at the data-write ingress (#3043) #3162)—— 探索本问题时发现