RBAC matrix editor
Outcome
Custom roles are created with the right permission set; user-role assignments fire individual audit entries; effective scopes are visible before granting access.
Prerequisites
| Action | Permission | Granted to |
|---|---|---|
| View matrix + effective scopes | users.read | PLATFORM_ADMIN, EMR_CLIENT_ADMIN, EMR_CLIENT_VIEWER |
| Assign / revoke roles | users.update | PLATFORM_ADMIN, EMR_CLIENT_ADMIN |
| Create / clone / delete roles + replace permission sets | users.admin | PLATFORM_ADMIN, EMR_CLIENT_ADMIN |
| Read audit drawer | config.read | PLATFORM_ADMIN, CONFIG_MANAGER |
PLATFORM_ADMIN always passes the users.admin check via the
short-circuit in RbacService.hasPermission. The explicit grant to
EMR_CLIENT_ADMIN (migration 106) is the load-bearing one for tenant
operators.
When to use
- Onboarding a new tenant operator who needs a non-standard scope bundle (e.g. "Denials Analyst").
- Tightening or widening an existing custom role's permission set.
- Auditing a recent permission change before / after a HIPAA review or post-incident.
- Confirming what a user can see before signing them in (the
effective-scope drawer in
/admin/users).
Custom role lifecycle
Create:
/admin/rbac→ "+ New" → enter UPPER_SNAKE_CASE name (matches^[A-Z][A-Z0-9_]*$) + description. The role lands as non-system with an empty permission set.Clone: pick an existing role (system or custom) → "Clone" → choose a new name. Permissions are copied transactionally.
Edit description: only allowed on custom roles. System roles return 409
SYSTEM_ROLE_PROTECTED.Toggle permissions: checkboxes in the right pane fire
PUT /admin/roles/:id/permissionswith the full new set (idempotent replace). System roles' checkboxes are disabled client-side and the route still 409s if bypassed.Delete: custom roles only. 409 with
ROLE_IN_USEif anyuser_rolerow is active. The handler sweeps revoked tombstones in a transaction so the FK does not block the delete once active assignments are gone.
User assignment
/admin/users → Roles on a row opens the multi-select
UserRoleAssignDialog. The dialog computes a diff against the current
selection and dispatches one assign per added role and one revoke per
removed role — each one writes its own audit.access_log entry with
purpose='role.assign:<roleId>' or role.revoke:<roleId>.
The shield-icon button next to "Roles" opens the inline
EffectiveScopeDrawer. It hits
GET /admin/users/:id/effective-permissions, which de-dupes the union
of all permissions across the user's active user_role rows and
attaches origin chips so the operator can see which role is granting
each permission.
Audit drawer
The Audit button in the /admin/rbac header opens the
RbacAuditDrawer. It pulls the three RBAC entity types
(role, role_permission, user_role) from /admin/audit-log and
merges them client-side, sorted newest first and capped at 50 entries.
When a role is selected on the matrix, the role and role_permission
queries pre-filter to that role's accessed_entity_id so the drawer
only shows that role's lifecycle.
Break-glass
If the UI is unavailable, the same operations are reachable directly:
# Create a custom role.
curl -sS -X POST "$BASE/api/v1/admin/roles" \
-H 'content-type: application/json' \
-H "x-user-id: $ACTOR" -H "x-tenant-id: $TENANT_ID" \
-d '{"roleName":"DENIALS_ANALYST","description":"Denials only"}'
# Replace its permission set.
curl -sS -X PUT "$BASE/api/v1/admin/roles/$ROLE_ID/permissions" \
-H 'content-type: application/json' \
-d '{"permissionIds":["..."]}'
# Tail the audit log for that role.
psql -c "SELECT actor_user_id, access_action, purpose, accessed_at
FROM audit.access_log
WHERE accessed_entity_id = '$ROLE_ID'
ORDER BY accessed_at DESC LIMIT 50;"
Pitfalls
- Large deltas batch as multiple HTTP calls so each assign/revoke is independently audited. By design.
- Audit drawer uses
audit.access_log(canonical, hash-chained by migration 019). Do not reach forsecurity.access_log— the pre-S02 route accidentally read from the wrong table; the canonical column shape isactorUserId,accessedEntityType,accessedEntityId,accessAction,purpose,accessedAt. - Deleting a role with revoked tombstones is allowed once the
active assignment count is zero — the transactional sweep removes
user_role, thenrole_permission, then the role itself. Do not attempt this directly in SQL without the same ordering. chk_access_action(migration 017) constrainsaccess_actiontoREAD/WRITE/DELETE/EXPORT/PRINT. The RBAC-specific verb (role.assign:…,role.set_permissions, etc.) always lives inpurpose, never inaccess_action.
Validation
| Check | Expected |
|---|---|
| Custom role created | Visible on matrix; is_system=false |
| Permission toggle | Audit row written |
| Effective scope drawer | Lists permissions with origin chips |
| Delete attempt with active assignment | 409 ROLE_IN_USE |
Cross-references
- Audit log viewer for full audit history.
- Drill-through & per-widget RBAC for the dashboard's scope split.