Skip to main content

Permissions and roles

The menu the user sees is not the menu you saved — it's the menu filtered to what the user is allowed to use. Liberty prunes the tree at fetch time so users never click on items they can't open.

Two filters apply:

FilterWhen it kicks in
Implicit permissionEvery leaf inherits the permission of its target — sql:<connector>:<target> for a query, api:<connector>:<target> for an endpoint. Users without the permission don't see the leaf.
Explicit rolesThe leaf's roles field is an additional gate — when set, the user must have at least one of the listed roles.

After both filters run, folders with no surviving children are collapsed away.


At a glance​

What each user seesAliceuser📁 SalesCustomersDealsBobmanager📁 SalesCustomersDeals📁 ReportsMonthly revenueCaroladmin📁 Sales…📁 Reports…📁 AdminConfigImplicit permissionthe query the entry opensroles on an entryextra gate: one of them

The implicit permission​

Every leaf is gated by the permission of its target:

typeRequired permission
screen, querysql:<connector>:<target> (the query's permission).
endpointapi:<connector>:<target> (the endpoint's permission).
dashboardNone at this layer — hidden only by a dashboard: or menu: deny.
pageNone at this layer — the page's own access rules apply.

An explicit menu:<app>:<id> allow in a role also shows the leaf, even without the target's permission; a menu: deny always hides it. The role editor writes these strings when an entry is allowed or denied on its menu tree — see Roles and permissions.

A user with sql:crm:customers_get sees the menu leaf that opens the Customers screen. A user without it doesn't — the leaf vanishes from the rendered menu.

This is the same permission the screen / endpoint already checks. The menu doesn't introduce a second permission; it just hides links the user couldn't follow anyway. Result: no greyed-out items, no clicks that take the user to a 403 forbidden page.


The explicit roles filter​

The roles field on a menu item is a list of role names. When set:

  • The user must have at least one of the listed roles.
  • The check is in addition to the implicit permission — both must hold.
roles valueBehaviour
Empty (default)Visibility determined by the implicit permission alone.
["manager"]Visible only to users with the manager role.
["manager", "admin"]Visible to users with either manager or admin.

Use cases​

PatternExample
Hide an item that's open to read but only meaningful for a subset of users.A Reports leaf where every user has read on the underlying query, but only managers actually use it.
Surface different sections to different audiences in the same app.An admin folder visible only when roles = ["admin"] matches.
Stage a rollout.Tag a new section roles = ["beta-testers"]; flip to empty when launching to everyone.

The roles field is not an alternative to the permission — both must hold: the user needs one of the listed roles and the right to open the leaf.


Folder collapse​

A folder with no visible children disappears from the rendered menu. This means:

  • A folder that wraps three leaves, all gated by roles = ["admin"], vanishes entirely for a non-admin user — they don't see an empty Admin folder.
  • A folder whose children are all gated by SQL permissions the user lacks similarly vanishes.

This happens at every level — a nested folder structure prunes from the leaves up. The user sees a minimal, useful tree.

The runtime walks the tree once per request, applying the filters and collapsing empties in one pass.


A worked example​

A menu with mixed visibility:

[menus.crm]
label = "CRM"

# Top-level — always visible
[[menus.crm.items]]
id = "pipeline"
label = "Pipeline"
icon = "briefcase"

[[menus.crm.items]]
id = "pipeline.customers"
parent = "pipeline"
label = "Customers"
type = "query"
target = "customers_get"
# implicit: sql:crm:customers_get

[[menus.crm.items]]
id = "pipeline.deals"
parent = "pipeline"
label = "Deals"
type = "query"
target = "deals_get"
# implicit: sql:crm:deals_get

[[menus.crm.items]]
id = "reports"
label = "Reports"
icon = "chart-bar"

[[menus.crm.items]]
id = "reports.monthly"
parent = "reports"
label = "Monthly revenue"
type = "query"
target = "monthly_revenue_get"
roles = ["manager", "admin"]

[[menus.crm.items]]
id = "reports.cohort"
parent = "reports"
label = "Cohort analysis"
type = "query"
target = "cohort_get"
roles = ["analyst", "admin"]

[[menus.crm.items]]
id = "admin"
label = "Admin"
icon = "shield"

[[menus.crm.items]]
id = "admin.config"
parent = "admin"
label = "Config"
type = "query"
target = "config_get"
roles = ["admin"]

What different users see:

UserRolesHas sql:crm:*Visible menu
Alice (user)["user"]customers_get, deals_get onlyPipeline → Customers, Deals. (no Reports, no Admin)
Bob (manager)["manager"]every sql:crm:*Pipeline → Customers, Deals · Reports → Monthly revenue. (no Cohort: not analyst; no Admin)
Carol (admin)["admin"]every sql:crm:*Pipeline → Customers, Deals · Reports → Monthly revenue, Cohort · Admin → Config.
Dave (manager + analyst)["manager", "analyst"]every sql:crm:*Pipeline → Customers, Deals · Reports → Monthly revenue, Cohort. (no Admin)
Eve (no permission)["guest"]noneEmpty tree — the app is left out of the picker.

Note how the Reports folder doesn't appear for Alice (her roles don't intersect any child); the Admin folder doesn't appear for Alice / Bob / Dave; the app is left out of Eve's application picker.


Permission shapes​

Liberty's permission strings follow a simple pattern:

StringGrants
sql:<connector>:<query>Run that specific SQL query.
sql:<connector>:*Run every query on that connector.
sql:*Run every query on every connector.
api:<connector>:<endpoint>Call that specific API endpoint.
api:<connector>:*Call every endpoint on that connector.
menu:<app>:<id>Show that menu entry. A !menu:… deny hides it.
*Everything.

A superuser is a flag on the user, not a permission: it passes every check.

For menu pruning, the relevant permissions are sql: and api: (matching the leaf's target) and menu:. A role granting sql:crm:* opens every screen and query leaf on the crm app.

Granular vs broad:

Granular sql:<c>:<query>Broad sql:<c>:*
Per-screen control — pick which screens this role sees.Whole-app control — this role sees every screen on the connector.
Tedious to maintain for apps with 50+ screens.One permission per role per app.
Useful for compliance scenarios.The default for most installs.

Most teams settle on broad sql:<c>:* per role per app, and use the roles filter on individual items for fine-grained exceptions.


How the pruning runs​

GET /api/menus fires when the user opens the app. The runtime:

  1. Loads the user's roles + permissions from the JWT.
  2. Walks the tree depth-first.
  3. For each leaf:
    • Check the implicit permission. If the user lacks it, the leaf is dropped.
    • Check the roles filter. If the list is non-empty and the user has none of them, the leaf is dropped.
  4. For each folder: drop it if no descendants survived.
  5. Return the pruned tree.

The pruning is per-request — it picks up permission changes the moment the user re-authenticates. No restart needed.


Dashboards and pages — different gating​

Dashboards and routes don't inherit a sql: / api: permission. They have their own access rules:

Leaf typeDefault visibility
screen, queryHidden unless sql:<connector>:<target> or menu:<app>:<id> is granted.
endpointHidden unless api:<connector>:<target> is granted.
dashboardVisible to everyone by default. Use roles to gate.
pageVisible to everyone by default. Use roles to gate.

For dashboards, the underlying chart queries still need their sql: permissions — a user without permission opens the dashboard and sees empty charts. For pages, the React component handles its own auth.

The right pattern for both: always set roles on dashboard and page leaves you want to scope. Liberty's default-visible behaviour is deliberate (most dashboards / pages are general-audience), but it means you must opt into restriction.


Common pitfalls​

MistakeSymptomFix
roles = ["Manager"] (capitalised).Users with the lowercase manager role don't see the item.Roles are case-sensitive. Use the exact spelling from the roles config.
Granting sql:crm:* to a role but the screen still doesn't appear.The user's session was issued before the grant.Sign out / sign back in to refresh the JWT.
roles = [] (explicit empty list).Equivalent to leaving the field blank — implicit permission alone.If you meant "only admins", use roles = ["admin"].
Folder visible despite all leaves being hidden.The pruning ran on stale data (rare race condition during a hot reload).Refresh the page.
Dashboard visible to everyone but its charts empty.The user has dashboard visibility but no underlying sql: permissions.Either grant the sql: permissions OR add a roles filter to hide the dashboard from those users.
Menu shows nothing for a brand-new user.The user's role grants no sql: / api: permissions on this app.Verify the role has at least one matching permission.

What's next​