Roles & Permissions
Klaxon uses a roles + permission-strings model. Every endpoint that mutates data calls require_permission("some.capability"); a role is just a named set of permission strings. Orgs can define custom roles in addition to the four system roles.
System roles (built-in)
Seeded by migration 0020_roles.sql with org_id = NULL. Shared across every org; you can't edit them.
| Role | Permissions |
|---|---|
| owner | org.manage, org.delete, org.billing, users.invite, users.remove, users.change_role, teams.create, teams.delete, teams.manage_members, channels.create, channels.delete, channels.manage, webhooks.manage, items.read, items.write, items.archive, audit.read, agents.manage |
| admin | same as owner minus org.delete and org.billing |
| member | items.read, items.write, items.archive |
| viewer | items.read |
Every org must have at least one Owner — see the last-Owner guard.
Permission strings
Grouped by surface:
| Family | Permissions |
|---|---|
| Organization | org.manage, org.delete, org.billing |
| Members | users.invite, users.remove, users.change_role |
| Teams | teams.create, teams.delete, teams.manage_members |
| Channels | channels.create, channels.delete, channels.manage |
| Webhooks | webhooks.manage |
| Items | items.read, items.write, items.archive |
| Audit | audit.read |
| Agents | agents.manage |
The server treats this list as free-form TEXT, so adding a new capability doesn't require an enum migration — define a handler that calls require_permission("new.capability"), assign it to whatever system roles should get it by default, and document it here.
Wildcards
require_permission("items.write") matches:
- an exact grant of
items.write, OR - a wildcard grant of
items.*, OR - the universal grant
*.
The matcher is in klaxon-auth-core::extractor::has_permission. Wildcard matching is prefix-based: items.* matches any items.something but not itemsfoo. Nested dots work too — org.billing.* would match org.billing.export without matching org.other.
Custom roles
Orgs can define their own roles in addition to the four system roles. Custom roles live in the roles table with org_id = <your org> and any subset of the permission strings above (including wildcards).
Create a custom role
curl -X POST https://api.klaxon.sh/api/admin/roles \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name":"Incident Responder",
"description":"Can read everything and resolve items",
"permissions":["items.*","audit.read","channels.manage"]
}'Escalation guard: the caller must hold every permission they're granting. An admin without org.delete cannot create a role that includes it.
Permission required: users.change_role.
Update a custom role
curl -X PATCH https://api.klaxon.sh/api/admin/roles/$ROLE_ID \
-H "Authorization: Bearer $TOKEN" \
-d '{"permissions":["items.read","audit.read"]}'System roles are read-only — PATCH /api/admin/roles/{system_role_id} returns 403.
Delete a custom role
curl -X DELETE https://api.klaxon.sh/api/admin/roles/$ROLE_ID \
-H "Authorization: Bearer $TOKEN"Blocked if the role still has members assigned. Reassign them first with POST /api/admin/users/{user_id}/role.
List roles
curl https://api.klaxon.sh/api/admin/roles \
-H "Authorization: Bearer $TOKEN"Returns system roles (org_id = NULL) plus any roles your org has defined.
How enforcement works
- The
AuthUserextractor resolves the bearer token to auser_id,org_id, and the current role'spermissions[]array. - Each handler calls
user.require_permission("something.specific"). has_permissionchecks exact match → wildcard → universal.- On failure, the handler returns
403with an error body naming the missing permission.
Because checks live in handlers (not middleware on routes), the same permission can gate different endpoints and one endpoint can check multiple permissions where the semantics demand it.
Schema
Migration 0020_roles.sql replaced the previous TEXT enum on org_memberships.role with a first-class roles table:
| Column | Notes |
|---|---|
id | UUID PK |
org_id | nullable — NULL = system role |
name | TEXT, unique per org (system uses a partial unique index where org_id IS NULL) |
description | TEXT |
is_system | BOOLEAN, true only for the four seeded roles |
permissions | TEXT[] — free-form strings, wildcards allowed |
created_at | timestamptz |
Both org_memberships.role_id and invitations.role_id are NOT NULL FKs into this table.
Related guides
- Admin Panel — who can call what
- Invitations — invites carry a
role_id - Authentication — how the bearer token gets resolved