Skip to content

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
Every read or mutate crosses the same three gates, in order
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).

rust
// 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 a

Response

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 b

Response

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 a

Response

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.

  1. 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.
  2. 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).
  3. 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).
  4. 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.
  5. 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 tokenPermits read (SELECT)Permits write (INSERT/UPDATE/DELETE/CREATE)
readyesno
writenoyes
read_writeyesyes
nonenono
(table absent, no * )nono

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.