Infrastructure security requirements
Why this document exists
Some controls cannot be implemented in this repository. At-rest encryption for the Postgres data
directory, encrypted backups, HSM key custody and ingress TLS live in the external Postgres cluster
and in the ehr/deployment Helm chart. Writing a backend ticket for them produces a ticket that can
never close.
So this repo owns two things instead: the requirement, and the conformance test that makes it verifiable.
⚠ That distinction is the point. "The database uses TDE" is a claim. "This test fails when the connection is not TLS" is a control. A compliance matrix (M33-016) can cite the second and cannot honestly cite the first.
The controls
| # | Control | Owning repo | Conformance check |
|---|---|---|---|
| 1 | Postgres data directory and WAL encrypted at rest | cluster / infra | ⚠ none possible from here — see What this cannot verify |
| 2 | App→database connections use TLS | ehr/deployment (DB_SSL_MODE) | databaseConnectionIsEncryptedInTransit — pg_stat_ssl.ssl for the app's own backend |
| 3 | Ingress serves TLS with a chain that validates | ehr/deployment (ingress.tls) | ingressServesValidTls — a default JDK client, so a self-signed certificate fails |
| 4 | X-Forwarded-Proto: https reaches the application | ehr/deployment (ingress) | hstsIsServedOverHttps — without it M33-001's HSTS never fires |
| 5 | Plain HTTP redirects rather than serving the API | ehr/deployment (ingress) | plainHttpIsNotServed |
| 6 | Backups encrypted, separately keyed, restore tested | cluster / infra | ⚠ none possible from here |
| 7 | PII key custody in an HSM/KMS | ehr/deployment (PII_KEY_PROVIDER) | partially — the app refuses to start with vault and no wrapped keys |
| 8 | POSTGRES_PASSWORD from a secret, never a literal | this repo + ehr/deployment | ✅ already done — see below |
⚠ Control 8 was already fixed; the ticket text was stale
M33-015's body asks for "POSTGRES_PASSWORD from a secret rather than the literal change-me".
M33-003 already did this. docker-compose.yml now reads:
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env — there is deliberately no default}
Recorded here rather than silently skipped, because a control listed as outstanding when it is done is how a matrix ends up understating what exists — the mirror of overstating it.
The conformance test
core/src/test/java/…/conformance/DeployedEnvironmentSecurityConformanceTest.java
⚠ The security:conformance CI job runs only on a deliberately-started pipeline (Run pipeline,
or a schedule) — never on an MR. A when: manual job with allow_failure: false is a blocking
manual job: GitLab holds the pipeline in manual instead of success until someone runs it, which on
an MR reads as "not green" and can stop the merge. Where it does run, allow_failure: false stands.
CONFORMANCE_BASE_URL=https://uhp.zhenus.local \
DB_HOST=… DB_PORT=… DB_NAME=… DB_USERNAME=… DB_PASSWORD=… \
./mvnw -pl core test -Dsurefire.excluded.groups= -Dgroups=conformance
⚠⚠ It fails rather than skips when unconfigured. A conformance check that quietly skips reports green while verifying nothing, which is worse than having no check: it manufactures evidence of compliance where none was gathered. If it runs, it either verified the control or it failed.
⚠ Tagged conformance, excluded in two places. The root pom's surefire.excluded.groups and
the explicit override test:unit passes in .gitlab-ci.yml — that override replaces the default
rather than adding to it, so excluding it in only one place would let it run somewhere nobody
intended, against nothing.
⚠ The HTTPS client is a default HttpClient, which validates the chain against the JDK trust
store. A permissive client would turn control 3 into "TLS with any chain", which is exactly what a
forgotten self-signed certificate satisfies.
Baseline: what fails today (2026-08-28)
Run against the deployed environment, all four checks fail, and each failure names a real gap:
| Check | Result | Evidence |
|---|---|---|
ingressServesValidTls | ❌ | SSLHandshakeException: PKIX path building failed |
hstsIsServedOverHttps | ❌ | same handshake failure — HSTS cannot be reached to be checked |
plainHttpIsNotServed | ❌ | plain HTTP returns 200; the API is served in the clear |
databaseConnectionIsEncryptedInTransit | ❌ | The server does not support SSL (local dev Postgres) |
Corroborated outside the test: kubectl -n uhp get ingress reports PORTS 80, and the ingress
spec.tls is empty — matching values.yaml's tls: [].
⚠ This baseline is the deliverable, not a defect report. M33-015 is complete when the requirement
and the check exist; the checks going green is ehr/deployment's work, tracked separately.
What this cannot verify, and why saying so matters
Controls 1 and 6 — data-directory encryption and backup encryption — cannot be checked from an
application connection. Postgres exposes no reliable way to ask whether its storage is encrypted;
pg_stat_ssl describes the wire, not the disk.
⚠ So this repo must not assert that TDE "is done". Any such claim in a compliance matrix would be unfounded, and the honest entry is: control owned by infra, evidenced by an infrastructure attestation (disk/volume encryption configuration, backup tooling configuration and a dated restore test), not by anything in this codebase.
Cross-repo tracking
The Helm changes these checks require — populating ingress.tls, keeping DB_SSL_MODE, and the
PII_* custody settings M33-003 needs — are tracked in ehr/deployment#2. ⚠ Note for whoever picks
that up: values.yaml is the file CI rewrites and the one ArgoCD deploys. values-prod.yaml is
linted by CI and deployed by nothing (see the note on #336), so adding a tls: block only there would
change nothing while looking like it had.