feat(auth)!: membership grade is not a capability channel — close the sys_member.role vocabulary (ADR-0108, #3723) - #3802
Merged
Conversation
… `sys_member.role` vocabulary (ADR-0108, #3723) `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. One column was answering both. `resolve-authz-context` projects EVERY value stored in `sys_member.role` into `current_user.positions`, alongside the rows read from `sys_user_position`. A business role handed out through the membership role was therefore capability, granted with none of the position system'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 ever authorized the widening — it arrived as a bug fix (#3747) and was then made automatic in every host (#3779). The vocabulary is closed to the four framework-owned names: owner / admin / delegated_admin / member. - `additionalOrgRoles` removed from AuthManagerOptions and AuthPluginOptions, with `plugin-auth/src/org-roles.ts` in full and the `kernel:ready` derivation hook. A TypeScript error is the intended failure; a silently ignored option would be `declared != enforced` one more time. - The two `role` selects carry `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` and nothing else; nothing widens them at boot. - `MEMBERSHIP_ROLE_NAME_PATTERN` / `_MIN_LENGTH` dropped from spec — they existed only to validate app-supplied names. - lint's `MEMBERSHIP_TIERS` 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 and the lint whose job is to catch that stayed silent — fixed by construction, with a regression test. - `app-org-role-invite.dogfood.test.ts` is replaced by `membership-role-vocabulary.dogfood.test.ts`, which proves the vocabulary is closed, that an app name is refused at better-auth's door leaving no row, and that the same intent succeeds through ADR-0105 D8 placement. Migration (also in the changeset): - POST /organization/invite-member { email, role: 'sales_rep' } + POST /organization/invite-member { email, role: 'member', + businessUnitId, positions: ['sales_rep'] } Placement reaches further than what it replaces: it is authorized against the issuer's adminScope, so a delegated admin may use it within their subtree, where the membership-role route was open to org admins only. Both reversed changesets were unreleased, so no published version ever offered the behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186LhwkUBupmLJUUAMda5hU
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 9 package(s): 119 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
… ratchet) The role-word ratchet caught this PR's own prose growing the count it exists to shrink — in the docs for the change that closes the vocabulary, which is about as pointed as a lint failure gets. Every avoidable use is now "membership tier". What remains in the two touched files is the better-auth boundary itself (`sys_member.role`, the wire field in the invite sample), which D3 keeps as its documented exception. authentication.mdx lands at 4, below its baseline of 5, so the baseline ratchets down one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186LhwkUBupmLJUUAMda5hU
os-zhuang
marked this pull request as ready for review
July 28, 2026 06:43
…e-assessment-s09jvx
This was referenced Jul 28, 2026
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. Reverses #3747 and #3779, neither of which was consumed into a release.
The problem
sys_member.roleanswers "what is your standing in this organization". It does not answer "what may you do" — that is what positions are for. One column was answering both.resolve-authz-context.tsprojects every value stored insys_member.roleintocurrent_user.positions, alongside the rows read fromsys_user_position. So a business role handed out through the membership role was capability — granted with none of the position system's controls:sys_user_positionDelegatedAdminGate— BU-subtree anchoring,assignablePermissionSetsallowlist, strict containment,granted_by, ADR-0091 validity windowsys_member.roleWhy this is a revert, not a new opinion
This question already had an answer in three accepted ADRs:
additionalOrgRoleswith an explicit qualifier — feed the names to better-auth "only so invitations to those role names are accepted — never as the authority for RBAC". The projection above is what voided the qualifier.permission_set· distribution =position… The word 'role' does not exist here."sys_member.rolesurvives as third-party schema we do not own — an exception for a column we cannot rename, not a licence to build a distribution channel on it.No ADR authorized the widening. It arrived as a bug fix (#3747 made app names storable) and was then made automatic in every host (#3779,
kernel:readyself-derivation). ADR-0108 records the reversal so the next reader finds a decision rather than a silently reversed doctrine.Why now
.changeset/pre.jsonshows neitherapp-org-roles-storablenorauth-org-roles-self-derivedwas consumed into an RC, so no published version ever offered this behaviour. Cutting it now removes a feature that never shipped; after the next RC cut it would be a breaking removal of a live one.The replacement already shipped and reaches further
ADR-0105 D8 invitation placement is a strict superset:
membersys_membersys_user_positionrowsgranted_by+ ADR-0091 windowsWhat changed
owner/admin/delegated_admin/member. Bothroleselects declareBUILTIN_MEMBERSHIP_ROLE_OPTIONSstatically; nothing widens them at boot.additionalOrgRolesremoved fromAuthManagerOptions/AuthPluginOptions, withplugin-auth/src/org-roles.tsin full and thekernel:readyderivation hook. A TypeScript error is the intended failure; a silently ignored option would bedeclared ≠ enforcedone more time. Tombstone comments at both option sites carry the FROM → TO.MEMBERSHIP_ROLE_NAME_PATTERN/_MIN_LENGTHdropped from@objectstack/spec— they existed only to validate app-supplied names.api-surface.jsonregenerated.delegated_adminregistration is untouched — it is a grade (what you may reach), not capability.MEMBERSHIP_TIERSnow derives fromBUILTIN_MEMBERSHIP_ROLES. This fixed a live bug on contact: the hand-kept copy carriedguest, which the select has never offered, so an approver authored as{ type: 'org_membership_level', value: 'guest' }resolved to nobody and the lint whose whole job is to catch that stayed silent. Regression test added.authentication.mdx,positions.mdx,delegated-administration.mdx) with a callout for the removed path.Proof
app-org-role-invite.dogfood.test.tsproved the opposite and is replaced bymembership-role-vocabulary.dogfood.test.ts, which drives the real HTTP route and asserts:positionname is refused at better-auth's door, leaving no row — loud and early, not a 400 at the insert (fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747's symptom) and not silent success storing an ungoverned grant (what fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747 shipped);sys_member.rolerefuses the name on write too, so the ungoverned grant is unrepresentable at the table, not merely unreachable by route;Suites green after merging latest
main: spec 6736, plugin-auth 573, lint 472, runtime 661, platform-objects 239,delegated-admin-invite.dogfood4.check:api-surfaceand the ADR-0090 D3 role-word ratchet both clean.Downstream:
objectstack-ai/cloudaudited — no impact#3779 cited
cloud#897as its motivation, so cloud was audited directly atb168e94. Verdict: no compile-time or runtime break.//comment (apps/ee-group-showcase/test/group-posture.dogfood.test.ts:328). Bothnew AuthPlugin({...})sites —objectos-runtime/src/artifact-kernel-factory.ts:380andservice-cloud/src/control-plane-preset.ts:389— omitadditionalOrgRoles.role: 'owner'literals inpersonal-org-hook.ts:249,319, andenvironment-org-seed.ts:114whose parameter is already typed'owner' | 'admin' | 'member'. Cloud issues no invitations from production code, soROLE_NOT_FOUNDis unreachable. No role picker, no option-list read, no capability inferred fromsys_member.role(positions come fromsys_user_position).cloud#897is a different bug —KNOWN_METADATA_CATEGORIEShad been hand-copied and kept the pre-D3roleskey, sopositions[]was silently dropped on package publish/export andSecurityPluginhad nothing to seed intosys_position. It is now derived fromPLURAL_TO_SINGULAR. That fix is complementary to this PR: it makes declared positions survive into hosted environments, which is the channel this PR directs everyone to.role: 'member'+positions: [...]— the governed placement path, not the retired one.Two cosmetic follow-ups for cloud, neither blocking: the stale comment above, and two mock fixtures seeding
'viewer'/'guest'into fake drivers (harmless now; they would fail the closed select only if those tests were ever migrated to a real kernel).Reviewer notes
delegated_adminreachable and narrow both role pickers (framework#3697) objectui#2891). Now that the vocabulary is genuinely closed, that mirror can derive from@objectstack/spec— the remaining copy, tracked in ADR-0108's consequences.resolve-authz-context.ts:305-324is unchanged on purpose: with the vocabulary closed it can only ever see the four names. Narrowing it further is a separate change.🤖 Generated with Claude Code
https://claude.ai/code/session_0186LhwkUBupmLJUUAMda5hU