Skip to content

Roles & permissions

Access is governed by roles. Each user carries one or more roles (permissions union — see Multiple roles per user); a role grants access to tables, columns, rows and actions. Roles are authoritative in config/auth.hcl — versioned config you review like code. The in-app roles screen edits that same file: creates, edits and deletes write config/auth.hcl atomically and hot-swap the live config (on a read-only bundle it hands you the HCL to commit yourself).

How a permission is decided

  1. Each role's coarse tables level sets a baseline (read / write / none).
  2. The role's perm "<table>" block refines it per capability (view / create / update / delete).
  3. With several roles, capabilities OR together — privileges add up, restrictions need unanimity.
  4. The result is ANDed with the table config's permissions { } ceiling (writes only).
  5. A view or PK-less table is structurally read-only — no role, admin included, can write it.
  6. Masking, row filters and editable then narrow what an allowed request can touch.

Public access (no login)

hcl
# config/auth.hcl
public_role = "viewer"

With public_role set, anonymous visitors act as that role without logging in — kiosk dashboards, read-only status panels, hosted demos. It accepts a comma-separated list of roles, exactly like a user's assignment. A real session always wins over the anonymous identity, log in normally to switch. Naming an unknown role is a load error. Point it at a write-capable role only if you truly mean it: everyone on the network gets that power.

The admin role

admin is built in. It has full access to everything and cannot be edited or deleted. It bypasses the role-side refinements below (levels, perm, editable, role masking, row filters) — but not the table itself: a table config's permissions { } ceiling and structural read-only-ness (a view or a PK-less table) bind admins too, and so do automatic and field-level column masking.

config/auth.hcl

Every role is a role "<name>" { } block:

hcl
role "staff" {
  tables = {
    "*"          = "read"
    "orders"     = "write"
    "products"   = "write"
    "customers"  = "write"
  }

  actions = [
    "orders.mark_shipped",
    "orders.refund",
    "products.activate",
    "products.deactivate",
  ]

  masked = {
    "subscriptions" = ["api_token"]
  }
}
KeyTypeDescription
extendsstringParent role to inherit from — see Role inheritance.
customizeboolMay open Personalizar and edit table configs (visual + raw HCL + version history). Config authorship — grant to trusted power users only; groups, dashboard, discover and access screens stay admin-only. Inherits through extends and unions across multiple roles.
tablesmapThe coarse access level per table: "read" or "write". "*" sets a default for every table.
perm "<table>" { }blockFine-grained per-capability override (view/create/update/delete).
editablemapPer-table whitelist of columns this role may edit.
actionslistBulk actions this role may invoke, as "<table>.<action>".
maskedmapPer-table columns whose values are hidden from this role.
row_filtermapPer-table SQL predicate scoping which rows this role sees.

Role inheritance

A role can extend another with extends — the parent resolves first, then the child's own entries override it key by key:

hcl
role "viewer" {
  tables = { "*" = "read" }
}

role "support" {
  extends = "viewer"
  tables  = { "orders" = "write" }   # everything else stays read from viewer
}
  • tables, perm, editable, masked and row_filter merge per key: a child entry for a table replaces the parent's entry for that table wholesale.
  • actions is the union of parent and child.
  • Chains are allowed (abc). An extends naming an unknown role or forming a cycle is a startup/config error, and the runtime editors reject it with a 400 — a broken hierarchy never loads silently.
  • The runtime Roles editor exposes this as the Inherits from selector, plus a show effective permissions toggle that renders the fully-resolved matrix (parents flattened in) read-only — what the role actually grants, not just its own overrides.

Multiple roles per user

A user may carry several roles (comma-separated in the Users editor — chips in the UI). Permissions are the union, Django-groups style:

  • Privileges add up: table levels take the highest (read + write = write), granular perm capabilities OR together, actions union.
  • Restrictions hold only when every relevant role imposes them: a column stays masked only if masked in ALL view-granting roles for that table; row_filters OR together (a view-granting role with no filter lifts the restriction entirely); an editable whitelist applies only if every write-granting role has one (then the union of the lists).
  • admin anywhere in the list makes the user an admin.

A broad role lifts restrictions

Because restrictions need unanimity, adding a generic role (e.g. a viewer with "*" = "read" and no masks/filters) to a user whose other role masked columns or filtered rows unmasks and unfilters everything that role can see. Keep broad roles narrow, or give them the same masks.

Coarse table access

tables is the baseline. Two levels:

  • "read" — the role may view the table but not change it.
  • "write" — the role may view, create, update and delete.

The "*" wildcard sets a default for all tables; per-table entries override it:

hcl
tables = {
  "*"      = "read"      # read everything by default
  "orders" = "write"     # …but fully manage orders
}

Granular capabilities (perm)

A perm "<table>" block refines the coarse level one capability at a time. Each of view, create, update, delete is optional: unset defers to the coarse tables level; set forces that value.

hcl
role "support" {
  tables = { orders = "write" }

  perm "orders" {
    view   = true
    update = true
    create = false      # can edit rows, but not add or remove them
    delete = false
  }
}

Every write capability is then intersected with the table's own permissions { } ceiling and the structural read-only gate:

update = (perm.update ?? coarse) AND ceiling.write                     AND not read-only
create = (perm.create ?? coarse) AND ceiling.write AND ceiling.create AND not read-only
delete = (perm.delete ?? coarse) AND ceiling.write AND ceiling.delete AND not read-only
view   = (perm.view   ?? coarse)          # reads have no ceiling

So create = true on a role means nothing if the table config sets permissions { create = false } (or write = false) — the ceiling wins.

Editable-column whitelist

editable restricts which columns a role can write, table by table. When a table has an editable list, any column not in it is rejected on every write path (update, create, bulk, import) — on top of the usual masked / readonly / PK / computed rejections. Absent means no per-column restriction.

hcl
role "support" {
  tables   = { orders = "write" }
  editable = { orders = ["status", "total"] }   # may only change these two columns
}

This is orthogonal to masked: a column can be readable-but-not-editable, or editable-but-masked-in-display, independently.

Column masking

masked lists columns whose values this role should not see. Masked values come back pre-masked (never the real value), are excluded from search and export, and cannot be used as a sort key.

hcl
masked = {
  "subscriptions" = ["api_token"]
}

Independent of roles, secret-shaped column names auto-mask for everyone (admins included), and a field-level masked = true binds admins too — see Security → Column masking.

Row-level filters

row_filter scopes a role to a subset of rows via a SQL predicate that is ANDed into every query touching that table — list, count, search, and writes. A user can never see or touch a row outside their filter.

hcl
role "support" {
  tables     = { "*" = "read" }
  row_filter = {
    "customers" = "t.active AND t.plan <> 'free'"
  }
}

The current row is aliased t. The token {actor.email} is substituted with the signed-in user's email as a complete quoted, escaped SQL string literal — write it without surrounding quotes:

hcl
row_filter = {
  "orders" = "t.customer_id IN (SELECT id FROM customers WHERE email = {actor.email})"
}

Actions

actions lists which bulk actions the role can invoke, each as "<table>.<action>" matching an action "…" block in that table's config. A role can only run actions listed here.

hcl
actions = ["orders.mark_shipped", "orders.refund", "products.deactivate"]

Managing roles & users at runtime

Admins can manage roles and users from the in-app access screens. Role edits write config/auth.hcl (atomically, versioned, hot-swapped); users live in cartapel's SQLite state. Guardrails:

  • The builtin admin role cannot be edited or deleted — and a new role named any casing of it (Admin, ADMIN) is rejected. Role names are 1–64 chars of letters, digits, _ or -.
  • A role still assigned to users cannot be deleted — reassign them first.
  • A role other roles extends cannot be deleted — remove the inheritance first.
  • You cannot delete or demote the last admin user.
  • Role definitions are validated against the live schema — every referenced table, action and editable column must exist (and be configured in the admin; a role cannot reference a table cartapel doesn't expose).
  • A user assigned a role name that no longer exists simply gets nothing from it — stale assignments fail closed, never error.
  • On a read-only config bundle, role edits change nothing — the screen returns the would-be HCL for you to commit.

Users can also be provisioned offline with cartapel user add.

View as a role

An admin can impersonate any other defined role to verify exactly what it sees — pick "View as" from the user menu. While impersonating:

  • Every request is evaluated with the impersonated role's permissions, masking and row filters.
  • The session is read-only: any mutation is rejected with a 403 until you exit.
  • It never escalates — only admins are honored, and viewing as admin is a no-op.

A banner shows the active role the whole time; exit via the banner or the user menu.

Released under the MIT License.