Qirava DMS docs
API keys: minting, storage, scope & rotation
API keys are the credential for programmatic access to the QQL HTTP surface. An admin mints a key with auth.create_api_key; the server generates a random key plus a separate signing secret, stores only the key's SHA-256 hash (the signing secret is stored as-is for HMAC verification) in _sys_api_keys, and returns the plaintext key and secret exactly once. A key has a scope: an admin key bypasses all table grants, while any other scope is a non-admin key whose access is strictly the per-table grant CSV it was minted with. Grants on a live key can be changed at any time with auth.grant_table; rotation is mint-new-then-revoke-old.
The _sys_api_keys catalog#
Every minted key is a row in _sys_api_keys, a governance catalog. A row carries: id (the public key id), key_hash (SHA-256 of the plaintext key, for the X-API-Key lookup path), sign_secret (the symmetric HMAC secret for signed requests), scope, enabled, and table_grants (the grant CSV for non-admin keys).
- id / key_id
- 8-byte (16 hex) random public identifier. Used by signed requests (X-Key-Id) and by auth.grant_table (key_id).
- key_hash
- sha256_hex of the 32-byte (64 hex) plaintext key. The plaintext is NOT stored — only this hash. The X-API-Key / Bearer path hashes the presented key and matches against this.
- sign_secret
- A separate 32-byte (64 hex) symmetric secret for HMAC-signed requests. Stored AS-IS (cannot be one-way hashed — verification needs the material). Planned: encrypted at rest under the master seed.
- scope
- "admin" (bypasses table grants) or any other string (a non-admin "user" key, governed by table_grants). Defaults to "admin".
- table_grants
- CSV of table:grant pairs for non-admin keys (e.g. "users:read,orders:read_write"). Empty for admin keys (they bypass).
- enabled
- Boolean. Lookups require enabled = true; flipping it to false revokes the key.
Minting a key — auth.create_api_key (admin only)#
auth.create_api_key (key auth.create_api_key) mints a key. It takes scope (positional "0" or named "scope"; defaults to "admin") and grants (positional "1" or named "grants"). It generates a 32-byte random key, computes its SHA-256 hash, generates a separate 32-byte signing secret and an 8-byte id, stores the row in _sys_api_keys, and returns the plaintext key + secret ONCE.
For a NON-admin scope, a valid grant CSV is REQUIRED. An empty grants field is rejected ("grants required for non-admin keys"), and each entry must be table:grant with grant in read|write|read_write|none, else the mint fails with "invalid grant entry '<e>': want table:read|write|read_write|none". This up-front validation prevents minting a key that silently denies everything. Admin keys IGNORE grants entirely (they store an empty grant CSV).
Request
// auth.create_api_key input (admin is the default scope; grants ignored)
{ "scope": "admin" }Response
{
"api_key": "<64-hex plaintext key — shown ONCE>",
"id": "a1b2c3d4",
"key_id": "a1b2c3d4",
"sign_secret": "<64-hex signing secret — shown ONCE>",
"scope": "admin",
"table_grants": ""
}Output
Wrapped in the standard envelope when called over HTTP:
{ "error": null, "data": { …the record above… }, "root": { … } }Request
// auth.create_api_key input — grants REQUIRED and validated
{ "scope": "reporter", "grants": "users:read,orders:read_write" }Response
{
"api_key": "<plaintext key — shown ONCE>",
"id": "e5f6a7b8",
"key_id": "e5f6a7b8",
"sign_secret": "<signing secret — shown ONCE>",
"scope": "reporter",
"table_grants": "users:read,orders:read_write"
}Output
A bad grant CSV is rejected at mint time, never stored:
{ "error": { "code": "bad_request", "message": "invalid grant entry 'orders:rw': want table:read|write|read_write|none" }, "data": null, "root": { … } }How a key is presented and validated#
There are two authentication paths, both reading the same _sys_api_keys rows:
- Header/bearer path (auth.api_key): present the plaintext key in the X-API-Key header, or fall back to an Authorization: Bearer <key>. The gate hashes it (SHA-256) and looks up the enabled row, injecting { api_key_scope, table_grants } into ctx. Missing → "missing api key"; no match → "invalid api key".
- Signed path (auth.signed): no plaintext key on the wire; the client signs each request with the sign_secret. See HMAC-signed API keys & replay guard.
Request
curl -X POST http://localhost:8080/api/qql \
-H 'X-API-Key: <your-key>' \
--data 'SELECT name FROM users'Response
{ "error": null, "data": [ /* rows */ ], "root": { "took_us": 0, "request_id": "…" } }Output
The auth check reads X-API-Key first, then falls back to a Bearer token:
curl -X POST http://localhost:8080/api/qql -H 'Authorization: Bearer <your-key>' --data 'SELECT name FROM users'Admin scope vs scoped keys#
An admin-scoped key BYPASSES table grants entirely inside the core authorization decision — it can read and write any table, including writes to the deploy catalogs (_sys_routes/_sys_workers/_sys_jobs/_sys_pages/_sys_assets/_sys_functions/_sys_groups). Any other scope is a non-admin key whose reach is exactly its grant CSV; with no grant on a table (and no * wildcard) the table is invisible (deny-by-default).
Changing grants on a live key — auth.grant_table#
auth.grant_table (key auth.grant_table) lets an admin grant or revoke a single table on an existing key WITHOUT re-minting it. Input is { key_id, table, grant } with grant in read|write|read_write|none; grant: "none" revokes. It loads the key's current table_grants, upserts the table's entry, re-serializes the CSV SORTED by table (for determinism), and UPDATEs the row. key_id and table are required; an unknown key id → "no such api key"; an invalid grant → "invalid grant; want read|write|read_write|none".
Request
// auth.grant_table input
{ "key_id": "e5f6a7b8", "table": "orders", "grant": "read" }Response
{
"success": true,
"table": "orders",
"grant": "read",
"table_grants": "orders:read,users:read" // re-serialized, sorted by table
}Output
To revoke, set grant to "none":
{ "key_id": "e5f6a7b8", "table": "orders", "grant": "none" } → table_grants becomes "orders:none,users:read"Rotation#
There is no in-place secret rotation: the plaintext key and signing secret are write-once. To rotate, mint a NEW key with the same scope/grants, switch clients to it, then revoke the old one. Revoke by disabling (enabled = false) or deleting the old row — but remember the governance catalogs (including _sys_api_keys) are NOT writable via QQL, so revocation is an admin/governance operation, not something a tenant key can perform on itself.
- Mint a replacement: auth.create_api_key with the same scope (and, for non-admin, the same grants CSV).
- Deploy the new key/secret to clients.
- Revoke the old key (disable or remove its _sys_api_keys row via the governance/admin path).
- For signed keys, the old sign_secret stops verifying as soon as the row is disabled (the lookup requires enabled = true).