> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Roles and permissions

> A catalogue that lives in code, and role definitions that live in your database.

A **permission** is a fine-grained capability. A **role** is a named set of them. A
**membership** gives one user one role in one organization.

The split that makes this work: the *catalogue* lives in Go, because the set of
things the system can do is a fact of the codebase. *Which role holds which* lives
in the database, so an organization can define its own roles without waiting for a
deploy.

## The catalogue

<ResponseField name="organization:update" type="permission">
  Rename the organization, change its logo, close onboarding, set an environment's
  auth mode.
</ResponseField>

<ResponseField name="organization:delete" type="permission">
  Destroy the organization and everything under it.
</ResponseField>

<ResponseField name="members:read" type="permission">
  List members and pending invitations.
</ResponseField>

<ResponseField name="members:write" type="permission">
  Invite, revoke invitations, change roles, remove members.
</ResponseField>

<ResponseField name="roles:read" type="permission">
  List roles and read the permission catalogue.
</ResponseField>

<ResponseField name="roles:manage" type="permission">
  Create, edit and delete roles. **This one can grant any permission, including
  itself** — whoever holds it can self-escalate, which is why `admin` does not get
  it by default.
</ResponseField>

<ResponseField name="api_keys:read" type="permission">
  List API keys and publishable keys.
</ResponseField>

<ResponseField name="api_keys:write" type="permission">
  Create and revoke API keys, set allowed origins, read and rotate an
  environment's identity secret.
</ResponseField>

<ResponseField name="customers:read" type="permission">
  Read contacts in the panel.
</ResponseField>

<ResponseField name="customers:write" type="permission">
  Merge contacts. Split from read because a merge is destructive in the
  "no un-merge" sense.
</ResponseField>

Read it from the API instead of hard-coding it — that is what the role editor does:

```bash theme={null}
curl -s $API/v1/organization/roles/permissions \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…"
```

## The system roles

Seeded into every organization at creation. They are marked `is_system`.

|                       | owner | admin | member |
| --------------------- | :---: | :---: | :----: |
| `organization:update` |   ✓   |   ✓   |        |
| `organization:delete` |   ✓   |       |        |
| `members:read`        |   ✓   |   ✓   |    ✓   |
| `members:write`       |   ✓   |   ✓   |        |
| `roles:read`          |   ✓   |   ✓   |        |
| `roles:manage`        |   ✓   |       |        |
| `api_keys:read`       |   ✓   |   ✓   |    ✓   |
| `api_keys:write`      |   ✓   |   ✓   |        |
| `customers:read`      |   ✓   |   ✓   |    ✓   |
| `customers:write`     |   ✓   |   ✓   |        |

`admin` runs the operation but cannot delete the organization nor manage roles.
`member` is read-only.

<Note>
  Role names are stored in English — `owner`, `admin`, `member` — because the API
  matches those literals: the last-owner guard and the seeding both compare on them.
  Translate for display only.
</Note>

## Custom roles

Custom roles are never `is_system`, so they stay renamable and deletable.

<CodeGroup>
  ```bash Create theme={null}
  curl -s $API/v1/organization/roles \
    -H "Authorization: Bearer $UK_SESSION" \
    -H "X-Organization-Id: org_4b1e…" \
    -H 'Content-Type: application/json' \
    -d '{
      "name": "Support",
      "permissions": ["members:read", "customers:read", "customers:write"]
    }'
  ```

  ```bash Replace its permissions theme={null}
  curl -s -X PUT $API/v1/organization/roles/{id} \
    -H "Authorization: Bearer $UK_SESSION" \
    -H "X-Organization-Id: org_4b1e…" \
    -H 'Content-Type: application/json' \
    -d '{ "name": "Support", "permissions": ["customers:read"] }'
  ```
</CodeGroup>

Both need `roles:manage`.

<Warning>
  `PUT` **replaces** the permission set, it does not merge. The editor sends the
  full set every time — a merge would make removing a permission impossible.
</Warning>

An unknown permission is refused with `400 unknown_permission` rather than
silently dropped. Stored as-is it would sit in the database looking like access
nobody ever grants.

### Editing system roles

A system role's **permissions** can be edited — that is how an organization tailors
`admin` to itself. Its **name** cannot: `403 system_role`.

One rule is absolute: the `owner` role must keep `roles:manage`. Removing it
answers `403 owner_locked`. Without it an owner could edit themselves out of role
management and leave the organization with nobody able to grant it back.

### Deleting

A role that still has members is refused with `409 role_in_use` rather than
cascaded — those people would silently lose every permission. Move them first.
System roles cannot be deleted at all.

## How enforcement works

Permissions load with the session, at no extra round-trip, and every gated route
checks them before the handler runs:

```json 403 theme={null}
{ "error": { "code": "forbidden", "message": "your role does not allow this action" } }
```

Because they are resolved per request from the membership, **a permission change
takes effect on that user's very next request**. Nobody has to sign out and back
in.

The panel hides what a role cannot do. That is courtesy for the person using it —
the API is what actually refuses.
