Skip to main content

Dashboard Write Auth

The dashboard requires HMAC auth on both tiers, using the same api_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 carry role='operator' in the 3-role RBAC model; admin keys 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 global HMACAuthMiddleware only guards settings.api_prefix, i.e. /v3/*), and each write route mounts require_dashboard_writer_hmac, which enforces role ∈ {admin, operator}. dashboard_writer is now only a log label (kept for forensic-line clarity), not an access-control scope.
  • Key store: api_client_keys table (existing). A key is issued per operator workstation via the bootstrap CLI (it sets purpose='dashboard_writer' for the log label and role='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 CryptoKey and lives only in memory.

Protected Routes

All dashboard routes require valid X-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 is HMAC-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=b and ?status=b&status=a produced 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 send b=2&a=1 while having signed a=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 of X-QTG-Timestamp.
  • nonce: the value of X-QTG-Nonce.
  • Lines joined with \n (LF only, no trailing newline).
Reference implementation: 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:
Source: 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):
Revocation is immediate — the next write request with that key returns 401. To issue a replacement, run the bootstrap CLI with a new --key-id.

Browser Unlock UX

  1. The dashboard nav bar shows 🔒 Unlock writes when session is locked.
  2. Click to open the Unlock Writes modal.
  3. Enter key_id and hmac_secret. 1Password / Bitwarden autofill works if you saved a Login item for the dashboard origin URL; autocomplete="username" and autocomplete="current-password" attributes are set on the respective inputs.
  4. Optionally check “Remember Key ID on this device” — stores only the key_id string in localStorage; the secret is never persisted anywhere.
  5. Click Unlock. The secret is imported as a non-extractable CryptoKey (extractable=false) and lives in memory only for the browser session.
  6. The nav indicator changes to 🔓 Unlocked as dw_alice_desk.
Clicking the nav indicator while unlocked locks the session (clears the in-memory 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:
The 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 during unlockSession() 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 legacy MG_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.