Skip to content

Users and RBAC

Otherix uses fixed roles defined in code. There are no custom roles and no dynamic permission editing through the API. This guide covers the four roles, how to manage users and API tokens, and the visibility rule that governs 404 vs 403. For the complete permission table, see the RBAC matrix.

The four roles

A user has exactly one role, stored in users.role and carried in the JWT role claim:

Role What it can do
admin Full system access, including users, nodes, firmware, storage pools, and networks. Sees everything.
operator Full VM and network management on behalf of users; node maintenance (cordon/uncordon). Not user management, node lifecycle, firmware, or storage-pool lifecycle.
developer Manages own VMs and own snapshots; reads infrastructure for context. Cannot operate other users' VMs and cannot migrate.
viewer Read-only across visible resources. No mutation, no console access.

Scopes: own vs any

Permissions on resources that carry an owner_id (VMs, snapshots) come in two scopes:

  • own - the resource's owner_id must equal the caller's user id.
  • any - all matching resources, regardless of ownership.

For example a developer holds vm:read at any scope (they can list every VM's name and metadata) but vm:lifecycle and vm:delete only at own scope (they can start or delete only their own VMs). Resources without an owner (networks, storage pools, firmwares, nodes) have a single meaningful scope - the permission is simply held or not. The full per-permission table is in the RBAC matrix.

Managing users: REST only

There is no otherix user CLI command

User CRUD is not exposed through the CLI. Create, list, update, and delete users via the Control Plane REST API directly. All of these endpoints require an admin bearer token.

The examples below use a bearer token in the Authorization header. That token can be an admin JWT (from POST /v1/auth/login) or an admin otx_* API token; the server selects the scheme by the otx_ prefix.

Create a user (admin)

POST /v1/users takes email, password, role, and an optional display_name:

curl -sS -X POST https://cp.example.com/v1/users \
  -H "Authorization: Bearer $OTHERIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "dev@example.com",
        "password": "correct-horse-battery",
        "role": "developer",
        "display_name": "Dev User"
      }'

Passwords are 12..256 characters (no composition rules). Valid role values are admin, operator, developer, viewer.

List, read, update, delete

# List users (cursor-paginated; optional ?email= exact-match filter)
curl -sS https://cp.example.com/v1/users \
  -H "Authorization: Bearer $OTHERIX_TOKEN"

# Get one user by UUID
curl -sS https://cp.example.com/v1/users/<uuid> \
  -H "Authorization: Bearer $OTHERIX_TOKEN"

# Update (display_name, password, or - admin only - role)
curl -sS -X PATCH https://cp.example.com/v1/users/<uuid> \
  -H "Authorization: Bearer $OTHERIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "operator"}'

# Soft-delete a user (refused while they still own resources)
curl -sS -X DELETE https://cp.example.com/v1/users/<uuid> \
  -H "Authorization: Bearer $OTHERIX_TOKEN"

Any user can read and update themselves through GET /v1/users/me and PATCH /v1/users/me (the latter for display_name and password; a user cannot change their own role).

API tokens

API tokens are long-lived otx_* credentials, an alternative to short-lived JWTs for CLI and automation. The plaintext is shown exactly once on creation; the server stores only its SHA-256.

Via the CLI

otherix config add cluster runs a one-time bootstrap: it logs in with an email and password, creates a long-lived API token named otherix-cli-<cluster> via POST /v1/users/me/api-tokens, and stores the (server, token) pair in your CLI config so subsequent commands authenticate automatically:

otherix config add cluster \
  --name prod \
  --server https://cp.example.com \
  --login me@example.com
# password prompted interactively, or pass --password / OTHERIX_PASSWORD

Re-run with --force to replace an existing cluster entry (it best-effort revokes the previous token server-side first).

Via the REST API

POST /v1/users/me/api-tokens issues a token for the calling user. The token field in the response is the plaintext, returned only this once:

curl -sS -X POST https://cp.example.com/v1/users/me/api-tokens \
  -H "Authorization: Bearer $OTHERIX_JWT" \
  -H "Content-Type: application/json" \
  -d '{"name": "ci-runner", "expires_at": null}'
# => { "id": "...", "name": "ci-runner", "prefix": "otx_abcd", "token": "otx_...", ... }

List your tokens with GET /v1/users/me/api-tokens and revoke one with DELETE /v1/users/me/api-tokens/{token_id}. Admins can manage tokens on behalf of any user through the /v1/users/{id}/api-tokens endpoints.

The 404-not-403 visibility rule

Otherix never leaks the existence of a resource a caller cannot see. When a resource exists but belongs to another user and the caller's scope is own, the API returns 404 not_found, not 403. Returning 403 in that case would reveal that the resource exists.

403 permission_denied is reserved for the case where the caller can see a resource but their role lacks the capability to act on it - for example a developer viewing their own VM but lacking vm:migrate. The error payload's details.required_permission names the missing permission.

In short:

  • Resource you should not even know about -> 404 not_found.
  • Resource you can see but cannot act on -> 403 permission_denied.