Skip to main content

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

ActionPermissionGranted to
View matrix + effective scopesusers.readPLATFORM_ADMIN, EMR_CLIENT_ADMIN, EMR_CLIENT_VIEWER
Assign / revoke rolesusers.updatePLATFORM_ADMIN, EMR_CLIENT_ADMIN
Create / clone / delete roles + replace permission setsusers.adminPLATFORM_ADMIN, EMR_CLIENT_ADMIN
Read audit drawerconfig.readPLATFORM_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

  1. 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.

  2. Clone: pick an existing role (system or custom) → "Clone" → choose a new name. Permissions are copied transactionally.

  3. Edit description: only allowed on custom roles. System roles return 409 SYSTEM_ROLE_PROTECTED.

  4. Toggle permissions: checkboxes in the right pane fire PUT /admin/roles/:id/permissions with the full new set (idempotent replace). System roles' checkboxes are disabled client-side and the route still 409s if bypassed.

  5. Delete: custom roles only. 409 with ROLE_IN_USE if any user_role row 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/usersRoles 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 for security.access_log — the pre-S02 route accidentally read from the wrong table; the canonical column shape is actorUserId, 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, then role_permission, then the role itself. Do not attempt this directly in SQL without the same ordering.
  • chk_access_action (migration 017) constrains access_action to READ/WRITE/DELETE/EXPORT/PRINT. The RBAC-specific verb (role.assign:…, role.set_permissions, etc.) always lives in purpose, never in access_action.

Validation

CheckExpected
Custom role createdVisible on matrix; is_system=false
Permission toggleAudit row written
Effective scope drawerLists permissions with origin chips
Delete attempt with active assignment409 ROLE_IN_USE

Cross-references

Next

8.3 — Audit log viewer