Skip to content

Commit ca54449

Browse files
authored
docs(adr): ADR-0057 addendum — D4 reverse role→users path still reads sys_member.role (#2326)
The role→users expansion (approvals expandRoleUsers, sharing teamGraph.expandRoleUsers) terminates in sys_member.role and never unions sys_user_role, contradicting D4's source-of-truth model on the reverse direction. Document it as an incomplete/phased migration (not intentional design, not an approvals-only bug — plugin-sharing, an in-scope consumer, behaves identically), with the follow-up decision to finish the union in both plugins and the type:'field'+mapping workaround until then.
1 parent 7db9a73 commit ca54449

1 file changed

Lines changed: 65 additions & 0 deletions

File tree

docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,68 @@ gate reports the service absent, persist when present, fail-open with no gate, a
543543
requiresObject entries are untouched. An end-to-end Setup-nav test is not feasible in the
544544
verify harness: the platform Setup app's navigation is not materialized there (`/meta/app`
545545
lists only the business app; `/meta/app/setup` returns a protection stub).
546+
547+
---
548+
549+
## Addendum (2026-06-25): D4's reverse path — `role → users` expansion still reads `sys_member.role`
550+
551+
> This addendum records a follow-up gap found while adjudicating an approval-routing
552+
> question (can an approval `role` approver resolve a tenant RBAC role assigned via
553+
> `sys_user_role`?). It **extends, and does not revise**, D4. D4 stands: `sys_user_role`
554+
> is the platform-owned source of truth for role assignment.
555+
556+
### The gap
557+
558+
D4 made `sys_user_role` the source of truth and reframed `sys_member.role` to
559+
better-auth org-administration (owner/admin/member), and it migrated the **forward**
560+
direction — *given a user, which roles do they hold?* — into `ExecutionContext.roles`
561+
as the documented transition-window union `sys_user_role ∪ sys_member.role`
562+
(`resolve-execution-context`).
563+
564+
The **reverse** direction — *given a role, which users hold it?* — was **not** migrated.
565+
Both consumers that need it still terminate in `sys_member.role` only, and neither unions
566+
`sys_user_role`:
567+
568+
- **plugin-approvals**`expandApprovers` dispatches a `role` approver to
569+
`expandRoleUsers` (`approval-service.ts:306`), which queries `find('sys_member', { filter: { role } })`
570+
(`approval-service.ts:380-389`; the doc comment at `:286` even pins the semantics to
571+
"`sys_member.role = value`"). No `sys_role` / `sys_user_role` read.
572+
- **plugin-sharing** — a `role` recipient resolves via `teamGraph.expandRoleUsers`
573+
(`role-graph.ts:100`, `team-graph.ts:144`), whose terminal query is
574+
`find('sys_member', …)` (`team-graph.ts:70-80`; comment `:25` "`sys_member.role` for
575+
tenant role expansion"). Sharing is *partially* further along — its
576+
`RoleHierarchyGraph` walks `sys_role.parent` to find subordinate roles
577+
(`role-graph.ts:57`) — but the final role→**user** hop is still `sys_member`.
578+
579+
Note `plugin-approvals` is **not** in this ADR's Consumers list; `plugin-sharing` **is**.
580+
That both behave identically is the key signal below.
581+
582+
### Consequence (measured against the D4 target model)
583+
584+
A user who holds an RBAC role **only** through `sys_user_role` (the source-of-truth table)
585+
and was never written into better-auth's `sys_member.role` is **invisible** to approval
586+
routing and to sharing-rule role expansion. Against D4's stated target this is a **defect**:
587+
the source of truth is bypassed on the reverse path. It is **not** an intentional decision
588+
that "approval/sharing roles mean org-membership roles" — no such decision is recorded —
589+
and it is **not** an approval-specific accident, since the in-scope sibling
590+
`plugin-sharing` exhibits the same behaviour. The accurate characterisation is an
591+
**incomplete / phased migration**: D4 shipped the forward path under a transition-window
592+
union and left the reverse-lookup consumers on the legacy table.
593+
594+
### Decision (follow-up, does not revise D4)
595+
596+
1. Finish D4 on the reverse path: the `role → users` expansion must read the source of
597+
truth, i.e. `sys_user_role ∪ sys_member.role` (the same union semantics D4 already
598+
applies to `ctx.roles`), scoped by `organization_id` where present. Apply it in **both**
599+
`plugin-approvals` `expandRoleUsers` and `plugin-sharing` `teamGraph.expandRoleUsers`
600+
so the two stay consistent.
601+
2. When `sys_member.role` is eventually retired from the union (transition window closes),
602+
retire it in the reverse path at the same time — track it as one migration, not two.
603+
3. Until (1) lands, the supported pattern for multi-entity, post-routed approvers
604+
(e.g. four-level approval by 岗位/职务 across plants) is a `type: 'field'` approver fed
605+
by a materialised 岗位→人 mapping (a submit-time hook writing `division.*_user`), which
606+
sidesteps the reverse-lookup path entirely and gives 绑岗位不绑人 / 按工厂路由 / 换人不改流程.
607+
608+
Proof obligation when (1) is implemented: a regression test asserting that a user assigned
609+
a role **only** via `sys_user_role` (no `sys_member.role` row) is returned by both
610+
`expandRoleUsers` paths.

0 commit comments

Comments
 (0)