Skip to main content

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 onChange prop to render a static view

The matrix is used in four places:

LocationModePurpose
Role create/edit dialogInteractiveSelect permissions for a role
Role detail pageRead-onlyDisplay role permissions at a glance
Access audit pageRead-onlyShow expanded permission details per assignment
Role assignment hoverRead-onlyPreview 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:

  1. Role information — name, description, system badge, permission count
  2. Permission matrix — read-only grid showing all granted permissions
  3. 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:

ColumnDescription
ClientWhich client the access applies to
RoleThe role granting permissions
Source"Direct" badge or "Team: team-name" badge
PermissionsCount — 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:

ColumnDescription
UserDisplay name (linked to user detail) and email
RoleThe role granting permissions
Source"Direct" badge or "Team: team-name" badge
PermissionsCount — 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

EndpointDescription
GET /api/v1/admin/access-audit/by-user/:userIdAll access entries for a user
GET /api/v1/admin/access-audit/by-client/:clientIdAll 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.

note

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 PermissionMatrix preview, 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:

  1. Click "Compare" to enter compare mode
  2. Select 2-3 roles by clicking their cards (checkboxes appear on each card)
  3. 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:

  1. PermissionusePermissions().can(permission, clientId): does the user's role grant it? Resolved per-client from the user's role and team assignments.
  2. Feature flaguseFeatures().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 permissionProject featureResult
✅ has compliance:read✅ enabledVisible and usable
✅ has compliance:read❌ offHidden — capability not offered to this project
❌ lacks compliance:read✅ enabledHidden — 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 AuthContextnot baked into the JWT. So:

  • Permissions are enforced live on the backend. JWTMiddleware re-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. AuthContext refetches /auth/me on 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:

RolePermissionsWhat the user can do
Clientclients:read, environments:read, hosts:read, assets:read, servicedesk:read, servicedesk:writeRead-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 Engineereverything in Client, plus metrics:read, processes:read, changes:read, logs:read, alerts:read, healthcheck:read, compliance:read, tags:read, search:readEverything 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.