Skip to content

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.

RolePermissions
ownerorg.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
adminsame as owner minus org.delete and org.billing
memberitems.read, items.write, items.archive
vieweritems.read

Every org must have at least one Owner — see the last-Owner guard.

Permission strings

Grouped by surface:

FamilyPermissions
Organizationorg.manage, org.delete, org.billing
Membersusers.invite, users.remove, users.change_role
Teamsteams.create, teams.delete, teams.manage_members
Channelschannels.create, channels.delete, channels.manage
Webhookswebhooks.manage
Itemsitems.read, items.write, items.archive
Auditaudit.read
Agentsagents.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

bash
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

bash
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

bash
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

bash
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

  1. The AuthUser extractor resolves the bearer token to a user_id, org_id, and the current role's permissions[] array.
  2. Each handler calls user.require_permission("something.specific").
  3. has_permission checks exact match → wildcard → universal.
  4. On failure, the handler returns 403 with 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:

ColumnNotes
idUUID PK
org_idnullable — NULL = system role
nameTEXT, unique per org (system uses a partial unique index where org_id IS NULL)
descriptionTEXT
is_systemBOOLEAN, true only for the four seeded roles
permissionsTEXT[] — free-form strings, wildcards allowed
created_attimestamptz

Both org_memberships.role_id and invitations.role_id are NOT NULL FKs into this table.