Skip to main content

Roles & permissions

Access control is split between roles (named groups of permissions) and permissions (atomic codes that gate one surface). A user carries one or more roles; the framework collects every permission of every role into a flat permission set and uses it to prune every surface the user sees — menus, screens, connectors, AI tools, the Settings page itself.

The design goal is invisible failure: a user without a permission doesn't see the surface, doesn't get a 403 — the entry simply isn't rendered. The Settings UI is the canonical place to define both ends — Settings → Roles for the role definitions, Settings → Users for the assignment.


At a glance

USER
alice
roles = [viewer, editor]
ROLES
viewer → sql:billing:, screens:billing:
editor → + sql:billing:*:write, api:crm:contacts
EFFECTIVE
flattened permission set
used to prune every surface

Editing a role — Settings → Roles

Open Settings → Roles. The page lists every role with its member count, member's roles, description and inheritance. Clicking a row opens the role editor.

Settings → Roles → editor
CancelSave
Display name
Editor
Description
Viewer + write on billing
Inherits from
viewer
Permissions granted
+ Add permission
sql:billing:*:write
api:crm:contacts
FieldEffect
Display nameShown in the role picker and on user pages.
DescriptionFree text — surfaces as a tooltip in the Roles list.
Inherits fromMulti-select of other roles whose permissions are merged into this one. Cycles are refused at save.
Permissions grantedThe list of permission codes this role carries. Each row has an to remove. + Add permission opens the Permission picker.
Members (read-only)Count of users who carry this role — linked to the Users tab filtered to them.

The role editor surfaces the effective permissions at the bottom — the union of Permissions granted + every inherited role's effective set. Useful for confirming that an inherits chain produces the expected total.

Permission picker

+ Add permission opens a dialog with the framework's canonical permission codes grouped by category — SQL, API, Screens, Menus, Dashboards, Charts, Jobs, AI, Settings, Users / Roles, License. Each row offers the wildcard form first (sql:billing:*, screen:billing:*) and the per-entity rows underneath.

Typing in the search box narrows the list — searching billing finds every code in the billing app / connector across categories.


The permission codes

Every gated surface has a canonical permission code generated by the framework. The picker shows them grouped by category; the table below is the conceptual map.

SurfaceCode templateExample
SQL query (read)sql:<connector>:<query>sql:billing:monthly-invoice-counts
SQL query (write)sql:<connector>:<query>:writesql:tasks:update:write
HTTP / API endpointapi:<connector>:<endpoint>api:crm:get-customer
Screenscreen:<app>:<id>screen:billing:invoices
Menumenu:<app>:<leaf>menu:billing:invoices
Dashboarddashboard:<id>dashboard:sales-overview
Chart (direct access)chart:<id>chart:invoices-by-month
Job (Nomaflow)job:<name>job:nightly-sync
Settings — readsettings:readgrants visibility of the Settings link
Settings — per tabsettings:<section>settings:connectors, settings:dictionary, …
Settings — reloadsettings:reloadgrants POST /admin/reload
Users / Rolesusers:read, users:writeview / edit users
Licenselicense:readview the license payload
AI assistantai:chat, ai:tool:<name>use chat, allow a specific tool

Wildcards are supported on the connector / app axis: sql:billing:* grants every query of the billing connector; screen:billing:* grants every screen of the billing app; * alone is reserved for the built-in admin role.


Built-in roles

The framework ships two roles you can't remove:

RolePermissions
admin* — every code, including the Settings page itself.
anonNone. Assigned automatically to unauthenticated requests on public endpoints (rare; the framework defaults to authenticated).

A fresh ./start.sh init-db seeds an admin user with the admin role; no other user starts with elevated permissions.


Assigning roles to users — Settings → Users

In the Users tab, each user row exposes a multi-select of roles. Adding a role is a single click. The framework recomputes the user's effective permission set on save; the next API call from that user uses the new set.

When OIDC is the source of truth (see Authentication → OIDC), the IdP's groups claim is mapped 1:1 to Liberty role names. The Roles tab defines what each role can do — the IdP just decides who carries it.


How pruning works

Pruning runs per request, against the JWT's permission set. Each surface follows the same recipe:

SurfacePruning rule
MenusA leaf is hidden when its underlying permission isn't granted. A folder with zero visible leaves is hidden as well.
Connectors catalogueConnectors with no granted query / endpoint are hidden entirely.
AI assistant toolsThe tool list passed to the LLM only contains queries the caller can run.
Settings pageThe Settings link disappears from the header without settings:read. Each builder tab is hidden without its own permission.
DashboardsA panel referencing a query the caller can't run is silently removed. The dashboard renders without the panel.
ScreensA user without screen:<app>:<id> gets a 403 on direct navigation (the URL is reachable). The Settings UI never exposes the screen in pickers when this permission is missing.

The 403 on direct navigation to a screen is the only place the framework returns a hard failure — every other surface is pruned silently. The reason: screen URLs may have been bookmarked or copy-pasted, and rendering nothing would feel like a broken page.


Server-side enforcement

Pruning is a UX optimisation; the gate is on the server. For every REST call:

  1. The framework parses the JWT and extracts the user's permission set.
  2. The route handler computes the required permission from the URL (POST /api/sql/billing/customer-createsql:billing:customer-create:write).
  3. The handler runs a flat wildcard match against the permission set.
  4. On miss, the handler returns 403 Forbidden with the missing code in the body.

A user crafting a request to a non-permitted query gets the 403; the UI's pruning only matters for usability.


Granting Settings UI access

The Settings page itself is gated. The minimum set for a typical operator editing one builder:

PermissionEffect
settings:readThe Settings link appears in the header.
settings:connectors (or whichever tab)The corresponding tab is visible.
settings:reloadThe Save & reload button works (otherwise the form saves and a warning says "reload required").

Reading without writing is also possible — grant settings:read + settings:connectors (without :reload) for an auditor profile.

The Raw TOML editor is gated separately with settings:raw; this lets you allow a regular operator to edit every builder but withhold the escape hatch.


Common role recipes

The Roles editor's Templates button proposes a few starting points — pick one, then trim.

TemplateEffective permissions
ViewerEvery read query (sql:*:*), every API GET (api:*:*), every screen (screen:*:*), every menu (menu:*:*), every dashboard (dashboard:*), every chart (chart:*), AI chat (ai:chat).
EditorInherits Viewer + every write query (sql:*:*:write).
Settings editorsettings:read + every per-tab settings:* + settings:reload. No sql:*. Can wire the framework without seeing the underlying data.
Job operatorsettings:read + settings:jobs + job:*. Trigger any job manually, see all run history.
Auditorsettings:read + settings:technical + users:read + license:read + read access to audit-specific connectors.

Use Templates as a starting point; rename and trim before saving.


Inspecting effective permissions

MethodWhat it shows
Settings → Users → click a user → Effective permissions tabFlat list of every permission granted, with the role that contributed it.
liberty-admin show aliceSame list on the CLI.
GET /auth/meReturns the JWT payload of the calling user, including the permission array.
Server log with LIBERTY_LOG_LEVEL=DEBUGPrints the matched permission on every gated call.

Tips & best practices

  • Prefer wildcards on connectors. sql:billing:* is easier to reason about than 20 individual permissions and survives the addition of a new query. Reserve named permissions for cross-cutting concerns.
  • Never grant *. It's reserved for the built-in admin role; granting it to a custom role bypasses every future gate the framework adds.
  • Use Inherits from to layer roles. editor inherits from viewer, manager inherits from editor. Linear inheritance is easier to debug than parallel grants.
  • Audit the anon role. Even though there's no user assigned to it by default, a setting somewhere might add a public endpoint. Make sure anon carries nothing it shouldn't.
  • Mirror the IdP groups. When OIDC is the source of truth, name your Liberty roles to match the IdP group names — the 1:1 mapping is the cleanest contract.

Under the hood

Roles live alongside users — in config/auth.toml for the TOML backend, in the ly2_roles / ly2_role_permissions tables for the database backend. Operators do not edit these by hand; the Roles editor is the canonical interface.


What's next