Roles and permissions
A role in Liberty is a name + a list of permission strings. Users get roles; roles grant or deny what users can do.
The Settings → Access → Roles tab is where you build them. The page hides every permission-string detail behind a PermissionPicker — you don't type sql:crm:customers_get; you pick Query → customers → customers_get from dropdowns and the picker generates the string.
The Roles tab
Each card shows the role's id, description, and a count of allow/deny rules (or a Full access chip when the baseline is * with no further restrictions).
The Role editor
Click a role row to open the editor. It has three pieces:
| Section | Notes |
|---|---|
| Role name | Read-only after creation. Renaming requires deleting and recreating with the new name (no cross-file rename for roles — references in users update automatically because users carry role names, not ids). |
| Description | Free text. Shown on the card. |
| Permissions | The PermissionPicker — baseline + allow / deny rules. |
The PermissionPicker — anatomy
The baseline
Two buttons, mutually exclusive:
| Baseline | Permissions list | Semantics |
|---|---|---|
| No access | starts empty | Nothing is allowed except what you add via Allow rules. The right default for least-privilege roles. |
| Full access | starts with ["*"] | Everything is allowed except what you remove via Deny rules. The right default for superuser-like roles. |
The hint below the toggle reads either "nothing, plus the allows below" or "everything, minus the denies below" depending on the choice.
The rule list
Each rule is a coloured chip:
| Chip colour | Meaning |
|---|---|
| Green | An allow rule — sql:crm:customers_get, menu:crm:pipeline. |
| Red | A deny rule — the same shapes but prefixed ! on storage (!sql:crm:customers_delete). |
The ✕ on each chip removes the rule. The list at the top of the Picker is sorted by the order rules were added.
Adding a rule
Three controls below the chips:
| Control | What it does |
|---|---|
| Effect (Allow / Deny) | Two-button toggle. Allow adds the chip without the ! prefix; Deny adds it with !. |
| Type | Dropdown — Menu / Dashboard / Connector (all queries) / Query / API endpoint / AI assistant. Drives the available items in the next dropdown. |
| Item | A search-select populated based on Type. For Menu it lists every menu item from every app; for Connector it lists SQL connectors; for Query it lists every named query; for Dashboard it lists every dashboard id; for API endpoint it lists every API connector's endpoints; for AI assistant there's a single entry. |
| + Add | Resolves the picked item into one or more permission patterns, prefixes ! if Deny, appends to the rule list. |
The picker handles the pattern composition so you don't have to remember the sql:<c>:<q> syntax. You think in terms of "allow this menu" or "deny this query"; the storage looks like menu:crm:reports or !sql:crm:customers_delete.
Menu rules have a special expansion
When you allow a Menu rule, the picker expands it into multiple permission patterns:
menu:<app>:<id>— so the menu item itself shows in the navigation.menu:<app>:<descendant_id>— for each descendant leaf (so submenus show).sql:<connector>:<target>orapi:<connector>:<target>— for each descendant leaf's underlying query / endpoint, so the screens / endpoints actually run.
This means an Allow → Menu → Pipeline rule grants the user the whole Pipeline folder and every screen inside it, in one go. The same expansion happens on Deny — denying Menu → Admin hides the folder and refuses every screen inside.
Without this expansion, you'd have to add an allow for the menu and a separate allow for each underlying query — an error-prone manual chore. The picker does it for you.
Resolution order
When the framework checks a permission, it runs through this order:
- Superuser check — if the user has
is_superuser = true, allow everything (deny rules ignored). - Explicit deny — if any
!<pattern>in the user's combined roles' permissions matches, reject. - Explicit allow — if any non-
!pattern matches, allow. - Default deny — otherwise, reject.
The order matters. A ["*", "!sql:crm:customers_delete"] permission list:
sql:crm:customers_get→ matches*(allow), no matching deny → allowed.sql:crm:customers_delete→ matches*(allow) and matches!sql:crm:customers_delete(deny) → deny wins → rejected.sql:reporting:*→ matches*(allow), no matching deny → allowed.
Pattern syntax — the full grammar
Permission strings use colon-segmented globs:
| Operator | Meaning | Example |
|---|---|---|
<word> | Literal segment. | sql, crm, customers_get. |
* (single segment) | Matches one segment. | sql:*:customers_get matches sql:crm:customers_get and sql:reporting:customers_get. |
* (trailing) | Matches the rest. | sql:crm:* matches every sql:crm:<anything>. |
!<pattern> | Deny. | !sql:crm:customers_delete. |
* (alone) | Wildcard everything. | The Full access baseline. |
!* | Deny everything. | Rare; effectively a kill switch. |
The full surfaces:
| Pattern | Grants |
|---|---|
sql:<connector>:<query> | Run one SQL query. |
sql:<connector>:* | Run every SQL query on a connector. |
sql:* | Run every SQL query everywhere. |
api:<connector>:<endpoint> | Call one API endpoint. |
api:<connector>:* | Call every endpoint on a connector. |
menu:<app>:<id> | See one menu item (folder or leaf). |
menu:<app>:* | See every menu item under an app. |
dashboard:<id> | Open one dashboard. |
ai:chat | Use the AI assistant. |
* | Everything. |
Standard role recipes
| Role | Baseline | Rules |
|---|---|---|
| Superuser | Full access | (empty) — equivalent to flipping is_superuser on the user. |
| Reader on the CRM app | No access | Allow → Menu → CRM (which expands into menu:crm:* + sql:crm:* for the read queries). |
| Power user on CRM except delete | Full access | Deny → Query → customers_delete, deals_delete, activities_delete. |
| AI-only access | No access | Allow → AI assistant. The user signs in and gets the AI chat surface but no menu / no screens. |
| Reports manager | No access | Allow → Menu → Reports + Allow → Dashboard → revenue_overview / pipeline_health / etc. |
| Admin minus AI | Full access | Deny → AI assistant. Everything else allowed. |
The Permissions Picker handles every case through the same Allow / Deny / Type / Item cascade.
How a user's effective permissions compose
A user can have several roles. The framework concatenates the permission lists from every role — deny wins across roles too.
Example: Alice has both analyst and reporter:
analyst.permissions = ["*", "!sql:crm:customers_delete"]reporter.permissions = ["sql:reporting:*", "menu:reporting:*"]
Alice's effective permissions:
sql:crm:customers_get→ allowed by*in analyst.sql:crm:customers_delete→ denied byanalyst's!sql:crm:customers_deleteeven though*would allow it.sql:reporting:monthly_revenue→ allowed by both*(analyst) andsql:reporting:*(reporter) — redundant but fine.
There's no way for one role to "override" another role's deny — deny is final across the user's complete role set.
Common pitfalls
| Mistake | Symptom | Fix |
|---|---|---|
| Allow a Menu without realising it grants the underlying queries. | A user has more SQL access than expected. | Use Allow → Query for fine-grained control; reserve Allow → Menu for "grant the whole section". |
| Full access baseline + a few allow rules. | The allows are redundant (the baseline already grants them). | Either use Full access (and deny exceptions) or No access (and allow specifics). Not both. |
!sql:crm:* thinking it'll override an inherited allow. | The deny works — but the user has no SQL access on crm even when other roles grant it. | Denies are absolute. Use them deliberately. |
| Renaming a role through editing the name. | Name is read-only after creation. | Delete the role and recreate with the new name. Re-assign users. |
| Forgetting that JWT changes lag. | A user gets a new role but doesn't see it for an hour. | The user signs out and back in, or waits for the access token TTL. |
What's next
- Users — assign roles to users.
- Sign-in — how users get their JWTs.
- Encrypted secrets — independent layer; orthogonal to RBAC.