> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Control Plane Auth

> How QTG authenticates and authorizes service-to-service callers using HMAC signatures, role-scoped keys, and nonce replay protection.

# Control Plane Auth

## What is this auth for?

QTG v3's inbound auth is not a **user login system** — it is **control plane access control**.

The questions it answers are:

1. Did this request really come from a **registered system**?
2. Does this key have **permission to perform this action**?
3. Was the request body **tampered with in transit**?
4. Is this request a **replay of a previous one**?

In other words, this is not multi-tenancy — it's a **service-to-service command protection layer**.

## Who are the callers?

Examples of systems that act as callers:

* `signal-bot` — creates movements (`operator` or `agent`)
* `dashboard` — approves/rejects (`operator`)
* `ops-cli` — retries/resumes/cancels (`operator`)
* `admin-cli` — signer rotation + registry status control (`admin`)
* `read-monitor` — status queries (`operator`)

These systems **don't see different data** — they share the same control plane but differ only in **what actions they are allowed to take**. The exception is the `agent` role, which is additionally tenant-isolated to a single namespace (see [The agent role and namespace isolation](#the-agent-role-and-namespace-isolation)).

## Authentication method

Required headers:

* `X-QTG-Key-Id`
* `X-QTG-Timestamp`
* `X-QTG-Nonce`
* `X-QTG-Signature`

Canonical string:

```text theme={null}
METHOD
PATH
QUERY
SHA256(raw_body_bytes)
TIMESTAMP
NONCE
```

* `PATH` is the routed, percent-decoded path the server dispatches on — no origin, no query string.
* `QUERY` is the query **exactly as sent**: no leading `?`, **no sorting**, empty string if absent.
  Sorting before signing transmits bytes you did not sign, and every such request 401s.

The caller computes HMAC-SHA256 over this canonical string and sends it as `X-QTG-Signature`.

QTG verifies in this order:

1. timestamp freshness check
2. `key_id` lookup
3. HMAC comparison
4. nonce uniqueness check
5. role check
6. (agent keys only) namespace match check

The signature is verified **before** the role check on purpose. The other way round, an
unauthenticated caller could probe any `key_id` against any route and read that key's role
off the 401-vs-403 split.

## Why is a nonce needed?

With only a timestamp, it's possible to "resend the same request within the 5-minute window."

For example:

* `approve movement X`
* An attacker captures the same request and resends it 30 seconds later

To prevent this, a **nonce ledger** is required.

QTG records `(key_id, nonce)` in the DB, and if the same combination arrives again it is rejected with `401 replay detected`.

## Role model

Authorization is a **3-role axis**. Every API client key carries exactly one `role`, constrained at the DB level to one of:

* `admin` — full control (template/signer/executor mutation, registry, rotation)
* `operator` — approve/reject, retry/resume/cancel, reads, capital transfers
* `agent` — propose movements within a single tenant namespace, read its own status

> This replaced the older 6-"purpose" model (`read`/`write`/`approval`/`operate`/`admin`/`all`). The `purpose` column survives as a label only; the live authorization axis is **roles**, and the seed CLI accepts only `--role`.

Authority is a fixed route catalog, keyed by `(method, path) → allowed roles`:

| Route                                                                                       | Allowed roles          |
| ------------------------------------------------------------------------------------------- | ---------------------- |
| `POST /v3/movements`, `GET /v3/movements`, `GET /v3/movements/{id}` · `/timeline`           | admin, operator, agent |
| `GET /v3/balances`, `GET /v3/route-catalog`, `POST /v3/routes/recommend`, `GET /v3/whoami`  | admin, operator, agent |
| `POST /v3/movements/{id}/approve` · `/reject` · `/actions/retry` · `/resume` · `/cancel`    | admin, operator        |
| `GET /v3/signers`, `GET /v3/executors`, `GET /v3/audit/events`, `GET /v3/registry/audit`    | admin, operator        |
| `/v3/capital-transfers` (all verbs)                                                         | admin, operator        |
| `POST` / `PATCH /v3/plan-templates`, `PATCH /v3/executors/{key}`, `PATCH /v3/signers/{key}` | admin                  |
| `POST /v3/template-proposals/{id}/promote`, `/v3/agent-wallet-funding/envelopes` writes     | admin                  |
| `POST /v3/agent-authorities/{key}/template-proposals` · `/bridge-attempt`                   | agent only             |

Reads are not automatically the loosest tier — `GET /v3/signers` is admin+operator while `GET /v3/movements` is open to agents. The table prevents, for example, an `agent` key from approving its own proposal, or an `operator` key from mutating a signer registry entry.

<Note>
  The catalog is extended at boot. Pro registers its own routes (Stargate chain and path-baseline admin, auto-approve policies) through the same `(method, path) → roles` mechanism, so an OSS deployment simply has fewer entries — never looser ones.
</Note>

## The agent role and namespace isolation

The `agent` role is the only one with tenant isolation. An `agent` key is bound 1:1 to a network-scoped authority. The control plane enforces that the authority / namespace in the request path matches the authority the key is bound to — a mismatch is rejected with `403 agent_namespace_mismatch` **before** the handler runs. So even a leaked agent key can only act within its own namespace, and only to *propose* (approval still gates execution unless an auto-approve policy is in scope).

## Storage structure

Keys and replay-protection state are backed by a DB-based registry:

* a record per registered client system
* a record per issued key, looked up directly by `key_id`
* a nonce ledger that records seen `(key_id, nonce)` pairs for replay defense

## Two authentication channels

QTG has two independent authentication channels:

| Channel        | Path           | Auth method                                                                           | Purpose                       |
| -------------- | -------------- | ------------------------------------------------------------------------------------- | ----------------------------- |
| HMAC API       | `/v3/*`        | HMAC-SHA256 signature (4 headers) + role check                                        | Programmatic API access       |
| Dashboard HMAC | `/dashboard/*` | Per-operator HMAC v3 (nonce + timestamp); browser holds a non-extractable `CryptoKey` | Dashboard UI write protection |

These two are separate channels and do not cross. Dashboard UI uses `/dashboard/*` routes; programmatic callers like Hummingbot and signal-bot use `/v3/*` routes. Dashboard **write** keys have `role=operator` (the `dashboard_writer` label survives only for log clarity). The old `MG_DASHBOARD_WRITE_TOKEN` / `VITE_DASHBOARD_WRITE_TOKEN` bearer-token model has been **removed** — the server hard-fails at startup if `MG_DASHBOARD_WRITE_TOKEN` is set. See the [dashboard auth migration runbook](/runbooks/dashboard-auth-v1-to-hmac).

***

## Caller Inventory & Naming Matrix

### Naming Convention

**Key ID pattern**: `{client-name}-{environment}`

| Element           | Rule                                          | Example                      |
| ----------------- | --------------------------------------------- | ---------------------------- |
| `client-name`     | lowercase hyphenated, matches the system name | `strategy-engine`, `ops-cli` |
| `environment`     | `staging` or `prod`                           | `staging`, `prod`            |
| suffix (optional) | only when the same client needs multiple keys | `dashboard-staging-v2`       |

**Client Name**: stays identical across environments. Must match the `--client-name` argument of the seed CLI.

### HMAC Caller Matrix

| Client Name       | Key ID (staging)          | Role                                              | Actual system                      | Usage                                                     |
| ----------------- | ------------------------- | ------------------------------------------------- | ---------------------------------- | --------------------------------------------------------- |
| `strategy-engine` | `strategy-engine-staging` | `operator` (or `agent` for namespace-scoped bots) | Hummingbot controller / signal bot | Create movements (signal)                                 |
| `dashboard`       | `dashboard-staging`       | `operator`                                        | Dashboard (HMAC path)              | Approve/reject movements                                  |
| `ops-cli`         | `ops-cli-staging`         | `operator`                                        | Operations CLI scripts             | Retry/resume/cancel                                       |
| `admin-cli`       | `admin-cli-staging`       | `admin`                                           | Admin CLI scripts                  | Signer rotation, registry management, auto-approve policy |
| `read-monitor`    | `read-monitor-staging`    | `operator`                                        | Monitoring/alerting system         | Status queries, polling (reads need the operator role)    |

There is no `read`-only role: GET routes are reachable by `operator` (and many by `agent`). Use `operator` for read/monitor callers.

***

## Seed procedure

```bash theme={null}
# Staging seed example
uv run python -m qtg.interfaces.tools.seed_auth_client \
  --client-name strategy-engine \
  --key-id strategy-engine-staging \
  --role operator \
  --database-url "$MG_DATABASE_URL"

# Prod seed example (change DB URL)
uv run python -m qtg.interfaces.tools.seed_auth_client \
  --client-name strategy-engine \
  --key-id strategy-engine-prod \
  --role operator \
  --database-url "$MG_PROD_DATABASE_URL"
```

<Warning>
  The `hmac_secret` printed to stdout is only visible at this moment. Store it in a secure location immediately.
</Warning>

Key lifecycle management CLI:

```bash theme={null}
# List all registered keys
uv run python -m qtg.interfaces.tools.list_auth_keys \
  --database-url "$MG_DATABASE_URL"

# Revoke a specific key
uv run python -m qtg.interfaces.tools.revoke_auth_key \
  --key-id dashboard-staging-v1 \
  --database-url "$MG_DATABASE_URL"
```

***

## Secret Storage Policy

### Principles

1. **Complete isolation between environments**: staging and prod use separate secrets. Never share them.
2. **Least privilege**: each caller is issued a key with the narrowest role it needs (`agent` \< `operator` \< `admin`). Reserve `admin` for the admin CLI only.
3. **Store immediately on creation**: the secret printed by the seed CLI is one-time only. Store it in a secure location immediately.
4. **No git commits**: never commit `.env` files, seed artifacts, or any file containing a secret.

### Storage guide by environment

| Environment | Storage method                      | Access control       | Backup                             |
| ----------- | ----------------------------------- | -------------------- | ---------------------------------- |
| local dev   | `.env` file (gitignored)            | Developer only       | Not required                       |
| staging     | `.env.staging` on host, `chmod 600` | Operator access only | Optional                           |
| prod        | `.env.prod` on host, `chmod 600`    | Operator access only | Encrypted separate backup required |

### Management by secret type

| Secret type               | How generated                                     | Where stored                                                        | Rotation impact                         |
| ------------------------- | ------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------- |
| HMAC caller secret        | `seed_auth_client` CLI (auto 64-char hex)         | Caller's `.env` + DB (api\_client\_keys)                            | Zero-downtime possible                  |
| Dashboard writer HMAC key | `seed_auth_client --role operator` (per operator) | DB (api\_client\_keys); browser holds a non-extractable `CryptoKey` | Zero-downtime (per-operator key revoke) |
| Callback HMAC secret      | `openssl rand -hex 32`                            | `.env` (sender) + receiver config                                   | Both sender and receiver must rotate    |
| Exchange API keys         | Exchange console                                  | `.env`                                                              | Container restart required              |

### Rotation policy

* **Scheduled rotation**: once per quarter, or immediately upon suspected secret exposure
* **HMAC key rotation (zero-downtime)**:
  1. Check current keys with `list_auth_keys`
  2. Seed a new key for the same client (`seed_auth_client --key-id {client}-{env}-v{N+1}`)
  3. Deploy the new secret to the caller (update `.env` + restart)
  4. Smoke test with the new key
  5. Revoke the old key with `revoke_auth_key --key-id {old-key-id}`
* **Dashboard writer rotation**: re-seed the operator's HMAC key and revoke the old one — no frontend rebuild (the key is no longer baked into the bundle)
* **Emergency rotation**: execute immediately, accepting service interruption

***

## One-line summary

> QTG v3 auth is a layer that enforces "who can send what kind of command to this control plane" via HMAC + role + nonce (+ namespace isolation for agent keys).
