Roles & Permissions UX
The admin interface provides a visual, matrix-based approach to managing roles and permissions. Instead of scrolling through 40+ individual checkboxes, administrators work with a resource-by-action grid that makes permission patterns immediately visible.
Permission Matrix
The PermissionMatrix component is the foundation of the roles UX. It renders a grid where rows are resources (hosts, users, teams, credentials, etc.) and columns are actions (read, write, delete, manage, etc.). Each cell is a checkbox toggle (interactive mode) or a colored indicator (read-only mode).
Features
- Row header click — toggle all actions for a resource (e.g., enable all host permissions)
- Column header click — toggle all resources for an action (e.g., enable read on everything)
- Top-left corner — select all / deselect all
- Non-existent combinations — shown as a dash (e.g., there is no
audit:write) - Read-only mode — omit the
onChangeprop to render a static view
The matrix is used in four places:
| Location | Mode | Purpose |
|---|---|---|
| Role create/edit dialog | Interactive | Select permissions for a role |
| Role detail page | Read-only | Display role permissions at a glance |
| Access audit page | Read-only | Show expanded permission details per assignment |
| Role assignment hover | Read-only | Preview permissions before assigning a role |
Technical Details
Permissions follow the resource:action format (e.g., hosts:read, credentials:write). The extractResourceActions() helper in lib/permissions.ts parses the full permission list into { resources, actions, permSet } for the grid. Actions are sorted with the core CRUD trio first — read, write, delete — followed by any remaining actions (e.g. manage, acknowledge, access) in their original discovery order.
Role Form Dialog
The create/edit role dialog uses an interactive PermissionMatrix instead of grouped checkboxes. The dialog is wider (sm:max-w-2xl) to accommodate the grid. System roles render in read-only mode with a notice banner explaining they cannot be modified.
Role Detail Page
The role detail page displays:
- Role information — name, description, system badge, permission count
- Permission matrix — read-only grid showing all granted permissions
- Used By section — two tables showing who is assigned this role:
- Users — display name, email, client context, source (direct or team-inherited), linked to user detail page
- Teams — team name, member count, linked to team detail page
The "Used By" data comes from GET /api/v1/roles/:id/usage.
Access Audit Page
Route: /admin/access-audit (requires roles:read)
A dedicated page for answering "who has access to what" across the system. Two tabs:
By User
Select a user from the dropdown to see all their access across clients:
| Column | Description |
|---|---|
| Client | Which client the access applies to |
| Role | The role granting permissions |
| Source | "Direct" badge or "Team: team-name" badge |
| Permissions | Count — click the row to expand and see the full permission matrix |
By Client
Select a client from the dropdown to see all users with access:
| Column | Description |
|---|---|
| User | Display name (linked to user detail) and email |
| Role | The role granting permissions |
| Source | "Direct" badge or "Team: team-name" badge |
| Permissions | Count — click the row to expand and see the full permission matrix |
Both tabs use expandable rows — clicking a row reveals a read-only PermissionMatrix showing the exact permissions granted by that role assignment.
Backend Endpoints
| Endpoint | Description |
|---|---|
GET /api/v1/admin/access-audit/by-user/:userId | All access entries for a user |
GET /api/v1/admin/access-audit/by-client/:clientId | All users with access to a client |
Both endpoints resolve team-inherited access by joining user_clients, team_members, and team_clients tables with a UNION ALL query.
roles:read grants access to both tabs, but each endpoint also accepts an alternate permission: the by-user endpoint accepts roles:read or users:read, and the by-client endpoint accepts roles:read or clients:read.
Role Assignment Dropdowns
The RoleSelectWithPreview component replaces plain role dropdowns in UserClientDialog and TeamMemberDialog. It provides:
- Left panel — scrollable list of available roles showing name (bold), description, and permission count badge. The currently selected role shows a check icon.
- Right panel — hovering over a role for 300ms shows a read-only
PermissionMatrixpreview, so the administrator can see exactly what permissions will be granted before making a selection.
This component fetches roles and permissions internally, so consuming dialogs only need to provide value and onValueChange props.
Role Comparison
The Roles page (/admin/roles) includes a compare mode for side-by-side role analysis:
- Click "Compare" to enter compare mode
- Select 2-3 roles by clicking their cards (checkboxes appear on each card)
- Click "Compare N Roles" to open the comparison dialog
The comparison dialog shows:
- Role header cards — name, description, and permission count for each selected role
- Permission table — one row per permission, one column per role, with disabled checkboxes indicating granted/not-granted
- Diff highlighting — rows where roles differ are highlighted in amber; rows where all roles agree are visually muted (reduced opacity)
Permissions are grouped by resource, with resource headers separating the groups.
Permissions vs. feature flags
Two independent gates decide whether a user sees and can use a capability, and both must pass:
- Permission —
usePermissions().can(permission, clientId): does the user's role grant it? Resolved per-client from the user's role and team assignments. - Feature flag —
useFeatures().isEnabled(feature, clientId): is the feature turned on for that project (client)? Set per client by an admin (the project's Features section).
Feature-gated surfaces combine them, for example:
const showCompliance = can("compliance:read") && isEnabled("compliance", clientId);
| Role permission | Project feature | Result |
|---|---|---|
✅ has compliance:read | ✅ enabled | Visible and usable |
✅ has compliance:read | ❌ off | Hidden — capability not offered to this project |
❌ lacks compliance:read | ✅ enabled | Hidden — and the API returns 403 |
| ❌ | ❌ | Hidden |
The feature flag decides whether a capability is offered to a project; the permission decides whether this user may use it. Enabling a feature never grants access the role lacks, and a permission is inert if the project's feature is off.
Example: the built-in Client role has no compliance:read. Even with Compliance enabled for their project, a client user does not see the Compliance tab or sidebar entry, and compliance API endpoints return 403 — because can("compliance:read") is false. See Built-in (system) roles.
When changes take effect
Both clientAccess (permissions) and clientFeatures (flags) are delivered in the GET /api/v1/auth/me response and held in AuthContext — not baked into the JWT. So:
- Permissions are enforced live on the backend.
JWTMiddlewarere-resolves access on every request, and changing a role, assignment, or team membership invalidates the cached access immediately. The token is only identity. - Feature flags are read fresh on the next
/auth/me; toggling a project's features invalidates the per-client feature cache. - The UI updates on reload.
AuthContextrefetches/auth/meon mount, so a user picks up new permissions and features after a page reload — no re-login. It does not poll, so someone already on a page won't see changes appear until they reload (the backend already enforces permission changes for any API call in the meantime).
Built-in (system) roles
System roles (is_system = true) ship with the product and cannot be edited or deleted — the role dialog renders them read-only, and the API rejects updates/deletes. Two are intended for external client users, at different levels of technical depth:
| Role | Permissions | What the user can do |
|---|---|---|
| Client | clients:read, environments:read, hosts:read, assets:read, servicedesk:read, servicedesk:write | Read-only infrastructure status (CMDB) for their assigned project, plus raising and tracking Service Desk requests. Aimed at project/product managers and C-level stakeholders who need a high-level view. |
| Client Engineer | everything in Client, plus metrics:read, processes:read, changes:read, logs:read, alerts:read, healthcheck:read, compliance:read, tags:read, search:read | Everything Client can do, plus read-only operational visibility into their own infrastructure — metrics, processes, change detection, logs, alerts, healthchecks, and compliance posture. Aimed at a client-side SA / DevOps. |
Both roles deliberately exclude operator control — terminal (shell) access, runbook execution, agent config, host/environment writes — plus user/role/team management and credential access. The difference is purely in read breadth: Client is CMDB + Service Desk only; Client Engineer adds the monitoring/observability reads.
As always, both gates must pass: the role grants the permission, and the client's feature flag controls the surface (e.g. metrics only appear when monitoring is enabled for that project — see Permissions vs. feature flags). Assign either role directly to the client user (a user_clients row).
To give a client something neither built-in role covers, or to fine-tune (system roles can't be modified): add the needed permission to a custom role, assign it, and enable the matching feature for their project.