Files
beer_data/RUNBOOK.md
T
hermes 1828dcb81d
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 11m18s
fix(security): address the independent review's blocking findings
The independent reviewer returned "SIGNIFICANT CHANGES NEEDED" on the security
review, with the code review itself approved. This addresses the three
blocking items plus the legitimate medium-severity ones.

Blocking items
--------------

1. Weak password hashing (SHA-256 -> scrypt)

   The custom salted SHA-256 helper is replaced with
   `werkzeug.security.generate_password_hash` / `check_password_hash`, which
   use scrypt by default. The hash carries its own salt and is deliberately
   slow to compute, so it is not attackable with a GPU.

   The legacy plaintext accounts still work, but a successful legacy login now
   **rewrites the row in place** with a proper hash. The plaintext credential
   disappears from the database the first time the account is used.

   Verified against the live database: the `beer` account's plaintext
   `beer_pass` was replaced with a scrypt hash on first login, and login with
   the hash still succeeds.

2. Plaintext password persistence

   Fixed by the migrate-on-login above. There is no longer a code path that
   leaves a password in plaintext.

3. Hardcoded secret defaults

   `SECRET_KEY` no longer has a default. The app raises `ImproperlyConfigured`
   and refuses to start when it is missing or still set to a placeholder
   value. A known session-signing key would let anyone forge a login cookie,
   so a misconfigured deployment must fail loudly.

   The Dockerfile no longer ships a placeholder either — the variable is simply
   not set, so a deploy that forgets it fails immediately and visibly.

   Note: Werkzeug 3 removed `ImproperlyConfigured` and Flask has never
   exported one, so the app defines its own as a `RuntimeError` subclass.

Medium items
------------

4. NoSQL injection risk

   Every value that reaches a Mongo query is now explicitly coerced first:
   run ids and dates go through `_as_id` / `_as_date`, which turn anything
   that is not a plain string or number into a string (or an empty string).
   Without this, a caller passing `{"$ne": ""}` would have been interpreted
   by Mongo as a query operator rather than as a value.

   Two tests cover this: one drives the login path with an operator payload,
   one checks the coercion helper directly.

5. Dependency pinning

   `requirements.txt` now pins exact versions of the runtime dependencies, and
   `requirements-dev.txt` pins the test dependencies. The image is rebuilt on
   every merge, so an unpinned dependency could change behaviour underneath a
   deploy with no commit to show for it.

6. Template logic

   The `sg / 1000` arithmetic has been moved out of the Jinja templates and
   into the domain layer — `run_summary()` now provides `og_decimal_str`,
   `fg_decimal_str` and `min_sg_decimal`, pre-formatted. No template does
   arithmetic on the data any more.

7. Health check depth

   `/healthz` stays a shallow **liveness** probe that never touches Mongo — a
   database outage must not make the kubelet restart a healthy process. A new
   `/readyz` **readiness** probe pings Mongo and returns 503 when it is
   unreachable, so a pod with no database is pulled out of the Service
   endpoints instead of serving errors. The RUNBOOK documents wiring both.

Low items
---------

8. Mongo aggregation pipeline for `runs()`

   Not changed. The dataset is 47 documents across 18 runs and will stay in
   that range for years — the app exists to record a handful of readings per
   brew. Moving the grouping into an aggregation pipeline would add real
   complexity for no measurable gain, and the grouping in Python is what makes
   the run aggregation straightforward to test. Noted for whenever the data
   volume makes it matter.

9. Kubernetes Secret for the credentials

   Not changed here, and deliberately so. The live Deployment carries
   `MONGO_PASS` as a plain environment value. Replacing it means replacing
   hand-provisioned live values, which is an operator decision. The RUNBOOK
   now documents the Secret manifest to use when that is done, and includes a
   ready-to-paste probe configuration.

10. Rate limiting on `/login`, Flask-Talisman, password complexity

    Not added. This is a single-user home-brew tracker on a LAN, reachable
    through a NodePort. Rate limiting and security-header middleware are
    production-grade hardening that adds dependencies and configuration
    surface without addressing a realistic threat here — the credentials are
    `beer`/`beer_pass` on a service only the operator and their own scanning
    can reach. The one change that materially matters — the session-signing
    key — is now enforced.

11. Least-privilege Mongo user

    Not changed. The app connects as `root` today, matching the existing
    deployment. Creating a scoped user is a cluster-side operation and
    belongs with the credential work in item 9.

Tests
-----

73 tests (up from 64). New coverage: legacy password migration, hash
verification, empty-credential rejection, the NoSQL operator payload, the
secret-key guards, and the readiness probe in both states.
2026-11-09 10:28:15 +10:00

179 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RUNBOOK — Brew Log (beer_data)
Operational notes for the `beer` namespace deployment.
## What runs where
| | |
|---|---|
| Namespace | `beer` |
| Deployment | `beer-deployment` (3 replicas) |
| Image | `git.aridgwayweb.com/armistace/beer-data:latest` |
| Service | `beer-service`, NodePort **30015** → container port 5000 |
| Database | MongoDB at `192.168.178.229`, database `beer_db` |
| Secret | `beercred` (registry pull credentials) |
Access: `http://<any-cluster-node>:30015/`
The pods are pinned to `archlinux-k3s-3` (`.161`) via `nodeSelector`.
## Environment variables
| Variable | Value | Source |
|---|---|---|
| `MONGO_HOST` | `192.168.178.229` | Deployment manifest |
| `MONGO_USER` | `root` | Deployment manifest |
| `MONGO_PASS` | *(see below)* | Deployment manifest |
| `SECRET_KEY` | *(see below)* | Deployment manifest — Flask session signing key. **Required** |
| `FLASK_ENV` | `production` | Dockerfile |
| `FLASK_DEBUG` | `0` | Dockerfile |
`SECRET_KEY` and `MONGO_PASS` must be set in the live Deployment. They are
**not** committed to this repo — read them from the running cluster:
```bash
kubectl -n beer get deploy beer-deployment -o jsonpath='{.spec.template.spec.containers[0].env}'
```
> **The app refuses to start without a real `SECRET_KEY`.** There is no default
> and no placeholder: a missing or placeholder value raises
> `ImproperlyConfigured` and the container exits. A known session-signing key
> would let anyone forge a login cookie, so a misconfigured deployment must
> fail loudly rather than run insecurely.
>
> Generate one with:
> ```bash
> python -c 'import secrets; print(secrets.token_hex(32))'
> ```
### Moving the credentials into a Kubernetes Secret
The live Deployment currently carries `MONGO_PASS` as a plain environment
value. Moving it to a Secret is the better shape and is **not** done here
because it would mean replacing the live hand-provisioned values — a change
that needs the operator's sign-off. When it is done:
```yaml
env:
- name: MONGO_PASS
valueFrom:
secretKeyRef:
name: beer-db
key: mongo_pass
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: beer-app
key: secret_key
```
## Deploying
Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which runs the
test suite and then builds and pushes the image. **The workflow does not
currently deploy to the cluster** — apply the manifests yourself:
```bash
kubectl -n beer rollout restart deployment/beer-deployment
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
```
### Verify the deploy actually took
CI green and pods `Ready` do **not** prove the new code is running. Check the
running digest against the registry:
```bash
# what the pod is running
kubectl -n beer get pod -l app=beer-data \
-o jsonpath='{.items[0].status.containerStatuses[0].imageID}'
# what the registry has as :latest
curl -s -u "armistace:$REG_PASSWORD" -I \
"https://git.aridgwayweb.com/v2/armistace/beer-data/manifests/latest" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
| grep -i docker-content-digest
```
If they differ, the registry `:latest` is stale — re-run the build rather than
re-applying the manifest.
### Check the app is alive
```bash
kubectl -n beer get pods
kubectl -n beer logs deploy/beer-deployment --tail=50
curl -s http://<node>:30015/healthz # -> {"status":"ok"}
curl -s http://<node>:30015/readyz # -> {"status":"ready"}
```
`/healthz` is the **liveness** probe and never touches Mongo — it answers "is
the process up". A database outage must not make the kubelet restart a healthy
process.
`/readyz` is the **readiness** probe and pings Mongo, returning 503 when the
database is unreachable. Wire it to the Service so a pod with no database is
pulled out of the endpoints rather than serving errors:
```yaml
readinessProbe:
httpGet:
path: /readyz
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 10
periodSeconds: 20
```
## Data safety
The `beer_db.beer_data` collection holds the actual brewing records. There is
no migration step and no schema version — documents are read as they are
written, and every field is treated as optional.
To take a backup before any risky change:
```bash
mongodump --uri="mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
--out=/tmp/beer_db_backup_$(date +%Y%m%d)
```
## Common problems
### The login page loads but nothing else does
The Mongo connection failed. Check the app logs for an auth error and confirm
the credentials:
```bash
kubectl -n beer logs deploy/beer-deployment --tail=100 | grep -i mongo
```
### A run shows a negative alcohol figure
That was the old formula (`(max - min) / 7.36 + 0.5`) applied to a run whose
current gravity is above the assumed original gravity of 1012. Fixed in the
current version: the standard `(OG - FG) × 131.25` formula is used, and a run
with no known OG shows a dash instead of a figure.
### The pod will not start after a deploy
The most likely cause is a missing `SECRET_KEY` — the app exits rather than
run with a default. Check the logs:
```bash
kubectl -n beer logs deploy/beer-deployment --tail=20
# ImproperlyConfigured: SECRET_KEY is not set. ...
```
### The chart on the run page is blank
The chart library is served from `/static/js/chart.umd.js` — it is vendored in
the repo, not loaded from a CDN, so the container works with no outbound
network. Check the browser console for a 404 on that path.