fix(auth): app-declared org roles are storable, not just registerable (#3723) - #3747
Merged
os-zhuang merged 6 commits intoJul 28, 2026
Merged
Conversation
…#3723) `additionalOrgRoles` registered every `permission` / `position` name a stack declared with better-auth's organization plugin, so `POST /organization/invite-member { role: 'sales_rep' }` passed the role check — and then the write failed, because `sys_invitation.role` and `sys_member.role` were closed selects listing `owner|admin|member` only: ValidationError: role must be one of: owner, admin, member { field: 'role', code: 'invalid_option' } A select is enforced on write and better-auth's own inserts are not exempt (they run through the ordinary ObjectQL validator, after the security middleware), so every deployment declaring role names was registering roles that could be requested and never stored — `declared ≠ enforced` one layer below the declaration. Contract-first fix: one list, materialized into both consumers. - `spec/identity/membership-role.ts` holds the built-in roles + their select options. `MEMBERSHIP_ROLE_DELEGATED_ADMIN` moves here from `eval-user.zod` (package-level export path unchanged). - `plugin-auth/org-roles.ts` owns the derivation: `normalizeAdditionalOrgRoles` is the single normalizer, and its output feeds better-auth's role map AND the two select option lists (`withMembershipRoleOptions`, stamped onto the manifest at registration — copy-on-write, the shared definitions are never mutated). Neither side keeps a list of its own. - `sys_invitation` / `sys_member` declare the built-in baseline only. - `collectStackOrgRoles` is the one producer-side walk; `objectstack serve` and the `@objectstack/verify` harness both use it. The harness passed no roles at all, which is why the surface that drives the real HTTP route was blind to this class of bug. A declared name that is not a valid machine name is now refused on BOTH sides with a boot warning, rather than registered and then stored mangled: `Field.select` strips characters outside `[a-z0-9_]`, so `showcase.export_data` would be accepted as itself and written as `showcaseexport_data` — the same mismatch with extra steps. Verified: `app-org-role-invite.dogfood.test.ts` drives the real invite route on the showcase stack and fails with the exact reported ValidationError when the materialization step is removed.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 7 package(s): 114 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
The positions page said `sys_member.role` was the "org-membership tier: owner/admin/member". That parenthetical was already one value short (`delegated_admin`, #3697) and this branch makes it materially wrong: every declared position / permission-set name is now a valid `sys_member.role` value, so someone can be invited straight into a position. Documents that path where the names are declared, with the two limits that bound it — the invitation role cap (an issuer below admin grade invites plain members only; a delegate's channel for capability is placement, not the membership role) and the machine-name requirement.
…oundary The previous commit put an "app roles" section on the positions page, which tripped `check:role-word` — the ADR-0090 D3 ratchet — by adding 11 uses of the reserved word to the one page whose thesis is that positions are NOT roles. The ratchet was right; raising its baseline there would have spent exactly what D3 bought. `positions.mdx` keeps only the factual correction to the tier list (the parenthetical was already missing `delegated_admin`, and app-declared names now belong in it), phrased to leave the file's word count unchanged at 6. The substance moves to `authentication.mdx` under Organizations — the better-auth boundary page, where `sys_member.role` is the sanctioned exception. Baseline for that file ratcheted 2 → 5: two `sys_member.role` mentions and the literal `"role"` JSON field in the invite sample.
…exports ADR-0059's public-API ratchet: an ADDED export still requires regenerating, so every change to the spec's third-party surface is deliberate. 16 additions across `.` and `./identity`, zero removals or narrowed signatures — and `MEMBERSHIP_ROLE_DELEGATED_ADMIN` stays present on both entry points, which is the snapshot confirming its move from `identity/eval-user.zod` to `identity/membership-role` is invisible to consumers.
The fourth host that boots AuthPlugin from a loaded stack, and the last one still walking past `additionalOrgRoles`. Unlike the pre-fix `serve` path this was not a mismatch — passing nothing left better-auth and the selects agreeing on the built-ins — but DevPlugin documents itself as equivalent to assembling the full stack by hand, and that equivalence quietly excluded every app-declared organization role. Now uses the same `collectStackOrgRoles(this.options.stack)` as `serve` and the verify harness. Guarded on the export being present: plugin-auth is a dynamic optional import here, so an older copy on disk yields none rather than throwing.
…d name
Deriving the option label by title-casing the machine name made a THIRD source
of truth for one string: a position declaring `{ name: 'exec', label:
'Executive' }` rendered as "Exec" in the picker, contradicting the very
metadata it came from. Worse for a non-English stack — a position labelled
`销售代表` came out as "Sales Rep".
Same one-list principle as the role set itself, applied to how it is displayed:
`collectStackOrgRoles` carries each entry's declared label through, and
`additionalOrgRoles` accepts `{ name, label }` alongside a bare name (a
widening — `string[]` callers are unaffected). Title-casing survives only as
the fallback for a host that had no label to offer.
Presentation only: better-auth is handed names via `orgRoleNames`, and the
stored value is always the name.
Pinned in the dogfood gate against a real difference — showcase's `exec`
declares `Executive`, which title-casing could never produce.
os-zhuang
marked this pull request as ready for review
July 28, 2026 02:44
os-zhuang
deleted the
claude/additionalorgroles-registration-mismatch-d9aez7
branch
July 28, 2026 02:44
This was referenced Jul 28, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 28, 2026
… `sys_member.role` vocabulary (ADR-0108, #3723) (#3802) `sys_member.role` answers "what is your standing in this organization". It does not answer "what may you do" — that is what positions are for. `resolve-authz-context` projects EVERY value stored in `sys_member.role` into `current_user.positions`, so a business role handed out through the membership role was capability, granted with none of ADR-0090 D12's controls: no `granted_by`, no ADR-0091 validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. ADR-0057 D4 ruled that out ("never as the authority for RBAC"), ADR-0090 D3's word ban restates it (distribution = `position`), and ADR-0095 D3 keeps the better-auth role out of the enforcement path. No ADR authorized the widening — it arrived as a bug fix (#3747) and was then made automatic in every host (#3779). The vocabulary is closed to owner / admin / delegated_admin / member. `additionalOrgRoles`, `org-roles.ts` and the `kernel:ready` derivation hook are removed; capability at admission time goes through ADR-0105 D8 invitation placement, which is governed and reaches further (a delegated admin may use it within their subtree, where the membership-role route was org-admin-only). Both reversed changesets were unreleased, so no published version ever offered the behaviour. Downstream `objectstack-ai/cloud` audited at b168e94: no compile-time or runtime impact. Also: lint's MEMBERSHIP_TIERS now derives from BUILTIN_MEMBERSHIP_ROLES. The hand-kept copy carried `guest`, which the select has never offered, so an approver naming it resolved to nobody while the lint whose job is to catch that stayed silent.
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.
Closes #3723.
问题
additionalOrgRoles会把 stack 声明的每个permission/position名字注册进 better-auth 的 organization plugin,所以POST /organization/invite-member { role: 'sales_rep' }能通过角色校验 —— 然后写入失败,因为sys_invitation.role和sys_member.role是只列了owner|admin|member的封闭 select:select 在写入时是强制生效的,而 better-auth 自己的 insert 并不豁免(它们同样走 ObjectQL 校验器,校验器排在安全中间件之后,
isSystem上下文不起作用)。所以任何声明了角色名的部署,注册的都是「能被请求、永远存不下来」的角色 ——declared ≠ enforced(Prime Directive #10),就发生在声明的下一层。方案
Issue 里列了三个选项,这里走的是 (2) —— 契约优先(Prime Directive #12):一份清单,物化进两个消费方。不是把两个字段改成自由文本(那会丢掉 picker 的选项列表和写入侧的护栏),也不是只加 lint(那能把不一致喊出来,但 app 角色照样不可用)。
spec/identity/membership-role.ts—— 内置角色常量 + 它们的 select options。MEMBERSHIP_ROLE_DELEGATED_ADMIN从eval-user.zod挪到这里(包级导出路径不变,api-surface 快照确认 0 breaking)。plugin-auth/org-roles.ts—— 派生逻辑的唯一归属。normalizeAdditionalOrgRoles是唯一的归一化入口,它的输出同时喂给 better-auth 的 roles map 和两个 select 的 options(withMembershipRoleOptions,注册 manifest 时物化,copy-on-write)。两边都不再各自持有清单,自然不可能一边接受、另一边拒绝。sys_invitation/sys_member只声明内置基线(BUILTIN_MEMBERSHIP_ROLE_OPTIONS),app 角色启动时追加。collectStackOrgRoles是唯一的生产侧遍历。四个宿主,一个遍历
AuthPlugin从 stack 启动的地方有四处,其中两处从来没传过角色:objectstack serve@objectstack/verifyharnessDevPluginAuthPlugin自身harness 那处是要害:#3722 的单测能证明 roles map 构建正确,却仍然发布了一个不可用的角色,正是因为唯一能发现它的那层自己没开这个功能。
注意 harness / DevPlugin 两处不是错配(两边都只有内置角色,一致),而是功能缺席 —— 更安静,所以更该修。
角色 label 走声明值
title-case 机器名会制造第三处真相:position 声明了
{ name: 'exec', label: 'Executive' },picker 却渲染成Exec,和它自己的元数据打架;非英文栈更糟,销售代表会变成Sales Rep。collectStackOrgRoles把声明的 label 带出来,additionalOrgRoles放宽为接受string | { name, label }(向后兼容,string[]调用方不受影响)。better-auth 经orgRoleNames只拿名字,存储值永远是 name;label 纯展示,title-case 降级为「宿主没提供 label 时」的兜底。想做多语言覆盖仍走翻译包 —— 运行时追加的选项会被 i18n resolver 按 value 查询(
objects.sys_member.fields.role.options.<name>),这条路径已写进文档。一处行为变化
不符合机器名规范(
/^[a-z][a-z0-9_]*$/,最少 2 字符)的声明名,现在两边都不注册,并在启动时告警。Field.select会把[a-z0-9_]之外的字符剥掉,所以showcase.export_data会被原样注册、却被存成showcaseexport_data—— 名字对得上、值对不上,是同一个 bug 换了个写法。拒绝之后,邀请会在入口处以ROLE_NOT_FOUND明确失败,而不是拖到 insert。通过SnakeCaseIdentifierSchema的名字不受影响。关于能力通道(未扩大范围)
让 app 角色可存储之后,
mapMembershipRole会把它投射进current_user.positions,同名的sys_position_permission_set绑定就会解析出权限集 —— 这正是 app 声明这些角色的目的。签发侧的封顶不变:invitation-role-cap.ts仍然把 admin 级以下的签发者限制为只能邀请member。Issue 中提到的「owner/admin 签发者仍可授予 app 角色」这一通道保持原样,未在此扩大。验证
packages/qa/dogfood/test/app-org-role-invite.dogfood.test.ts—— 在 showcase stack 上驱动真实邀请路由:position 名(contributor)与 PermissionSet 名(showcase_manager)两条声明路径都能邀请成功、两个 select 选项一致、成员行能持有同一角色、picker 显示Executive而非Exec;并保留反向对照 —— 未声明的角色仍被拒绝(字段没有被简单放开)。role must be one of: owner, admin, delegated_admin, member。org-roles.test.ts24 条单测覆盖归一化 / label 优先级 / 选项构建 / copy-on-write / stack 遍历,含「静态定义只含内置角色」的防漂移断言;auth-manager.test.ts新增一条:写不进去的名字也不会注册进 better-auth。check:i18n、check:role-word、check:api-surface、check:doc-authoring均通过。turbo test跑了两轮,各有一个不同的包偶发失败(plugin-auditzh-CN 摘要、cloud-connectionmarketplace seed);两者单独重跑及干净工作树上重跑均通过,与本改动无关(都不涉及成员角色)。文档
positions.mdx的成员层级列表原本就漏了delegated_admin,本改动又让它漏了 app 角色,已修正(措辞控制在 ADR-0090 D3 的 role-word 配额内,未动该页基线)。展开说明放在authentication.mdx的 better-auth 边界页,基线 2 → 5。后续(已立项,不在本 PR)
objectstack-ai/cloud#897 ——
ArtifactKernelFactory是第五个宿主,同样不传additionalOrgRoles,即托管环境下 app 角色依然不可邀请(功能缺席,非错配)。已核实并附修复方向与两个待确认前提。那个 issue 里也提了更根本的一点:五个宿主里三个曾经忘了传,说明按宿主分发本身就是缺陷模式 —— 每加一个 embedder 就多一次静默失效的机会。更耐久的形状是让
AuthPlugin自己从 metadata service 派生,宿主什么都不用传;建议在 framework 侧单独立项。