Appearance
0038 — Multi-Role User Authorization ​
Status: Accepted
Date: 2026-06-23
ADO work item: AB#4386
Deciders: Kristopher Turner (platform owner)
Supersedes (partial): The "Multi-role assignment per user. Rejected." section of ADR 0037.
Extends: ADR 0006 (two-plane RBAC), ADR 0037 (role expansion)
Context ​
ADR 0037 (AB#4348) explicitly rejected multi-role RBAC in favor of a single User.role scalar, on the grounds that multi-role assignment adds complexity to authorization checks, session handling, and audit logging.
In practice this decision has not held. Two concrete scenarios emerged during the platform foundation build:
A platform engineer who is also a community member needs to hold both
infra_adminandmembersimultaneously. With a single-role scalar, assigninginfra_adminremoves the person's membership standing — they lose access to community content that an ordinary member can reach.A community member who volunteers as a media manager and also teaches a homeschool class needs
media_steward+homeschool_teacheralongsidemember. Assigning any single feature role strips the others.
The UserRole join table already exists in schema.prisma with the correct structure: (userId, roleId, assignedById, assignedAt, isActive). This table was included in the schema during the platform design phase but is not yet used as the source of truth for authorization middleware — the middleware currently reads from User.role (the scalar column). The structural groundwork for multi-role is already present; only the middleware and API contract must change.
The closed nature of this community (small membership, minister-controlled approval, known participants) makes the risk of multi-role misuse substantially lower than it would be in a public-facing product. The combination space of thirteen defined roles held by a small number of trusted users is manageable.
Decision ​
UserRole is the authorization source of truth ​
UserRole rows for a given user, filtered to isActive = true, define that user's effective role set. The User.role scalar is kept as a convenience column for the migration period and for backward-compatible queries (e.g., analytics, seeding) but is not read by the authentication middleware for authorization decisions.
AuthContext.roles is an array ​
The request context object populated during step 4 of the middleware pipeline changes from:
AuthContext.role: AppRole // beforeto:
AuthContext.roles: AppRole[] // afterHandlers that previously checked ctx.role === 'admin' must be updated to use the helper functions described below. No handler should read AuthContext.roles directly and perform its own intersection logic — that logic lives in middleware helpers only.
requireRole(minRole) semantics ​
requireRole(minRole) evaluates as: the maximum ordinal permission level across all of the user's active roles is greater than or equal to the permission level of minRole. Feature roles (non-ordinal, level 0) do not contribute to this maximum; they are evaluated separately via requireAnyRole().
Example: a user holding ['member', 'infra_admin'] passes requireRole('admin') because max(2, 7) = 7 >= 5.
requireAnyRole(allowedRoles) semantics ​
requireAnyRole(allowedRoles) evaluates as: the intersection of the user's active role set and the allowedRoles array is non-empty. This is unchanged in semantics; only the source changes from User.role (scalar) to AuthContext.roles (array).
Example: a user holding ['member', 'media_steward'] passes requireAnyRole(['media_steward', 'admin']) because {'member','media_steward'} ∩ {'media_steward','admin'} = {'media_steward'}.
Role-assignment endpoint contract ​
The admin role-assignment endpoint changes from a scalar overwrite to add/remove operations on UserRole rows:
| Before | After |
|---|---|
PATCH /users/:id/role with { role: 'admin' } | POST /users/:id/roles with { roleId } (add) |
DELETE /users/:id/roles/:roleId (remove) |
The scalar overwrite endpoint is deprecated; it remains functional during a transition window but will be removed in a follow-on migration.
/me contract amendment ​
GET /me returns roles: AppRole[] (array) in addition to the deprecated role: AppRole (the highest-ordinal role, kept for client backward compat during transition). Once all clients are updated, the scalar role field will be removed from the /me response.
FEATURE_ROLES set remains non-hierarchical ​
Feature roles (media_steward, comms_author, homeschool_*, highschool_student) remain non-hierarchical and do not influence requireRole() evaluations. This is unchanged from ADR 0037. The change is purely in how ordinal roles combine — they now contribute to a maximum, not a single value.
Explicitly supported combination ​
A single user holding infra_admin + member is explicitly supported and is the expected assignment for platform engineers who are also community members.
Alternatives considered ​
Keep single User.role, handle combinations via special-case roles. Rejected. This approach would require a new combined role (e.g., infra_member) for every needed combination, which does not scale and pollutes the role namespace.
Role groups / permission sets rather than multi-role. Rejected. The role set is small and stable (thirteen roles, none expected to exceed twenty). A group layer adds indirection without benefit at this scale.
Multi-role in session token only (no DB change). Rejected. The UserRole table already exists in the schema. Expressing multi-role only in the token without a DB record would break the audit trail and make role revocation unreliable.
Consequences ​
Breaking changes ​
- Schema migration required.
UserRolemust be fully populated for all existing users. For each existing user, oneUserRolerow is inserted matching their currentUser.rolescalar.User.roleis then marked deprecated in application comments. - Middleware updated.
requireRoleandrequireAnyRolemiddleware files are rewritten to read fromAuthContext.roles[]. - Admin UI. The role-assignment panel becomes a multi-select control rather than a single-select dropdown.
/meresponse.roles: AppRole[]is added.role: AppRole(scalar) is retained during transition.
Behavioral ​
- A user holding
infra_admin+ any ordinal role continues to pass all hierarchical checks, unchanged. - A user holding multiple feature roles is admitted by any
requireAnyRole()guard that includes any of those feature roles. - The
ADMIN_BOOTSTRAP_EMAILSbootstrap path must insert aUserRolerow for the first admin in addition to settingUser.role.
Audit ​
Every UserRole insert and delete writes an AuditLog row. This requirement is unchanged from ADR 0037; only the shape of the operation is different (insert/delete vs. scalar update).
Positive ​
- A single operator account can represent both infrastructure-level and community-level access without requiring two separate accounts.
- Feature role combinations (e.g.,
media_steward+homeschool_teacher) are supported without a schema or role-model change. - The join table that was already in the schema is now used as intended.
Negative / trade-offs ​
- More rows to read per request (one
UserRolelookup per user per request vs. one column read). Impact is negligible at the expected membership scale and the lookup is already present in the pre-ADR-0038 middleware (step 4). - The deprecated scalar
User.rolecolumn must be kept in sync during the transition period, adding one write per role-assignment operation. - Admin UI developers must understand the multi-select model; a mis-click that adds an unintended role is harder to spot than a dropdown change.
References ​
- ADR 0006 — Two-plane RBAC with reconciled role model
- ADR 0037 — RBAC role expansion
- AB#4386 — Multi-Role RBAC ADR and implementation story
database/schema.prisma—UserRolemodel (userId,roleId,assignedById,assignedAt,isActive)apps/api/src/middleware/requireRole.ts— to be updatedapps/api/src/middleware/requireAnyRole.ts— to be updatedapps/api/src/lib/auth.ts—AuthContexttype definition