Qirava DMS docs
Access model: three-tier authorization
Authorization in the DMS is not a single check — it is three checkpoints that every read and mutate must cross, in the same order, every time. L1 is the worker before-auth chain: it authenticates the caller and writes the principal into the shared request context. L2 is execute() itself: only Public functions can be scheduled by a worker, and internal helpers are gated by scope. L3 is the planner: database and table RBAC live here, and the planner is the only door to read or mutate — no write path is allowed to skip it. Everything is deny-by-default. This page walks the three gates end-to-end with a real keyed QQL request.
The three checkpoints#
Request Worker execute() Planner Tables + WAL
(browser/SDK/peer) before -> handle -> after bounded executor QQL plan -> exec storage
│ │ L1 │ L2 │ L3 │
└──────────────────┴───────────────────────┴───────────────────┴───────────────────┘
authenticate function scope table RBAC commit- L1 — Worker before-auth
- In functions/auth, the before-chain. It verifies a human session (auth.session) or an HMAC-signed key (auth.signed) or an X-API-Key (auth.api_key), then writes the caller's identity/scope/grants into the shared request context. A non-ok before short-circuits the whole pipeline (the handler never runs).
- L2 — Execute function scope
- execute() can only schedule a registered PUBLIC function. An internal function passed to execute() is rejected with AccessDenied. Inside a function, ctx.call/chain/all enforce internal scope: InternalGlobal is callable by anyone; InternalScoped { owner } only when the call tree's root_key equals owner; InternalPrivate { owner } only when the immediate caller (current_key) equals owner.
- L3 — The planner
- Database and table RBAC is enforced in the planner as app-scope INTERSECT principal-grant, deny-by-default. authorize_stmt classifies the statement (SELECT=read; INSERT/UPDATE/DELETE/CREATE=write; unknown keywords treated as write), resolves every table it touches, and denies unless the key holds a sufficient grant on ALL of them. The planner is the only read/mutate gate; no write path skips it.
L1: how before-auth wires in#
Authentication is plain before-functions bound to a route via a Group. The system worker's keyed variant attaches api_key_rbac_groups(): for the single-statement qql routes it runs auth.api_key then auth.check_table_grant in a Chain; for the batch route it runs only auth.api_key (the per-statement gate runs inside the batch handler instead).
// src/workers/mod.rs — the API-key RBAC groups
pub fn api_key_rbac_groups() -> Vec<Group> {
vec![
Group::new("auth_api_key_stmt")
.for_routes(&["qql.http", "qql.get"])
.before(ChainMode::Chain, vec![
Bound::wait(AUTH_API_KEY), // verify key, inject scope + table_grants
Bound::wait(AUTH_CHECK_TABLE_GRANT), // deny-by-default per-table gate (L3 logic)
]),
Group::new("auth_api_key_batch")
.for_routes(&["qql.batch"])
.before(ChainMode::Chain, vec![Bound::wait(AUTH_API_KEY)]),
]
}
// Build a system worker with the qql routes gated by those groups:
let worker = system_worker_rbac("0.0.0.0:8080");End-to-end example: a keyed QQL request#
This is the real behavior from the table-RBAC integration test. A non-admin key minted with the grant a:read may SELECT from table a but may not write it, and may not touch table b at all. An admin key (minted with empty grants) bypasses table grants entirely. Every verdict below is exactly what the running worker returns.
Request
POST /api/qql HTTP/1.1
X-API-Key: <read_a key>
SELECT * FROM aResponse
HTTP/1.1 200 OK
Content-Type: application/json
{"error":null,"data":[{"name":"ada",...}],"root":{...}}Output
// L1 auth.api_key matches the key, injects scope="user", table_grants="a:read".
// L3 authorize_stmt: op=read, table=a, grant a:read allows read -> granted.Request
POST /api/qql HTTP/1.1
X-API-Key: <read_a key>
INSERT INTO a { name: "x" }Response
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error":{"code":"forbidden","message":"table 'a': write denied"},"data":null,"root":{...}}Request
POST /api/qql HTTP/1.1
X-API-Key: <read_a key>
SELECT * FROM bResponse
HTTP/1.1 403 Forbidden
{"error":{"code":"forbidden","message":"table 'b': read denied"},"data":null,"root":{...}}Request
POST /api/qql HTTP/1.1
SELECT * FROM aResponse
HTTP/1.1 403 Forbidden
{"error":{"code":"forbidden","message":"missing api key"},"data":null,"root":{...}}L3: the authorize_stmt decision in detail#
authorize_stmt(scope, grants_csv, qql) is pure (no I/O) and is the load-bearing table-RBAC decision. The order of its tiers matters and is deliberate.
- Governance-catalog deny tier (runs BEFORE the admin bypass): any WRITE targeting _sys_users, _sys_sessions, _sys_roles, or _sys_api_keys is denied for EVERYONE via QQL, including admin keys. These catalogs are mutated only by the governance functions over the internal db.execute path, which does not pass through authorize_stmt. This closes the admin-API-key self-promotion gap.
- Admin bypass: if scope == "admin", return Ok — admin keys bypass table grants (and may write the DEPLOY catalogs: _sys_routes/_sys_workers/_sys_jobs/_sys_pages/_sys_assets/_sys_functions/_sys_groups).
- Target resolution: grant_tables(qql) resolves the FROM table plus every JOINed table for a SELECT, the single target for a write, and the created/indexed table for CREATE. If it cannot be determined, deny (fail closed).
- System-catalog write deny: a non-admin key writing any _sys_* table is denied — so a tenant cannot author scheduled jobs, mint keys, or reroute traffic.
- Per-table grant check: for every resolved table, the key must hold a sufficient grant (read for SELECT, write for a mutation; read_write satisfies both). An explicit per-table entry wins over a
*wildcard; absent both, DENY.
| Grant token | Permits read (SELECT) | Permits write (INSERT/UPDATE/DELETE/CREATE) |
|---|---|---|
| read | yes | no |
| write | no | yes |
| read_write | yes | yes |
| none | no | no |
| (table absent, no * ) | no | no |
Why three gates and not one#
The gates are independent and layered so that a mistake at one layer cannot defeat the others. L1 decides WHO you are. L2 decides WHICH function you may even reach (a worker can never schedule an internal planner helper directly — execute("planner.query", ...) returns AccessDenied). L3 decides WHAT data that function may read or mutate. The Studio admin UI has its own client-side capability map (role -> visible screens) but that is UX only; every read/mutate Studio performs still flows through L1/L2/L3 and is re-checked server-side.