Skip to content

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.

  1. Mint a replacement: auth.create_api_key with the same scope (and, for non-admin, the same grants CSV).
  2. Deploy the new key/secret to clients.
  3. Revoke the old key (disable or remove its _sys_api_keys row via the governance/admin path).
  4. For signed keys, the old sign_secret stops verifying as soon as the row is disabled (the lookup requires enabled = true).