Dashboard Write Auth
The dashboard requires HMAC auth on both tiers, using the sameapi_client_keys
infrastructure that guards /v3/*. Reads accept any role; writes require admin or
operator.
Overview
- Auth model: per-operator, per-request HMAC-SHA-256 (same canonical-string format as
/v3/*admin HMAC). Dashboard writer keys carryrole='operator'in the 3-role RBAC model;adminkeys are also accepted so operators do not need two keys. Agent keys are rejected. - Isolation: separation is by channel + route role, not a unique purpose. The dashboard
surface is exposed at
/dashboard/*(the globalHMACAuthMiddlewareonly guardssettings.api_prefix, i.e./v3/*), and each write route mountsrequire_dashboard_writer_hmac, which enforcesrole ∈ {admin, operator}.dashboard_writeris now only a log label (kept for forensic-line clarity), not an access-control scope. - Key store:
api_client_keystable (existing). A key is issued per operator workstation via the bootstrap CLI (it setspurpose='dashboard_writer'for the log label androle='operator'). - No secrets in bundle: the Vite build carries zero auth secrets. The operator pastes their
HMAC secret into the browser’s “Unlock writes” modal at session start; it is imported as a
non-extractable
CryptoKeyand lives only in memory.
Protected Routes
All dashboard routes require validX-QTG-* HMAC headers — the router attaches the reader
dependency to every route. Read (GET) routes accept admin, operator, or agent; the
write routes below require admin or operator.
Header Specification
Every dashboard write request must carry all four headers:
Missing or malformed headers →
401. Valid headers but key role ∉ → 403.
Replayed nonce (same key_id + nonce seen before) → 401.
Canonical String Format
The signature isHMAC-SHA-256(secret, canonical_string) where:
-
METHOD: uppercase HTTP verb (POST,PATCH, …). -
Path: the routed path — what the server dispatches on, percent-decoded, no origin
and no query string. Do not re-derive it by reassembling a URL: that round trip strips
CR/LF/TAB and honours
?/#, so distinct targets collapse onto one string (JEP-589). -
query_string: the query exactly as sent, with no leading?and no sorting. Empty string if no query.Changed 2026-08-16 (JEP-644). This was previously sorted by key and then by value. Sorting erased repeated-key order —
?status=a&status=band?status=b&status=aproduced one signature while the handler read different values — so a client must now transmit the exact bytes it signed rather than an equivalent reordering of them. A signer that still sorts will sendb=2&a=1while having signeda=1&b=2, and every such request 401s. -
sha256_hex(body_bytes): lowercase hex of SHA-256 over the raw UTF-8 request body bytes. For empty body, this is the SHA-256 of the empty string. -
timestamp: the value ofX-QTG-Timestamp. -
nonce: the value ofX-QTG-Nonce. -
Lines joined with
\n(LF only, no trailing newline).
canonical_string in
src/qtg/interfaces/api/middleware/auth/hmac.py, and the frontend mirror in
frontend/src/api/dashboardWriterAuth.ts (signRequest).
Cross-language test vectors: tests/fixtures/canonical_string_vectors.json.
Key Issuance
Issue one key per operator workstation using the bootstrap CLI:src/qtg/interfaces/tools/bootstrap_dashboard_writer_key.py.
Output is JSON containing key_id and hmac_secret. Store both in a password manager. The
secret is stored on the server as plaintext in the api_client_keys.hmac_secret column —
anyone with read access to the application DB can recover it. Protect your application DB at
rest (filesystem encryption, restricted file permissions, encrypted backups). Treat suspected
DB-read access as a key-compromise event and revoke affected keys via the SQL UPDATE in the
“Key Rotation and Revocation” section below.
Default quota: MG_DASHBOARD_WRITER_EXPECTED_KEY_COUNT=8 active keys. Raise for larger teams.
Key Rotation and Revocation
There is no TTL. To revoke (lost device, suspected compromise, operator offboarding):--key-id.
Browser Unlock UX
- The dashboard nav bar shows 🔒 Unlock writes when session is locked.
- Click to open the Unlock Writes modal.
- Enter
key_idandhmac_secret. 1Password / Bitwarden autofill works if you saved a Login item for the dashboard origin URL;autocomplete="username"andautocomplete="current-password"attributes are set on the respective inputs. - Optionally check “Remember Key ID on this device” — stores only the
key_idstring inlocalStorage; the secret is never persisted anywhere. - Click Unlock. The secret is imported as a non-extractable
CryptoKey(extractable=false) and lives in memory only for the browser session. - The nav indicator changes to 🔓 Unlocked as dw_alice_desk.
CryptoKey).
The key_id hint remains in localStorage if “Remember” was checked, prefilling the next unlock
modal.
If a write request is attempted while locked, a DashboardWritesLockedError is thrown and the
unlock modal opens automatically.
Forensic Surfaces
1. Auth-log line (universal — all dashboard write routes today). On every successful HMAC verification,require_dashboard_writer_hmac emits one INFO-level
log line:
path field includes the concrete resource ID (e.g., /dashboard/movements/abc-123/approve),
enabling direct grep by movement/transfer/authority ID without a timestamp join. Forensic
correlation across a log aggregator and the HTTP access log proceeds via request_id, which is
also echoed in the X-Request-ID response header.
2. RegistryAuditEvent DB row.
The allowlist write routes (/dashboard/allowed-addresses*) and the authority routes write a
RegistryAuditEvent row carrying the actor’s client_id / key_id. Its source_ip,
user_agent, and request_id columns are nullable and still NULL on those rows — none of the
constructors populates them, so the auth-log line below is where the request envelope lives.
Known Limitations
1. JS signing-oracle XSS surface. The HMAC secret passes through the JS heap duringunlockSession() before being imported as a
CryptoKey. Active XSS during that import window can read the raw bytes. After import,
crypto.subtle.exportKey() is rejected (extractable=false), blocking direct exfiltration
to a remote attacker — but active same-origin XSS can still use the live CryptoKey as a
signing oracle, generating valid write requests for as long as the page is open. This is
materially better than the prior bundle-baked Bearer token (which any dashboard reader could grep
and replay anywhere, anytime), but it is not a substitute for proper XSS hygiene and is not
equivalent to server-side session auth.
2. Workstation loss.
If the operator checked “Remember Key ID”, the key_id hint persists in localStorage. The
secret does not persist anywhere. A lost device requires CLI revocation
(uv run python -m qtg.interfaces.tools.revoke_auth_key --key-id <id>) to invalidate the key.
3. Read auth can be disabled, but only outside production.
MG_DASHBOARD_READ_AUTH_ENABLED=false is honored only when MG_ENVIRONMENT is one of
local / development / dev / test / ci. Any other value — including a typo — keeps
read auth enforced. Public exposure of the dashboard origin is still not supported by this
design. See docs/runbooks/operator-network-access.md for network boundary guidance.
4. No rate limiting on dashboard write routes today.
The global RateLimiter covers /v3/* only. Dashboard write routes have no rate limiting.
Defense-in-depth via reverse proxy (e.g., Nginx limit_req, Tailscale ACL) is the available
mitigation until a future spec extends rate limiting to /dashboard/*.
5. Log retention dependency for all dashboard write forensics.
Because the RegistryAuditEvent rows written by dashboard routes carry no request envelope
(no source_ip / user_agent / request_id), the auth-log line is the universal forensic
record linking a write to a workstation identity. Default Docker json-file
log driver rotation can discard logs before an incident window opens. Recommended minimum:
max-size=100m, max-file=10, or forward to a log aggregator (Loki, CloudWatch, etc.) with
at least 30-day retention. Operators who omit this lose workstation forensics for every
dashboard write route.
6. Server-side session/SSO is the durable XSS-resistant end state.
The HMAC model above (known limitation #1) is the correct design for the assumed deployment
surface (small operator team behind a LAN/VPC/Tailscale boundary). If the threat model changes —
dashboard exposed beyond trusted operator workstations, or a client-side XSS surface is
introduced — the migration is mechanical: replace require_dashboard_writer_hmac with a
session middleware and HTTP-only cookie reader. The role='operator' gate and forensic
columns (source_ip, user_agent, request_id) transfer cleanly.
Dashboard auth v2 — server-side session/SSO plus a body-size guard — is tracked as separate work.
7. Plaintext HMAC secrets on disk.
api_client_keys.hmac_secret is stored as plaintext text in the application DB. This matches
the existing /v3/* admin HMAC key infrastructure. DB-read access (e.g., a leaked backup) is
equivalent to dashboard-write capability. Encrypt your application DB at rest and limit
backup access. Encrypted-at-rest secret storage is out of scope for this release — a future
spec may add hmac_secret_ciphertext with KMS decryption in _resolve_key.
8. No body-size guard before signature verification.
verify_hmac_request reads the full request body into memory via await request.body()
before computing the canonical string and checking the signature. An unauthenticated actor
with network access (LAN / VPC / Tailscale) can submit oversized invalid writes to consume
memory/CPU. Mitigation: deploy behind a reverse proxy (nginx, Caddy, Tailscale serve) with
a client_max_body_size cap, OR rely on uvicorn’s defaults. A future spec may add a
Content-Length-aware middleware that rejects oversized requests with 413 before reaching
the HMAC layer.
Migration from Bearer (v1)
The legacyMG_DASHBOARD_WRITE_TOKEN bearer-token model is removed. The server now
hard-fails at startup if MG_DASHBOARD_WRITE_TOKEN is present in the environment — even when
set to an empty value (MG_DASHBOARD_WRITE_TOKEN=). Remove it entirely and issue per-operator
HMAC keys instead.
See docs/runbooks/dashboard-auth-v1-to-hmac.md for the step-by-step migration guide.