Qirava DMS docs
Studio RBAC: role hierarchy & screen permissions
Studio's role-based access control is built on four ranked governance roles — custodian (3), admin (2), user (1), guest (0) — and a fixed map from each screen to the minimum role it requires. The same predicate, screen.can_access(role), drives three independent layers of enforcement: the sidebar hides screens you cannot reach, each page handler returns a real 403 page if you navigate to a forbidden screen directly, and — the only layer that is actually a security boundary — every mutation re-calls the governance function server-side with your role in ctx, deny-by-default. The first two layers are UX and defense-in-depth; the third is the real gate, and it lives in the engine, not in Studio. This page documents the role hierarchy, the screen access matrix, the three layers, and how db_grants scope what a plain user sees.
The role hierarchy#
There are four built-in governance roles, ranked by privilege. role_rank maps a role name to a rank; anything unrecognized (including the empty string) ranks as 0 — guest — so an unknown or absent role can never out-rank a real one. A capability is satisfied when the caller's rank is at least the capability's minimum rank.
| Role | Rank | Capability | Meaning |
|---|---|---|---|
| custodian | 3 | Custodian | The top governance tier: mint custodians/admins, define roles. |
| admin | 2 | Admin | Manages users within their grants, deploys workers. |
| user | 1 | User | Can hold per-db grants; reads/acts within them. |
| guest | 0 | Authed | Any authenticated principal; the floor. |
| (unknown) | 0 | — | Ranks as guest — reaches nothing above the Dashboard. |
pub fn role_rank(role: &str) -> u8 {
match role {
"custodian" => 3,
"admin" => 2,
"user" => 1,
"guest" => 0,
_ => 0, // unknown/absent ranks as guest
}
}The screen access matrix#
Each Screen declares the Capability (minimum role) it requires via Screen::capability(). This is the single source of truth that both the sidebar and the handlers gate on, so what is shown and what is enforced can never drift.
| Screen | Required capability | custodian | admin | user | guest |
|---|---|---|---|---|---|
| Dashboard | Authed (any signed-in) | yes | yes | yes | yes |
| Users & RBAC | Admin | yes | yes | no | no |
| Databases | User | yes | yes | yes | no |
| Workers & Apps | Admin | yes | yes | no | no |
| Governance | Custodian | yes | no | no | no |
Within Users & RBAC the Promote-to-admin form is shown only to a custodian, because promotion is a custodian-only action. Create user and Set DB grant are visible to admins and custodians. On the Governance screen, Create custodian and Define role are custodian-only by virtue of the whole screen being custodian-only.
Three layers of enforcement#
RBAC is enforced in three places. Two are convenience and defense-in-depth; one is the actual boundary.
Layer 1 — NAV HIDE (UX) sidebar renders a screen only if
screen.can_access(role). Hides dead links.
Layer 2 — HANDLER 403 (DiD) each page handler re-checks the SAME
predicate; a forbidden direct URL gets a
real 403 page, not a blank screen.
Layer 3 — GOVERNANCE FN (REAL) every mutation calls the governance fn
WITH ctx.role; the fn re-checks the role
server-side, deny-by-default. THIS is the
security boundary — in the engine, not Studio.Layer 1 lives in StudioShell::sidebar: the maintainer's rule is "don't show the navigation if you don't have access." Layer 2 lives in each page handler's gated() preamble: require_user resolves {user, role} from ctx (or redirects to login), then require_access enforces the screen's capability or returns forbidden_page with HTTP status 403. Layer 3 lives in the governance functions; Studio's action handlers add a cheap require_min check first so the UI fails fast, but that check is explicitly NOT the boundary — the fn re-checks regardless.
Layer 2 in action: a 403 on direct navigation#
A plain user who types /studio/users does not get a blank or partial page — require_access returns a real 403 document rendered inside the normal shell (so the already-RBAC-filtered nav stays consistent), with a Danger alert naming exactly what the route requires. The HTTP status 403 is carried via the __headers X-Status seam.
Request
GET /studio/users HTTP/1.1
Cookie: qdms_session=<a 'user' session>Response
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
Cache-Control: no-storeOutput
<!doctype html>… (rendered inside the StudioShell)
<h1>Access denied</h1>
<div class="alert danger">
403 — access denied. "Users & RBAC" requires role 'admin' or higher;
you are signed in as 'user'.
</div>
<p>You do not have permission to view this screen.
Ask a custodian or admin if you believe this is an error.</p>The other forbidden combinations behave identically: a guest gets 403 on Databases, and an admin gets 403 on Governance (custodian-only). When access IS allowed the same handler renders the screen at HTTP 200.
Layer 3 in action: the real gate#
This is the part that makes Studio safe. When you submit a governance form, Studio's action handler builds a request record for the governance fn with the caller's role injected into ctx the SAME way the worker funnel does — NOT as a spoofable top-level field — plus the form's string fields, then executes the fn through the runtime. The fn owns the authorization decision.
// gov_record puts role/user under ctx — never at top level (cannot spoof).
fn gov_record(caller: &Caller, fields: &[(&str, &str)]) -> Record {
let mut rec = Record::new().with("ctx", Value::Map(vec![
("role".to_string(), Value::Str(caller.role.clone())),
("user".to_string(), Value::Str(caller.user.clone())),
]));
for (k, v) in fields { rec = rec.with(*k, Value::Str((*v).to_string())); }
rec
}Request
POST /studio/actions/set-grant HTTP/1.1
Cookie: qdms_session=<admin session>
Content-Type: application/x-www-form-urlencoded
_csrf=<csrf>&username=bob&db=sales&perm=read_writeResponse
// Studio calls auth.set_db_grant with this record:
{
"ctx": { "role": "admin", "user": "ada" },
"username": "bob", "db": "sales", "perm": "read_write"
}Output
// On the fn returning Ok:
HTTP/1.1 302 Found
Location: /studio/users?flash=Set+sales=read_write+for+'bob'
// On the fn denying (e.g. role too low) it returns the fn's message:
HTTP/1.1 302 Found
Location: /studio/users?error=<the+fn's+message>| Form action | Governance fn | Min role (fn re-checks) | Redirects back to |
|---|---|---|---|
| /studio/actions/create-user | auth.create_user | admin | /studio/users |
| /studio/actions/set-grant | auth.set_db_grant | admin | /studio/users |
| /studio/actions/promote-admin | auth.promote_admin | custodian | /studio/users |
| /studio/actions/create-custodian | auth.create_custodian | custodian | /studio/governance |
| /studio/actions/define-role | auth.define_role | custodian | /studio/governance |
db_grants: scoping what a user sees#
A user's per-database permissions live in the db_grants field of their _sys_users row — a CSV of db:perm pairs, where perm is one of read, write, read_write, or none. Studio uses these grants on the Databases screen to decide what to show. A plain user sees ONLY the databases named in their own (non-none) grants. An admin or custodian sees every database referenced by deployed workers, with their own grant noted (or "admin" since they bypass per-table grants), plus a read-only table inventory.
- db_grants format
- CSV of db:perm, e.g. sales:read_write,hr:read. perm ∈ {read, write, read_write, none}. Set "none" via the Set DB grant form to revoke.
- Plain user view
- visible_databases filters the user's own grants to the non-none ones — they see only what they were granted.
- Admin/custodian view
- all databases referenced by workers' dbs field, plus any granted db not referenced by a worker, plus the Tables inventory.