Skip to content

feat(auth): 身份表写守卫 — managedBy:'better-auth' 引擎级强制(ADR-0092 D2/D3/D6,closes #2816)#2828

Merged
os-zhuang merged 2 commits into
mainfrom
claude/sys-user-field-delegation-4p8l20
Jul 11, 2026
Merged

feat(auth): 身份表写守卫 — managedBy:'better-auth' 引擎级强制(ADR-0092 D2/D3/D6,closes #2816)#2828
os-zhuang merged 2 commits into
mainfrom
claude/sys-user-field-delegation-4p8l20

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

概要

ADR-0092 实现 A(#2816):managedBy: 'better-auth' 从"文档承诺"变为引擎真实边界。

  • identity-write-guard.ts — plugin-auth 在 kernel:ready 注册 before{Insert,Update,Delete} hook(priority 10,先于审计快照)。判定按 schema registry 的 managedBy === 'better-auth' 标志(无硬编码表清单);用户上下文写入 default-deny,403 PERMISSION_DENIED 且错误信息指路专用端点。better-auth adapter(无 context)与 isSystem 写入直通。
  • 白名单 registryregisterManagedUpdateWhitelist(object, fields) 是唯一开口;首条目 sys_user → {name, image}。名单外字段剥离;剥空抛错(响亮失败)。引擎盖章的生命周期列(updated_at/updated_by)直通但不计入白名单判定 —— 否则纯 email PATCH 会退化成时间戳触碰而非报错(dogfood 中发现并修复)。
  • sys-user-writable-fields.ts(D3)— 共享字段分层:档案白名单 {name, image} 与 import 超集 {+phone_number, role}(spread 派生防漂移);admin-import-users.tsUPDATE_ALLOWED_FIELDS 改为共享常量。
  • D6afterUpdate hook 镜像 better-auth refreshUserSessions:守卫放行的档案编辑后,按原 TTL 重写(绝不删除,避免误登出)secondary storage 中该用户的 {session, user} 快照。

验证

单测(新增 15 项,plugin-auth 全套 387 项通过):逐表用户上下文 insert/delete/update 拒绝、白名单剥离与空包抛错、system/无 context 直通、multi-update 过滤、生命周期列直通、D6 快照重写/无 storage no-op/storage 故障不破坏写入、字段分层超集关系。

真机验证--fresh CRM 后端 + seeded platform admin,curl):

操作 结果
PATCH sys_user name ✅ 200,持久化,updated_at 刷新
PATCH 仅 email ✅ 403 + "Editable fields: name, image" 指路
PATCH name+role+must_change_password ✅ 200,role/mcp 剥离未落库
POST / DELETE sys_user ✅ 403
/admin/create-user(临时密码 + mcp 盖章) ✅ 正常
/admin/set-user-password ✅ 正常
自助 /update-user ✅ 正常(better-auth 路径)
/admin/import-users upsert 更新 ✅ 正常(SYSTEM_CTX 直通)

注:/admin/ban-user 返回 better-auth 原生 YOU_ARE_NOT_ALLOWED_TO_BAN_USERS(stock 插件只认 role 标量,seeded admin role=user)—— 既有行为,与本守卫无关(错误码可区分)。

调用点扫描:全仓身份表写调用点(plugin-auth × 9、plugin-sharing × 2)全部已是 SYSTEM_CTX,无需迁移。

迁移说明(见 changeset)

以用户上下文直写身份表的服务端脚本需改 system context(真内部写)或走专用端点;flow/automation 以用户身份写非档案 sys_user 列的同样被过滤。

相关

ADR-0092(Accepted)· closes #2816 · 后续:#2817(affordance 翻转,依赖本 PR)

🤖 Generated with Claude Code

https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21


Generated by Claude Code

… the engine (#2816)

ADR-0092 D2/D3/D6 implementation:

- identity-write-guard.ts: before{Insert,Update,Delete} hooks (priority 10)
  registered by plugin-auth at kernel:ready. User-context writes to any
  object whose schema declares managedBy:'better-auth' are rejected
  fail-closed (403 PERMISSION_DENIED with a pointer to the dedicated
  surfaces); better-auth adapter (no context) and isSystem writes bypass.
  Per-object update whitelist registry is the only opening; engine-stamped
  lifecycle columns (updated_at/updated_by) pass through but never satisfy
  the whitelist alone.
- sys-user-writable-fields.ts: shared field tiers — profile edit whitelist
  {name, image} and the import superset {+phone_number, role}
  (subset-by-construction, ADR-0092 D3); admin-import-users.ts now derives
  UPDATE_ALLOWED_FIELDS from it.
- D6: afterUpdate hook mirrors better-auth's refreshUserSessions — cached
  {session, user} snapshots in secondary storage are re-written (same TTL,
  never deleted) with the changed profile fields after a guarded edit.

Verified against a live --fresh CRM backend: profile PATCH persists with
fresh updated_at; email-only PATCH 403s; role/must_change_password are
stripped and not persisted; insert/delete 403; create-user /
set-user-password / self-service update-user / import upsert all work
unchanged. Call-site sweep: every existing identity-table write already
runs under SYSTEM_CTX or the adapter path.

Closes #2816

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
@vercel

vercel Bot commented Jul 11, 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 11, 2026 3:53am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/dogfood, @objectstack/plugin-auth.

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

  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/getting-started/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx (via packages/dogfood)
  • content/docs/permissions/delegated-administration.mdx (via packages/dogfood)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

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.

The old test asserted POST /data/sys_team returns 201 — a raw data-API
path the product UI never exposes (sys_team's own schema gates every
team-mutation affordance to multi-org mode, and its actions name the
better-auth team endpoints as the canonical entry points). The identity
write guard now rejects that user-context insert by design.

The ADR-0057 regression target — organization_id optional at the schema
level so single-tenant writes don't VALIDATION_FAIL — is kept, asserted
through the writers that remain legitimate post-ADR-0092: system-context
engine writes. The user-context 403 (PERMISSION_DENIED) becomes explicit
coverage of the guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
@os-zhuang
os-zhuang marked this pull request as ready for review July 11, 2026 07:30
@os-zhuang
os-zhuang merged commit a348394 into main Jul 11, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/sys-user-field-delegation-4p8l20 branch July 11, 2026 07:30
os-zhuang pushed a commit that referenced this pull request Jul 11, 2026
… fields (ADR-0092 D4)

sys_user gains userActions:{edit:true} (create/import/delete stay off).
name/image are editable; email, email_verified, two_factor_enabled, role,
banned/ban_reason/ban_expires, ai_access, manager_id,
primary_business_unit_id and every system-managed stamp are marked
readonly so the standard edit form renders them non-editable.

Safe because the server boundary is the identity write guard (ADR-0092
D2, #2828): a user-context sys_user update may only touch {name, image}
regardless of what a form submits. The readonly flags are UX only.

Verified in the running Console: the object metadata sent to the client
carries userActions.edit=true and the correct per-field readonly flags;
the row 'Edit' action appears; the edit form surfaces only name/image;
and — the risk this issue flagged — the create_user/invite_user dialogs
keep email/role editable (action params do not inherit field readonly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
os-zhuang pushed a commit that referenced this pull request Jul 11, 2026
…ect FLS + bump objectui to sys_user edit-form fix (ADR-0092 D4)

Completes ADR-0092 D4 end-to-end (#2817). Three parts brought the standard
sys_user edit form to life for platform admins, verified in a running
Console (edit name → persisted; email/role stay read-only):

1. foldWildcardSuperUser in /me/permissions (hono-plugin): the per-object
   FLS map now folds the '*' modifyAll/viewAll super-user grant into every
   object entry, so the client mirrors the server's actual enforcement
   (checkObjectPermission is most-permissive, no deny-wins). Fixes the
   false-negative where a platform admin who also holds organization_admin
   (denies identity writes) saw sys_user.allowEdit:false and a disabled
   form, though the server accepts the write (PATCH → 200). ADR-0057 D10.
2. .objectui-sha bump to 5da9905 — pulls in objectui#2395 (ObjectForm
   honors userActions.edit instead of blanket-disabling managed objects).
3. sys_user userActions.edit + field readonly governance (earlier commit).

The identity write guard (#2828) still restricts the actual write to
{name, image}; these changes only correct which affordances the UI shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
os-zhuang pushed a commit that referenced this pull request Jul 11, 2026
…ission ∩ guard (ADR-0092 complete fix)

Completes the /me/permissions consistency fix. foldWildcardSuperUser
alone fixed the false-NEGATIVE (admin couldn't see sys_user editable)
but introduced a false-POSITIVE: it reported allowEdit:true for every
better-auth identity table, including ones the identity write guard
(#2828) actually blocks (sys_member, sys_account, sys_session, …).

clampManagedObjectWrites re-applies the guard's user-context policy: a
managedBy:'better-auth' object is writable only where it opted in via
userActions.{edit,create,delete} (sys_user → edit; the field readonly
flags then narrow it to {name,image}). Only better-auth objects are
clamped — system/config/append-only have no such guard, so their
permission-set result stands (admin CAN write them; must not under-report).

Net: the Console's per-object FLS now equals real server enforcement
(permission-set ∩ guard). Verified against a running stack: sys_user
allowEdit:true + form editable; sys_member/session/account allowEdit:false
(read preserved). +4 unit tests (8 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
os-zhuang added a commit that referenced this pull request Jul 11, 2026
…-0092 D4) (#2832)

Completes ADR-0092 D4 (closes #2817), verified end-to-end in a running
Console (edit name → persisted; email/role read-only):

- sys_user: userActions.edit + per-field readonly governance (name/image
  editable, everything else read-only).
- /me/permissions now reflects permission-set ∩ identity-write-guard:
  foldWildcardSuperUser fixes the false-negative (admin's '*' modifyAll
  shadowed by organization_admin's explicit identity deny), and
  clampManagedObjectWrites re-applies the guard's user-context policy so
  managed tables the guard blocks (sys_member/session/account) stay
  non-editable while sys_user (opted in) is editable. Fixes #2836.
- .objectui-sha bump pulls in objectui#2395 (ObjectForm honors
  userActions.edit instead of blanket-disabling managed objects).

The identity write guard (#2828) still restricts the actual write to
{name, image}; the UI now shows exactly what the server enforces.
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 size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(identity): ADR-0092 实现 A — 通用身份表写守卫(registry 驱动 default-deny + sys_user 档案白名单)

2 participants