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

# Deployment Hardening

> Concrete hardening artifacts and how to apply them

# Deployment Hardening Guide

This page covers three concrete hardening artifacts and how to apply them. For enterprise-readiness context (key management, compliance posture, signer rotation), see [Enterprise Readiness](/security/enterprise-readiness).

***

## Background

QTG is a self-hosted movement control plane. The operator deploys the stack; the copy-and-run scripts below are the security posture.

The foundational threat model is in [DB Access Control Boundary](/reference/security/db-access-control-boundary): the accepted boundary is "DB-write = game over." The controls here do not eliminate that boundary — they shrink blast radius and raise the attacker's effort floor.

***

## Artifact 1: Least-Privilege DB Role

**File:** [`infra/sql/qtg_least_privilege_role.sql`](https://github.com/jephalabs/quant-transfer-guard/blob/main/infra/sql/qtg_least_privilege_role.sql)

### What it does

Creates a `qtg_app` PostgreSQL role that can only do what the runtime application needs:

* `SELECT / INSERT / UPDATE / DELETE` on all app tables in `public` (global bucket) and `qtg_network` (per-network bucket)
* `USAGE + SELECT` on sequences (required for `SERIAL` / `GENERATED` columns)
* **Cannot** execute DDL (`CREATE`, `ALTER`, `DROP`, `TRUNCATE`)
* **Cannot** manage roles (`NOCREATEROLE`, `NOINHERIT`)
* **Cannot** access schemas it does not own

The DDL-owner role (`qtg`) remains separate and is used only by Alembic migrations — never in the runtime app. This maps directly to [DB Access Control Boundary](/reference/security/db-access-control-boundary) § Proportionate investment, item 1.

### Schema layout

| Physical schema | Alembic bucket         | Contains                                                           |
| --------------- | ---------------------- | ------------------------------------------------------------------ |
| `public`        | `global`               | `api_clients`, `api_client_keys`, `nonce_registry`, version tables |
| `qtg_network`   | `mainnet` or `testnet` | All movement, executor, signer, audit tables                       |

### How to apply

```bash theme={null}
# 1. Apply as superuser / DDL owner (once per DB):
psql "$MG_DATABASE_URL" -f infra/sql/qtg_least_privilege_role.sql

# 2. Set a strong password for the new role (do not commit password):
psql "$MG_DATABASE_URL" -c "ALTER ROLE qtg_app LOGIN PASSWORD '<strong-random>';"

# 3. Update the app DATABASE_URL to use qtg_app:
#    MG_DATABASE_URL=postgresql+asyncpg://qtg_app:<password>@host:5432/qtg_v3
```

### Adding new tables

When a new Alembic migration adds a table, add a corresponding `GRANT ... ON TABLE ... TO qtg_app` in the SQL file (or rely on the `ALTER DEFAULT PRIVILEGES` clause at the bottom of the script, which auto-grants for tables created by the `qtg` DDL role going forward).

### Future hardening

The audit tables (`movement_events`, `registry_audit_events`, etc.) currently receive full DML. A future pass can add an `audit_writer` role that has `INSERT`-only access, so the runtime app cannot rewrite audit history even if compromised.

***

## Artifact 2: Hardened Compose Overlay

**File:** [`infra/docker-compose.hardened.yml`](https://github.com/jephalabs/quant-transfer-guard/blob/main/infra/docker-compose.hardened.yml)

### What it does

A Docker Compose overlay that tightens every service in the base compose without modifying it. Use it by passing both files to `docker compose`:

```bash theme={null}
docker compose \
  -f infra/docker-compose.yml \
  -f infra/docker-compose.hardened.yml \
  up -d
```

Controls applied per service:

| Control                      |                 postgres                 |    qtg-mainnet    | qtg-testnet | ccip-sidecar |           qtg-dashboard          |
| ---------------------------- | :--------------------------------------: | :---------------: | :---------: | :----------: | :------------------------------: |
| Non-root user                |                    999                   |        1000       |     1000    |     1000     |                101               |
| `read_only: true`            |                     —                    |        yes        |     yes     |      yes     |                yes               |
| `cap_drop: [ALL]`            |                    yes                   |        yes        |     yes     |      yes     |                yes               |
| Minimal `cap_add`            | CHOWN/FOWNER/SETUID/SETGID/DAC\_OVERRIDE |         —         |      —      |  NET\_ADMIN  |                 —                |
| `no-new-privileges:true`     |                    yes                   |        yes        |     yes     |      yes     |                yes               |
| `tmpfs` for writable scratch |         /tmp, /var/run/postgresql        | /tmp, /app/.cache |     same    |     /tmp     | /tmp, /var/cache/nginx, /var/run |
| CPU limit                    |                     2                    |         2         |      2      |       1      |                0.5               |
| Memory limit                 |                   512M                   |        512M       |     512M    |     256M     |                64M               |

**Notes:**

* `postgres` does not get `read_only: true` because the official postgres image writes to paths in the container root that vary across patch releases. Capability tightening and `no-new-privileges` still apply.
* `ccip-sidecar` retains `NET_ADMIN` because the base compose already adds it for network namespace operations.
* The Python app containers (`qtg-mainnet`, `qtg-testnet`) write nothing to the root filesystem at runtime; `read_only: true` is safe.
* The `user: "1000:1000"` directive takes effect once the Dockerfile creates the user. Add `RUN adduser --uid 1000 --disabled-password --gecos "" qtguser && chown -R qtguser /app` to `infra/Dockerfile` before running hardened mode in production.

### Why not modify the base compose?

The base compose works for local development without hardening overhead. The overlay pattern lets developers run the base compose without friction and deploy hardened compose in staging/production without maintaining two diverging files.

***

## Artifact 3: Local Dependency Audit Gate

**File:** [`scripts/security_audit.sh`](https://github.com/jephalabs/quant-transfer-guard/blob/main/scripts/security_audit.sh)

### What it does

A local shell script that:

1. **Lockfile-drift check** — runs `uv lock --check` to verify `uv.lock` is in sync with `pyproject.toml`. A drifted lockfile means the running app may not use the versions you pinned.
2. **CVE scan** — runs `uvx pip-audit` against the exported locked dependency set. Fails non-zero if any known, non-ignored CVE is found.

### How to run

```bash theme={null}
bash scripts/security_audit.sh
```

Or add to a `Makefile`:

```makefile theme={null}
security-audit:
    @bash scripts/security_audit.sh
```

### Why local-only (not a CI workflow)?

GitHub Actions CI is disabled until release (workflows switched to `workflow_dispatch` in commit `82a80a0e`). The script is the gate. Run it:

* Before every `uv lock` refresh (catch newly disclosed CVEs)
* Before releasing to staging/production
* After adding/bumping a dependency

### Accepting a false-positive CVE

If `pip-audit` flags a CVE that you have assessed and accepted (e.g., only affects a code path you do not use, or is disputed), add it to the `IGNORED_VULNS` map in the script:

```bash theme={null}
# In scripts/security_audit.sh, in the IGNORED_VULNS block:
IGNORED_VULNS["PYSEC-YYYY-NNN"]="Reason — assessed YYYY-MM-DD by <who>"
```

Do not add to the ignore list without a documented rationale.

### Current CVE floor

The `pyproject.toml` already sets explicit security floors from the 2026-06-10 audit:

| Package     | Floor      | CVE(s) covered                                                           |
| ----------- | ---------- | ------------------------------------------------------------------------ |
| `PyJWT`     | `>=2.13.0` | H3: 4 CVEs incl. C:H/I:H on JWT auth path                                |
| `starlette` | `>=1.0.1`  | H2: CVE-2026-48710 (Host header → path pollution)                        |
| `urllib3`   | `>=2.7.0`  | H4: CVE-2026-44431 (redirect header leak) + CVE-2026-44432 (decomp bomb) |

The audit script enforces that a `uv lock` refresh cannot silently drop below these floors.

***

## Applying Everything Together (Quick-Start)

```bash theme={null}
# 1. DB least-privilege role (one-time)
psql "$MG_DATABASE_URL" -f infra/sql/qtg_least_privilege_role.sql
psql "$MG_DATABASE_URL" -c "ALTER ROLE qtg_app LOGIN PASSWORD '$(openssl rand -base64 32)';"
# Update MG_DATABASE_URL in .env to use qtg_app

# 2. Run the local security audit gate
bash scripts/security_audit.sh

# 3. Start the stack with hardening overlay
docker compose \
  -f infra/docker-compose.yml \
  -f infra/docker-compose.hardened.yml \
  up -d
```

***

## Cross-References

* Threat model: [DB Access Control Boundary](/reference/security/db-access-control-boundary)
* Enterprise readiness (key management, compliance): [Enterprise Readiness](/security/enterprise-readiness)
* Security audit (prior wave): commit `f122b3bd` (H1–H5 secure defaults, dep CVE floors, dashboard opt-out gating)
