Skip to content

Qirava DMS docs

RBAC: four roles & invite-only onboarding

Qirava's governance hierarchy has four built-in roles, ordered by privilege: custodian > admin > user > guest. Onboarding is invite-only — there is no self-signup. A custodian is the root of trust (auto-created once on first install); custodians create other custodians, promote users to admin, and define custom roles; admins (and custodians) create users and guests and set their per-database grants. Every governance function is deny-by-default: it reads the caller's role from the request context (injected by auth.session) and refuses unless the caller outranks the operation.

The four roles#

RoleRankCapabilities
custodian3Root of trust. Can promote_admin, create_custodian, define_role, plus everything admin can do. The only role that can mint other custodians or admins.
admin2Can create_user (user/guest), set_db_grant. On the QQL surface, an admin-scoped API key bypasses table grants (but never the governance-catalog write deny).
user1An ordinary authenticated principal; access is governed by db/table grants.
guest0Lowest privilege; access is governed by grants. Default fallthrough for an unknown role name.

Role rank is computed by role_rank: custodian=3, admin=2, user=1, guest=0, and any unknown name → 0. is_known_role accepts only the four built-in names. The deny-by-default gate require_role_at_least(rec, minimum) rejects with "authentication required" if the caller's role is not a known role, or "role '<minimum>' required (caller is '<caller>')" if it ranks too low.

Invite-only onboarding (no self-signup)#

There is no public registration endpoint. New principals exist only because a sufficiently privileged caller created them. auth.login authenticates an EXISTING _sys_users row; it never creates one. The only way a user row appears is via a governance function (auth.create_user, auth.create_custodian) or the one-time bootstrap.

Who can create or promote whom#

auth.create_user (admin or custodian)
Requires caller rank >= admin. Creates a 'user' or 'guest' (role defaults to 'user') with optional db_grants. An admin may ONLY create user/guest; only a custodian caller may create another known role through this fn.
auth.set_db_grant (admin or custodian)
Requires caller rank >= admin. Upserts a user's per-database permission (db:perm, perm in read|write|read_write|none).
auth.promote_admin (custodian only)
Requires caller rank >= custodian. Sets a target user's role to 'admin'.
auth.create_custodian (custodian only)
Requires caller rank >= custodian. Creates a new user with the 'custodian' role.
auth.define_role (custodian only)
Requires caller rank >= custodian. Defines/updates a custom RBAC role {name, perms} in _sys_roles.
  custodian ──promote_admin──►  admin
      │   ──create_custodian──► custodian
      │   ──define_role──────►  custom role def
      │
  custodian / admin
      │   ──create_user──────►  user | guest
      │   ──set_db_grant─────►  user's db perms

  (login authenticates an EXISTING row; it never creates one)
Who creates / promotes whom

Creating a user (admin or custodian)#

auth.create_user requires caller rank >= admin (the gate reads ctx.role, set by auth.session). It inserts a _sys_users row with a freshly PBKDF2-hashed password, a random 8-byte id, the role (default "user"), and the optional db_grants CSV. The username must be unique — a duplicate → "username already exists". An admin caller asking for a role other than user/guest is denied ("admins may only create 'user' or 'guest'"); only a custodian may create another known role this way.

Request

// auth.create_user input (caller must be admin or custodian via ctx.role)
{ "username": "alice", "password": "s3cret-pass", "role": "user", "db_grants": "analytics:read" }

Response

{
  "success": true,
  "id": "3a7f1c9e",
  "username": "alice",
  "role": "user"
}

Output

Denied if an admin tries to create a privileged role:
{ "error": { "code": "forbidden", "message": "admins may only create 'user' or 'guest'" }, "data": null, "root": { … } }

Promoting to admin / minting custodians (custodian only)#

auth.promote_admin and auth.create_custodian both require caller rank >= custodian. promote_admin takes { username } and sets that existing user's role to admin (no such user → "no such user"). create_custodian takes { username, password } and creates a brand-new user with the custodian role.

Request

// auth.promote_admin input (caller must be custodian)
{ "username": "alice" }

Response

{ "success": true, "username": "alice", "role": "admin" }

Output

From a non-custodian caller the gate denies before any write:
{ "error": { "code": "forbidden", "message": "role 'custodian' required (caller is 'admin')" }, "data": null, "root": { … } }

Custom roles — auth.define_role (custodian only)#

auth.define_role (key auth.define_role, custodian only) upserts a role definition { name, perms } into the _sys_roles catalog: it deletes any existing row with that name and inserts the new definition. name is required; perms is an opaque permission string. This lets a custodian record named permission sets beyond the four built-ins. Note the four built-in role NAMES (custodian/admin/user/guest) are what the rank-based gates understand; custom role definitions are catalog data for higher-level authorization policy.

Request

// auth.define_role input (caller must be custodian)
{ "name": "analyst", "perms": "read:analytics,read:reports" }

Response

{ "success": true, "name": "analyst", "perms": "read:analytics,read:reports" }

Per-database grants — auth.set_db_grant#

auth.set_db_grant (admin or custodian) sets a user's permission on one database. Input { username, db, perm } with perm in read|write|read_write|none. It loads the user's db_grants CSV, upserts the db:perm entry, re-serializes SORTED by db, and updates the row. Unknown user → "no such user"; invalid perm → "perm must be read|write|read_write|none".

Request

// auth.set_db_grant input (caller must be admin or custodian)
{ "username": "alice", "db": "sales", "perm": "read_write" }

Response

{ "success": true, "db_grants": "analytics:read,sales:read_write" }

How the gate gets the caller's role#

Every governance function trusts the caller's role ONLY from ctx.role, which auth.session injects after validating the session and re-fetching the LIVE role from _sys_users. A role field placed in the request body is ignored (anti-spoofing). A caller_role top-level field is honored ONLY for in-process tests/trusted callers — the worker funnel never sets it. This is why Studio's governance mutation routes sit inside the session group: so auth.session populates ctx.role before the governance fn re-checks it.