> ## 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.

# Auth Migration: v1 to HMAC

> Migrate dashboard authentication from v1 to HMAC-based auth

# Migrating Dashboard Write Auth from Bearer (v1) to HMAC

This is a one-time migration when upgrading past the release that ships per-operator HMAC
authentication for dashboard write routes.

## Pre-flight

Note: the new fail-closed guard is in `create_app()` only. CLIs (`bootstrap_dashboard_writer_key`,
`alembic`, seed scripts, etc.) can still import `qtg.config.settings` while
`MG_DASHBOARD_WRITE_TOKEN` is still present in your environment. The OLD app container can keep
running on the OLD code during steps 1–2; the cutover happens at step 4.

> **Ordering matters**: Step 1 (apply the schema migration) MUST run before Step 2 (issue
> operator keys). The bootstrap CLI writes audit rows into `registry_audit_events.source_ip /
> user_agent / request_id`, columns that only exist after the migration. Running the CLI first
> on a pre-migration DB fails with `UndefinedColumn: source_ip` and aborts before any key is
> persisted. (Verified on the 2026-05-15 dev migration.)

## Steps

### 1. Apply the schema migration

Existing deployments use `Base.metadata.create_all()` at server startup, which creates new
**tables** only — it does NOT add new columns to existing tables. The Alembic migration for this
release adds `source_ip`, `user_agent`, and `request_id` columns to `registry_audit_events`:

```bash theme={null}
uv run alembic -c alembic_v3.ini upgrade head
```

> If `alembic` does not auto-load your `.env`, inject `MG_DATABASE_URL` explicitly so the
> migration runs against the correct PostgreSQL database:
>
> ```bash theme={null}
> MG_DATABASE_URL=$(grep -E '^MG_DATABASE_URL=' .env | head -1 | cut -d= -f2-) \
>   env MG_DATABASE_URL="$MG_DATABASE_URL" \
>   uv run alembic -c alembic_v3.ini upgrade head
> ```

### 2. Issue a key for each operator who needs dashboard write access

On the host running QTG:

```bash theme={null}
MG_CLI_BOOTSTRAP_ALLOWED=true \
uv run python -m qtg.interfaces.tools.bootstrap_dashboard_writer_key \
  --key-id "dw_alice_desk" \
  --description "Alice's primary workstation"
```

Capture the JSON output. Store `key_id` + `hmac_secret` in your password manager (a 1Password
vault entry saved under the dashboard origin URL is recommended — it enables browser autofill in
the Unlock modal).

Repeat for each operator. Each operator should have their own key so that revocation is
per-workstation, not fleet-wide.

### 3. Remove the legacy env var

Remove `MG_DASHBOARD_WRITE_TOKEN` from your `.env`:

```diff theme={null}
- MG_DASHBOARD_WRITE_TOKEN=...
```

If you skip this step, the server will refuse to start after the rebuild in step 4
(the fail-closed guard in `create_app()` fires and emits a clear error pointing to this runbook).

### 4. Rebuild and restart

Rebuild both the API and dashboard images so the bundle drops the old build args and the backend
picks up the new code:

```bash theme={null}
docker compose -f infra/docker-compose.yml build qtg-mainnet qtg-dashboard
docker compose -f infra/docker-compose.yml up -d qtg-mainnet qtg-dashboard
```

### 5. Unlock in browser

In each operator's browser, open the dashboard. Click **🔒 Unlock writes**.

* Paste `key_id` and `hmac_secret` from step 1. (1Password autofills if you saved a vault entry
  for the dashboard origin in step 1.)
* Optionally check **"Remember Key ID on this device"** — this stores only the `key_id` string in
  `localStorage`, never the secret. The secret lives only in memory as a non-extractable
  `CryptoKey` for the duration of the browser session.

### 6. Smoke test

> **Note**: in this release no dashboard HTTP route writes `RegistryAuditEvent` rows. The
> auth-log line emitted by `require_dashboard_writer_hmac` is the universal forensic surface
> for ALL dashboard write routes (movements, authorities, capital\_transfers, routes POST,
> auto\_approve). The `registry_audit_events` table's new forensic columns (`source_ip`,
> `user_agent`, `request_id`) are reserved-for-future use when a dashboard route is added
> that writes `RegistryAuditEvent`. Today the only writer is the bootstrap CLI which leaves
> them NULL.

To smoke-test forensics after a mutation (e.g., approve a pending test movement, or create
a dashboard authority from the UI; both go through the same auth dependency):

1. Perform the mutation from the dashboard and confirm the response is 200.
2. Find the matching `dashboard_writer_auth_ok` log line:
   ```bash theme={null}
   docker compose -f infra/docker-compose.yml logs qtg-mainnet | grep dashboard_writer_auth_ok | tail -1
   ```
3. Verify the line contains `request_id=<uuid>`, `key_id=<your operator key>`, `source_ip`,
   `user_agent`, `method`, and `path` (with the concrete resource id, e.g.,
   `path=/dashboard/movements/<id>/approve`).
4. Cross-reference `request_id` with the `X-Request-ID` response header from the browser
   DevTools Network tab for the same request.

## Revocation

If an operator leaves or a key is suspected compromised:

```sql theme={null}
UPDATE api_client_keys SET status = 'revoked' WHERE key_id = 'dw_alice_desk';
```

The next write attempt with that key returns 401. There is no grace period — revocation is
immediate on the next request.

To re-issue for the same operator, run the bootstrap CLI again with a new `--key-id`.
