Skip to main content

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.

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: 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

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 § Proportionate investment, item 1.

Schema layout

How to apply

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

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:
Controls applied per service: 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

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

Or add to a Makefile:

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:
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: The audit script enforces that a uv lock refresh cannot silently drop below these floors.

Applying Everything Together (Quick-Start)


Cross-References