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)
- Select all / deselect all — a toolbar button above the grid (the top-left cell is a static
Resourceheader) - 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 three places:
| Location | Mode | Purpose |
|---|---|---|
Role editor (RoleEditor) | Interactive | Select permissions for a role |
| Access audit page | Read-only | Show expanded permission details per assignment |
RoleSelectWithPreview | 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) alphabetically — the comparator ends in localeCompare.
Role Editor
Creating and editing a role uses a full-screen editor (RoleEditor) at /admin/roles/new and
/admin/roles/:roleId — not a dialog. It renders the interactive PermissionMatrix. System roles
render read-only with a notice explaining they cannot be modified.
/admin/roles/:roleId opens the editor, not a read-only detail view. GET /api/v1/roles/:id/usage
exists on the backend, but nothing in the UI calls it — there is no way to see who holds a role from
the roles pages. Use the Access Audit page instead.
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 Project
Select a project from the dropdown to see all users with access. (The UI says project; the underlying entity is a client — the two are the same thing.)
| 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.
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.