Files
kite/.planning/phases/06-performance-production-hardening/06-04-PLAN.md
T
curo1305andClaude Sonnet 4.6 70c09f6cd4 docs(06): create phase 6 plan — performance & production hardening
6 plans across 4 waves covering structlog/Loki observability, Locust
load testing, multi-stage Dockerfile hardening, trusted-proxy rate
limiting, and RUNBOOK.md. Verification passed (0 blockers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 20:35:03 +02:00

22 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
06-performance-production-hardening 04 execute 2
06-02
backend/Dockerfile
docker-compose.yml
false
D-07
D-08
D-09
truths artifacts key_links
backend image runs as uid=1000 (appuser), not root — docker run --rm <image> id returns uid=1000(appuser)
backend container's root filesystem is read-only (read_only: true) and writes to /tmp succeed because tmpfs is mounted mode=1777
celery-worker container has the same read_only + tmpfs + cap_drop + no-new-privileges hardening as backend
celery-beat is INTENTIONALLY left without read_only/cap_drop because it writes celerybeat-schedule to its working directory (Pitfall 7)
Both backend and celery-worker drop ALL Linux capabilities (cap_drop: [ALL]) and forbid privilege escalation (security_opt: no-new-privileges)
tempfile.NamedTemporaryFile (services/extractor.py) still succeeds inside the hardened container — the mode=1777 sticky-bit tmpfs is writable by appuser
path provides contains min_lines
backend/Dockerfile Multi-stage builder/runtime image; runtime stage creates appuser uid=1000 and runs USER appuser FROM python:3.12-slim AS builder 15
path provides contains
docker-compose.yml Hardened backend + celery-worker service definitions (read_only/tmpfs/cap_drop/security_opt); celery-beat deliberately unmodified read_only: true
from to via pattern
backend/Dockerfile runtime stage docker-compose.yml backend.read_only appuser uid=1000 + tmpfs mode=1777 cooperate so non-root writes to /tmp succeed USER appuser
from to via pattern
docker-compose.yml backend.tmpfs services/extractor.py tempfile.NamedTemporaryFile /tmp:mode=1777 is the destination tempfile picks via TMPDIR/default /tmp:mode=1777
from to via pattern
docker-compose.yml celery-beat block Pitfall 7 in 06-RESEARCH.md EXPLICITLY NOT hardened — preserves writable filesystem for celerybeat-schedule celery-beat:
Harden the production container image (D-07) and the docker-compose runtime for backend + celery-worker (D-08, D-09). Replace the current single-stage root-running Dockerfile with a multi-stage build whose runtime stage creates appuser (uid=1000) and copies installed packages from a discardable builder stage. Add `read_only: true`, `tmpfs: /tmp:mode=1777`, `cap_drop: [ALL]`, and `security_opt: no-new-privileges:true` to the backend and celery-worker services in docker-compose.yml. Leave celery-beat untouched (Pitfall 7 — it writes celerybeat-schedule to its working directory).

Purpose: Satisfy Phase 6 success criteria 3 + 4 (non-root container, read-only rootfs, dropped capabilities). Reduce blast radius of a backend RCE: an attacker cannot write to the image filesystem, escalate privileges, or use a kernel capability to break out.

Output: A rewritten Dockerfile that produces a runtime image with appuser; docker-compose.yml service blocks that apply the four hardening keys to backend and celery-worker only; verification commands that prove uid=1000, read-only rootfs writes fail, and tmpfs writes succeed.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/06-performance-production-hardening/06-CONTEXT.md @.planning/phases/06-performance-production-hardening/06-RESEARCH.md @.planning/phases/06-performance-production-hardening/06-PATTERNS.md @CLAUDE.md @backend/Dockerfile @docker-compose.yml @backend/services/extractor.py @backend/requirements.txt

Current Dockerfile shape (single stage, root user):

  • FROM python:3.12-slim
  • Runs apt-get install tesseract-ocr libgl1 libglib2.0-0 then pip install -r requirements.txt
  • COPY . . and EXPOSE 8000 — no USER directive (defaults to root).

Multi-stage target shape (from RESEARCH.md Pattern 5):

  • Stage 1 builder (python:3.12-slim) — installs build tooling (gcc) + pip install --no-cache-dir --prefix=/install -r requirements.txt.
  • Stage 2 runtime (python:3.12-slim) — installs RUNTIME apt deps (tesseract-ocr, libgl1, libglib2.0-0), COPY --from=builder /install /usr/local, creates appuser via groupadd/useradd uid=1000, COPY --chown=appuser:appgroup . /app, USER appuser, EXPOSE 8000, CMD uvicorn.

docker-compose service shape (per RESEARCH.md Pattern 6 + Critical Note 4/5):

  • Add to backend AND celery-worker:
    • read_only: true
    • tmpfs: ["/tmp:mode=1777"] (long-form list with mode option; mode=1777 = world-writable + sticky bit so appuser uid=1000 can write tempfile.NamedTemporaryFile output)
    • cap_drop: [ALL]
    • security_opt: ["no-new-privileges:true"]
  • DO NOT add to celery-beat.

Existing volumes: mount ./backend:/app on backend and celery-worker is a bind mount; bind mounts are writable even when read_only=true on the container rootfs. The /app path remains writable for dev reload — verified by the docker spec (read_only applies to the image layer, not declared mounts).

Task 1: Rewrite backend/Dockerfile to multi-stage with appuser backend/Dockerfile - backend/Dockerfile (current single-stage form — line 117) - backend/requirements.txt (verify list of packages compatible with --prefix install) - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pattern 5; Assumption A3 — pip --prefix copy) - .planning/phases/06-performance-production-hardening/06-PATTERNS.md (backend/Dockerfile section) - The built image's default user is appuser (uid=1000), not root: `docker run --rm id` returns `uid=1000(appuser) gid=1000(appgroup)`. - All Python imports from requirements.txt resolve in the runtime stage: `docker run --rm python -c "import fastapi, structlog, sqlalchemy, minio, celery"` exits 0. - Runtime system deps are installed in the runtime stage (tesseract-ocr, libgl1, libglib2.0-0): `docker run --rm tesseract --version` exits 0. - The builder stage is discarded — final image does NOT contain gcc: `docker run --rm sh -c "command -v gcc" || true` returns empty. - The runtime stage uses `--no-install-recommends` to keep apt footprint minimal. - Image still exposes port 8000 and CMD launches uvicorn with the same host/port as before. Replace backend/Dockerfile content with a two-stage build per RESEARCH.md Pattern 5. Stage 1 header: `FROM python:3.12-slim AS builder`. WORKDIR /build. Install build tooling: `apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*`. Copy requirements.txt. Run `pip install --no-cache-dir --prefix=/install -r requirements.txt`. Stage 2 header: `FROM python:3.12-slim AS runtime`. Install RUNTIME apt deps: `apt-get update && apt-get install -y --no-install-recommends tesseract-ocr libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*`. `COPY --from=builder /install /usr/local`. Create non-root user: `RUN groupadd --gid 1000 appgroup && useradd --uid 1000 --gid appgroup --shell /bin/sh --no-create-home appuser`. WORKDIR /app. `COPY --chown=appuser:appgroup . .`. `USER appuser`. `EXPOSE 8000`. `CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]`. Per the docker-compose `command:` override (`uvicorn main:app --host 0.0.0.0 --port 8000 --reload`), the CMD is replaced at runtime in dev — but the Dockerfile CMD remains the production default. Do NOT bake `--reload` into the CMD. Use `--no-install-recommends` on BOTH apt-get invocations. Do NOT add the locust dependency anywhere in this Dockerfile (per 06-03 boundary: locust lives in requirements-dev.txt only). cd backend && docker build -t docuvault-backend:phase6 . && docker run --rm docuvault-backend:phase6 id | grep -c "uid=1000(appuser)" - `grep -c "FROM python:3.12-slim AS builder" backend/Dockerfile` returns 1. - `grep -c "FROM python:3.12-slim AS runtime" backend/Dockerfile` returns 1. - `grep -c "^USER appuser" backend/Dockerfile` returns 1. - `grep -c "useradd --uid 1000" backend/Dockerfile` returns 1. - `grep -c "COPY --from=builder" backend/Dockerfile` returns 1. - `grep -c "no-install-recommends" backend/Dockerfile` returns 2 (both apt-get blocks). - `grep -c "tesseract-ocr" backend/Dockerfile` returns 1 (in runtime stage only — NOT in builder stage; the builder needs only gcc). - `grep -c "EXPOSE 8000" backend/Dockerfile` returns 1. - `grep -c "CMD .uvicorn" backend/Dockerfile` returns 1. - `grep -c "\\--reload" backend/Dockerfile` returns 0 (reload is a compose-level override only). - Build succeeds: `cd backend && docker build -t docuvault-backend:phase6 .` exits 0. - `docker run --rm docuvault-backend:phase6 id` output contains `uid=1000(appuser)`. - `docker run --rm docuvault-backend:phase6 python -c "import fastapi, structlog, sqlalchemy, minio, celery, structlog"` exits 0 (verifies A3 — the --prefix/COPY pattern populated site-packages correctly). - `docker run --rm docuvault-backend:phase6 tesseract --version` exits 0 (runtime apt deps present). - `docker run --rm docuvault-backend:phase6 sh -c "command -v gcc"` exits non-zero or returns empty (gcc was in builder stage only). Multi-stage image builds, runs as uid=1000, all production Python imports resolve, runtime apt deps present, build tooling absent. Task 2: Apply read_only + tmpfs + cap_drop + no-new-privileges to backend and celery-worker in docker-compose.yml docker-compose.yml - docker-compose.yml (lines 49104 — backend, celery-worker, celery-beat blocks; volumes block) - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pattern 6 hardening additions; Pitfall 1, 3, 6, 7; Critical Note 5) - .planning/phases/06-performance-production-hardening/06-PATTERNS.md (docker-compose.yml section; Critical Note 4) - backend/services/extractor.py (line 18 area — tempfile.NamedTemporaryFile usage that drives the mode=1777 requirement) - `docker compose config --quiet` exits 0 (compose file is still valid YAML/schema). - After `docker compose up backend`, `docker compose exec backend sh -c 'touch /tmp/probe && echo ok'` writes successfully (tmpfs writable by appuser). - After `docker compose up backend`, `docker compose exec backend sh -c 'touch /probe 2>&1 || echo readonly'` reports "readonly" (rootfs is read_only). - `docker compose exec backend cat /proc/self/status | grep CapEff` shows an empty/zero effective capability set (cap_drop: ALL took effect). - The celery-beat block contains NEITHER `read_only:` NOR `cap_drop:` NOR `tmpfs:` keys (Pitfall 7 — celerybeat-schedule must remain writable). - The celery-worker block has the SAME four hardening keys as backend. - The backend bind mount `./backend:/app` remains and continues to provide live source for `--reload` dev mode (bind mounts are unaffected by `read_only: true` on the container rootfs). Edit docker-compose.yml in place. For the backend service block (currently lines 4979), add the following four keys as siblings of existing keys (build, ports, volumes, environment, command, depends_on). Insert after `depends_on:` (or at a stable, readable location): - `read_only: true` - `tmpfs:` with a single list entry `"/tmp:mode=1777"` (use the long-form string with mode option — mode=1777 makes the tmpfs world-writable with the sticky bit so appuser uid=1000 can write the tempfile.NamedTemporaryFile output that services/extractor.py creates). - `cap_drop:` with a single list entry `ALL`. - `security_opt:` with a single list entry `no-new-privileges:true`. For the celery-worker block (currently lines 81103), add the SAME four keys with the SAME values. The celery worker runs the same image and the same temp-file-producing extractor; it needs identical hardening. For the celery-beat block (currently lines 105124), DO NOT add any of those four keys. Leave the block exactly as-is. Add a single comment line directly above the celery-beat key declaration: ` # celery-beat: NOT hardened — writes celerybeat-schedule to working directory (Phase 6 Pitfall 7).` Do NOT remove or modify the existing `volumes: - ./backend:/app` bind mounts — bind mounts remain writable under read_only because read_only applies only to the image layer. Do NOT remove or modify environment variables, healthchecks, or extra_hosts. Do NOT add the Loki/Promtail/Grafana labels here — those landed in 06-02 already (06-02 added `labels: logging: "promtail"` on backend and celery-worker). If those labels are missing because 06-02 has not yet shipped, ADD them now alongside the four hardening keys to keep the two plans composable — but if they are already present, leave them untouched. docker compose config --quiet && awk '/^ backend:/,/^ [a-z]/' docker-compose.yml | grep -c "read_only: true" - `docker compose config --quiet` exits 0. - `awk '/^ backend:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -c "read_only: true"` returns 1. - `awk '/^ backend:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -cE "/tmp:mode=1777"` returns 1. - `awk '/^ backend:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -cE "^\\s*-\\s*ALL\\s*$"` returns 1. - `awk '/^ backend:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -cE "no-new-privileges:true"` returns 1. - `awk '/^ celery-worker:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -c "read_only: true"` returns 1. - `awk '/^ celery-worker:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -cE "/tmp:mode=1777"` returns 1. - `awk '/^ celery-worker:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -cE "no-new-privileges:true"` returns 1. - celery-beat must have NONE of these keys: `awk '/^ celery-beat:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -cE "read_only:|cap_drop:|tmpfs:|security_opt:|no-new-privileges"` returns 0. - Bind mount preserved: `awk '/^ backend:/,/^ [a-z][a-z-]*:/' docker-compose.yml | grep -c "./backend:/app"` returns 1. - Smoke test (runtime): `docker compose up -d backend && sleep 5 && docker compose exec -T backend sh -c 'touch /tmp/probe && rm /tmp/probe && echo TMP_OK'` prints "TMP_OK" (tmpfs writable). - Smoke test (rootfs read-only): `docker compose exec -T backend sh -c 'touch /probe 2>&1; ls /probe 2>/dev/null || echo ROOTFS_RO'` prints "ROOTFS_RO". - Smoke test (extractor): upload a small PDF via the API and confirm classify works (proves tempfile.NamedTemporaryFile succeeded under the new constraints). If a full upload is heavy, instead `docker compose exec -T backend python -c "import tempfile; t = tempfile.NamedTemporaryFile(delete=False); t.write(b'x'); t.close(); print('TEMPFILE_OK')"` exits 0 and prints "TEMPFILE_OK". Backend and celery-worker run read_only with mode=1777 tmpfs /tmp, all capabilities dropped, no privilege escalation; celery-beat untouched; live-reload bind mount still works; extractor's tempfile pattern still succeeds. Task 3: Human smoke test — full stack up, upload + extract under hardened runtime - .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pitfall 6 — PyMuPDF cache paths; Pitfall 1 — tmpfs mode) - backend/services/extractor.py - backend/tasks/document_tasks.py The Dockerfile produces a uid=1000 runtime image; docker-compose.yml runs backend and celery-worker with read_only rootfs, tmpfs /tmp mode=1777, ALL capabilities dropped, no-new-privileges enabled. Celery-beat is intentionally left writable. 1. From repo root: `docker compose down -v` then `docker compose up -d --build` and wait ~30s. 2. Confirm appuser: `docker compose exec -T backend id` should print `uid=1000(appuser) gid=1000(appgroup) groups=1000(appgroup)`. 3. Confirm read_only rootfs: `docker compose exec -T backend sh -c 'touch /readonly_probe 2>&1 || echo "rootfs is read-only"'` should print "rootfs is read-only". 4. Confirm tmpfs writable: `docker compose exec -T backend sh -c 'touch /tmp/ok && ls -la /tmp/ok'` should succeed. 5. Confirm celery-worker has the same hardening: `docker compose exec -T celery-worker id` returns uid=1000; `docker compose exec -T celery-worker sh -c 'touch /readonly_probe 2>&1 || echo RO'` prints RO; `docker compose exec -T celery-worker sh -c 'touch /tmp/ok'` succeeds. 6. Confirm celery-beat is UNHARDENED (intentionally): `docker compose exec -T celery-beat sh -c 'touch /tmp/probe && touch /app/probe; ls /app/probe'` should succeed (writable rootfs — required for celerybeat-schedule). 7. End-to-end upload test: register a fresh user via the UI (or curl /api/auth/register), log in, upload a small PDF, wait ~10s for the Celery task. Confirm in the UI/API that the document appears with extracted text and classified topics. This validates Pitfall 6 (PyMuPDF cache under read_only) is not blocking extraction. 8. Inspect docker stats: `docker stats --no-stream` — backend and celery-worker should be running healthy (not restarting in a loop). 9. Inspect logs for any "Read-only file system" or "Permission denied" errors: `docker compose logs backend celery-worker | grep -iE "read-only|permission denied"` — should be empty (or only contain expected ignored writes, not failures). - User confirms steps 28 all pass; step 9 produces zero hard errors that crash request handling. - If step 7 (extraction) fails with a Read-only/Permission error, user reports it — planner adds an additional tmpfs mount (e.g. `/var/cache/fontconfig`) in a follow-up before approving. - User responds "approved" to advance Wave 3 (06-06 security gate + RUNBOOK). Type "approved" if all checks pass; otherwise paste the failing command output for triage.

<threat_model>

Trust Boundaries

Boundary Description
Attacker exploits backend RCE → container filesystem Hardening (read_only + cap_drop + no-new-privileges) limits post-RCE actions
Container process → host kernel Dropped capabilities + no privilege escalation make kernel-API abuse harder
Container process → other containers on same network Out of scope for hardening (compose default bridge network) — network policy is Phase 7+
Build-time gcc → runtime image Multi-stage build keeps build tools out of the runtime image, reducing CVE attack surface

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-04-01 Elevation of Privilege Container escape via root-owned process exploit mitigate Multi-stage Dockerfile creates appuser uid=1000; USER appuser drops root before CMD runs (D-07)
T-06-04-02 Tampering Attacker writes a webshell or modifies application code on container filesystem mitigate read_only: true on backend + celery-worker prevents all writes outside declared tmpfs/volumes (D-08)
T-06-04-03 Elevation of Privilege Attacker uses Linux capability (CAP_NET_RAW, CAP_SYS_PTRACE, etc) to expand reach mitigate cap_drop: [ALL]; no cap_add — port 8000 is unprivileged (D-09)
T-06-04-04 Elevation of Privilege Attacker invokes setuid binary to regain root inside container mitigate security_opt: no-new-privileges:true blocks setuid bit from elevating (D-09 belt-and-braces)
T-06-04-05 Denial of Service tempfile.NamedTemporaryFile fails under read_only because /tmp is not writable by appuser mitigate tmpfs: /tmp:mode=1777 — sticky world-writable bit per Pitfall 1 (RESEARCH.md) — verified by Task 3 step 4
T-06-04-06 Denial of Service celery-beat cannot write celerybeat-schedule under read_only accept celery-beat INTENTIONALLY left unhardened per Pitfall 7; security risk lower (no inbound network port); RUNBOOK.md (06-06) documents the deferral
T-06-04-07 Information Disclosure Build tooling (gcc) shipped in runtime image expands CVE surface mitigate Multi-stage build discards builder; runtime image contains only runtime apt deps + Python site-packages
</threat_model>
- `docker compose config --quiet` exits 0. - `cd backend && docker build -t docuvault-backend:phase6 .` exits 0; resulting image `id` returns uid=1000(appuser). - backend and celery-worker blocks each contain `read_only: true`, `/tmp:mode=1777` tmpfs, `cap_drop: [ALL]`, `security_opt: no-new-privileges:true`. - celery-beat block contains NONE of those four keys. - Live smoke (Task 3) confirms /tmp writable, rootfs writes fail, full upload+extract round-trip works. - Existing backend test suite still passes (no test regressions): `cd backend && pytest tests/ --no-header -q` baseline unchanged.

<success_criteria>

  • D-07 satisfied: Dockerfile is multi-stage with appuser uid=1000; runtime image contains only runtime deps + Python packages.
  • D-08 satisfied: backend + celery-worker run with read_only: true and tmpfs: /tmp:mode=1777; celery-beat preserved as writable.
  • D-09 satisfied: backend + celery-worker drop ALL capabilities and forbid privilege escalation.
  • No regression: existing pytest suite green; full Docker stack starts; upload + extract works end-to-end under the hardened runtime. </success_criteria>
Create `.planning/phases/06-performance-production-hardening/06-04-SUMMARY.md` when done. Include: built image ID + size before/after multi-stage, `docker run --rm id` output, `docker compose config --quiet` exit code, smoke test results from Task 3 (each numbered step + pass/fail), explicit confirmation that celery-beat was left untouched, and any tmpfs additions made beyond /tmp:mode=1777 (e.g. /var/cache/fontconfig if Pitfall 6 surfaced).