Skip to content

sys_member.role app-role channel reverses ADR-0057 D4 ("never as the authority for RBAC") — pin to the built-ins before the next RC; placement is the governed channel #3723

Description

@os-zhuang

> Revision 2 (2026-07-28, amended the same day): the first cut of this revision corrected the record — this issue was closed by #3747 (merged 2026-07-28 02:44Z, the same minute), not without rationale, and the reopen comment missed it — and recommended keeping #3747's landed design. The amendment reverses that recommendation. Two things were put back on the table: ADR-0057 D4 already decided this exact question, and the governed replacement (invitation placement, ADR-0105 D8) has already shipped. Earlier framings are summarized in History.

Where this stands (audited against main, 2026-07-28)

  • The original symptom is fixed. fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747 built the one list (normalizeAdditionalOrgRoles → better-auth's role map and both select option lists; built-ins in @objectstack/spec as BUILTIN_MEMBERSHIP_ROLE_OPTIONS), dogfood-proven end to end (app-org-role-invite.dogfood.test.ts).
  • The "un-gated entrance" is no longer un-gated. Three gates: the invitation role cap (invitation-role-cap.ts:143-150 — below-admin issuers invite plain member only), fix(security): govern sys_member writes — membership is not a delegable capability (#3697 follow-up) #3767 (sys_member in GOVERNED_OBJECTS, tenant-admin-only, non-delegable), and better-auth's admin-grade ACL on update-member-role. Only tenant admins can hand out an app role, and they bypass D12 by design — the sub-admin escalation revision 1 was reopened for does not exist on main.
  • But the channel itself was made deliberate: plugin-auth/src/org-roles.ts:61-69 documents the projection into grants.positions as "the intended channel (it is why apps declare these roles)".

Already decided — the doctrine exists, and #3747 reverses it

ADR-0057 D4 (accepted; status table: ✅ landed):

> sys_member.role is reframed to org-administration only (owner/admin/member) […]
> Continue feeding declared role names to better-auth additionalOrgRoles […] only so invitations to those role names are accepted — never as the authority for RBAC.

Consequences: "The model becomes self-owned: RBAC no longer borrows better-auth's membership role; better-auth is cleanly confined to identity + org-administration."

ADR-0095 D3 (accepted): better-auth role='admin' is "demoted to one source that grants that position (the existing mapMembershipRole normalization becomes a grant-provisioning concern, not an enforcement-time input)", and "no enforcement-time code path may consult the better-auth role directly".

ADR-0105 D8 conforms: delegated_admin "carries NO ObjectStack authority by construction […] Role = can reach the endpoint; adminScope = what the endpoint permits."

Against that: the projection union in resolve-authz-context.ts:305-324 is ADR-0057 D4's transition window ("union sys_member.role during a transition window"), not a contract — mapMembershipRole's default passthrough is its residue. #3747 took the residue and promoted it to "the intended channel", storable app roles included: an accepted ADR reversed by a patch-level changeset, with no superseding ADR. Revision 1's "option 4" was never a new proposal — it is the standing recorded doctrine.

The channel is also redundant — the governed one-step flow already shipped

Scoped invitation placement (ADR-0105 D8, .changeset/scoped-invitation-placement.md): an invitation carries business_unit_id + positions; issuance is dry-run through DelegatedAdminGate against the very sys_user_position rows acceptance would write; acceptance applies idempotently, failure-isolated.

membership-role channel (#3747) placement channel (shipped)
Who can issue org owner/admin only (the cap holds delegates to member) admins and delegated admins, inside subtree + allowlist
What acceptance writes a role string on sys_member real sys_user_position rows
Audit / validity none — no granted_by, no ADR-0091 windows full assignment-grade audit
Scope checks none (its only issuers are admins, who bypass) subtree, allowlist, strict containment
ADR status reverses 0057 D4 implements 0105 D8

The governed path is a strict superset: more delegable, fully audited. The convenience channel's only unique behavior is storing a business-role label on the membership row — presentational, and derivable from positions.

Decision — default: enforce the ADR, before the next RC cut

.changeset/pre.json shows app-org-roles-storable is not consumed by any RC: cutting now removes a feature that never shipped. After the next RC cut it becomes a breaking removal. Plan:

  1. Pin sys_member.role / sys_invitation.role to the four built-ins — BUILTIN_MEMBERSHIP_ROLE_OPTIONS becomes the whole list. Keep fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747's one-list infrastructure; pinned, it is exactly "derive once the vocabulary is closed" from revision 1.
  2. Stop registering app names with better-auth; deprecate the additionalOrgRoles app-role feed (collectStackOrgRoles) with a boot warning pointing at placement. An invitation naming an app role then fails loudly at the door (ROLE_NOT_FOUND) with a migration pointer, instead of being stored as un-governed authority.
  3. Flip the dogfood (app-org-role-invite.dogfood.test.ts): the app-role invite is refused at the door; the same intent expressed as member + placement succeeds, governed.
  4. ADR note (short amendment, not new doctrine): affirm 0057 D4 post-fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747; name placement as the delegable capability channel; record the three-facts distinction (what names exist / which mean authority / how names project) so the next agent does not re-unify them.
  5. Derive the closed vocabulary: lint's MEMBERSHIP_TIERS (validate-approval-approvers.ts:109-117 — hand-maintained, carries guest, absent from BUILTIN_MEMBERSHIP_ROLES; verify which is right) and objectui's mirror (feat(console): make delegated_admin reachable and narrow both role pickers (framework#3697) objectui#2891) read the four names from @objectstack/spec.

Keeping #3747 instead would mean superseding ADR-0057 D4 with a new ADR — and with placement shipped there is no DX case left to make in it; what remains is a label the console can render from positions.

Standing review-checklist item either way: any new surface that writes sys_member.role (SCIM group mapping, a future direct-write API) must carry the invitation cap's logic — #3767's gate is isSystem-short-circuited for every actual writer.

History

Related: #3697, #3722, #3747 (the reversal), #3767, objectstack-ai/objectui#2891 · ADR-0057 D4 · ADR-0095 D3 · ADR-0105 D8.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions