RBAC Permission Enforcement — Permissions as a Role's Resource-Scope Set, Replacing Both Admin-Scope Gates
Context
The RBAC schema (V005: roles / permissions / user_roles / role_permissions) ships with seed data but has zero enforcement — no business code reads role_permissions to make an authorization decision (only drogon_ctl-generated ORM boilerplate references it). docs/domains/rbac-guide.md:11 self-describes the model as "simplified to role-based URL interception". The 2026-10 IAM gap analysis called this an architectural misleading (evaluators read the schema and assume a working permission engine).
What actually gates admin operations today are two parallel, independent chains:
- DB-column chain:
OAuth2Pluginstartup loads theoauth2_scopes.requires_admin_rolecolumn into an injected predicate (OAuth2Plugin.cc:566-585);ScopeDecisionEngine.cc:31-36consumes it (scopeRequiresAdmin(scope) && !hasAdminRole → admin_role_required). The engine itself never reads the column, andAuthorizationService.cc:42-56keeps a hardcodeddefaultAdminScopes()fallback set used only when the DB has none. - Hardcoded chain:
IdentityService::scopeRequiresAdminRole(IdentityService.cc:227-240) writes a literal set{admin, admin:read, admin:write, user:manage, settings:manage}(with prefix matching), consumed byvalidateUserRolesForScopes(:188-224) and forwarded fromOAuth2Plugin.cc:1254-1256.
Because both chains require the literal admin role, a delegated administrator — e.g. a "helpdesk" role that may only read users — cannot be expressed at all. Decision D2 (2026-10-10, roadmap deliverables/iam-improvement-roadmap-2026-10.md §8) chose enforcement over freeze/remove, with an "integrate, don't duplicate" constraint.
Decision
- Semantics: a
permissionsrow is re-purposed as the right of a role to hold a resource scope. A permission's name IS a resource scope name (users:read,clients:write,audit:read, …) — the vocabulary already seeded byV023__resource_scopes.sql. No new permission grammar is invented; the fine-grained authorization surface remains the OAuth2 scope model (scope catalog +ResourceScopeRegistry(path,method)→scope + consent), exactly as today.role_permissionsbecomes "which resource scopes ride on this role". - Integration — both chains, not one: the P1 implementation replaces the admin check in each chain with the same lookup:
- Chain 1: the predicate injected into
ScopeDecisionEnginebecomes "does the user hold, via ANY of their roles, a permission whose name equals the requested scope (oradmin, which remains a super-scope implying every resource scope)". - Chain 2:
IdentityService::scopeRequiresAdminRole+ its literal set are deleted;validateUserRolesForScopesconsults the same permission lookup. Skipping chain 2 would leave delegated admins with inconsistent verdicts depending on which path evaluated the request (the failure mode the 2026-10-10 review flagged).
- Chain 1: the predicate injected into
- Compatibility seed: a migration grants the
adminrole every resource scope present in the catalog, so admin behavior is byte-identical before/after; therequires_admin_rolecolumn and the hardcoded set are both marked deprecated (column dropped no earlier than v2.0 after a deprecation window). - Unlocked capability — delegated administrators: custom roles can be granted a subset (e.g. helpdesk =
users:readonly). The admin console's Roles page gains a role→scope-set assignment UI. Acceptance (P1-8): a fresh "helpdesk" role with onlyusers:readcan list users and nothing else; theadminrole behaves exactly as before. - api-diff constraint:
IMfaRepository-style interface widening is not permitted in a minor; the lookup lives behind the existingIRoleProvider/IAuthorizationServiceseams (extend the concreteStorageRoleProvider/AuthorizationService, or add a new small port incommonif a new seam is unavoidable — additive only).
Non-goals (explicitly out of scope for P1-8)
- Role hierarchies, role→client bindings, and org-scoped roles (revisit after P1-8 lands; roadmap §11 I12).
- ABAC / a general policy engine.
- Changing what
ResourceScopeRegistrymaps or how consent works.
Consequences
role_permissionsstops being dead schema and becomes the delegated-admin mechanism;rbac-guide.mdis rewritten to the three-layer model (role = coarse, scope = fine, role→scope-set = the bridge).- The two deprecated gates (
requires_admin_rolecolumn,IdentityServiceliteral set) shrink to no-op shims for one release, then are removed. - Implementation is tracked as roadmap work package P1-8 (v1.6.0); this ADR is the design contract that issue/PR references.