Skip to content

fix(auth): app-declared org roles are storable, not just registerable (#3723) - #3747

Merged
os-zhuang merged 6 commits into
mainfrom
claude/additionalorgroles-registration-mismatch-d9aez7
Jul 28, 2026
Merged

fix(auth): app-declared org roles are storable, not just registerable (#3723)#3747
os-zhuang merged 6 commits into
mainfrom
claude/additionalorgroles-registration-mismatch-d9aez7

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #3723.

问题

additionalOrgRoles 会把 stack 声明的每个 permission / position 名字注册进 better-auth 的 organization plugin,所以 POST /organization/invite-member { role: 'sales_rep' } 能通过角色校验 —— 然后写入失败,因为 sys_invitation.rolesys_member.role 是只列了 owner|admin|member 的封闭 select:

ValidationError: role must be one of: owner, admin, member
  { field: 'role', code: 'invalid_option', options: ['owner','admin','member'] }

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_ADMINeval-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 自己走一遍 stack 就是 issue 报的错配
@objectstack/verify harness 完全不传 唯一驱动真实 HTTP 路由的验证面,对这类 bug 失明
DevPlugin 完全不传 自称等价于完整栈,却把 app 角色排除在外
AuthPlugin 自身 直接吃调用方数组 未归一化

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;并保留反向对照 —— 未声明的角色仍被拒绝(字段没有被简单放开)。
  • 确认这是一道真闸门:临时移除物化步骤后重跑,5 条里 4 条失败,报的正是 issue 里那条 role must be one of: owner, admin, delegated_admin, member
  • org-roles.test.ts 24 条单测覆盖归一化 / label 优先级 / 选项构建 / copy-on-write / stack 遍历,含「静态定义只含内置角色」的防漂移断言;auth-manager.test.ts 新增一条:写不进去的名字也不会注册进 better-auth。
  • 16 个 CI check 全绿。本地 spec (6696) / plugin-auth (598) / cli (642) / lint (447) / platform-objects (223) / verify (7) / plugin-dev (7) / dogfood 全过;check:i18ncheck:role-wordcheck:api-surfacecheck:doc-authoring 均通过。
  • 全量 turbo test 跑了两轮,各有一个不同的包偶发失败(plugin-audit zh-CN 摘要、cloud-connection marketplace 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 侧单独立项。

…#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.
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 2:28am

Request Review

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

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 7 package(s): @objectstack/cli, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-dev, packages/qa, @objectstack/spec, @objectstack/verify.

114 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 packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @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/cli, 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 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/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.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/your-first-project.mdx (via @objectstack/cli, @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/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, 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/plugin-auth, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/plugin-auth)
  • 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/permissions/sso.mdx (via @objectstack/plugin-auth)
  • 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/plugin-auth, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-dev, @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/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • 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 @objectstack/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/cli, @objectstack/plugin-auth, @objectstack/spec, @objectstack/verify)
  • 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/v15.mdx (via @objectstack/verify)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth, @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/platform-objects, @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.

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.
@github-actions github-actions Bot added size/xl and removed size/l labels Jul 28, 2026
claude added 4 commits July 28, 2026 01:44
…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
os-zhuang marked this pull request as ready for review July 28, 2026 02:44
@os-zhuang
os-zhuang merged commit 558fe4d into main Jul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/additionalorgroles-registration-mismatch-d9aez7 branch July 28, 2026 02:44
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.
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/xl tests tooling

Projects

None yet

2 participants