Secure defaults and the production posture assertion (M33-003)
The platform is configured through three surfaces, and they used to disagree about what "secure" means — with the surface that governs production being the weakest of the three:
| Surface | What it is | What it can and cannot do |
|---|---|---|
.env.example | the file an operator copies | advice. No cluster ever reads it. |
application.yml (core, app, and each domain module) | the fallback baked into the jar | a default. It only applies to the environment that forgot to set the value. |
Helm values (ehr/deployment, cross-repo) | what actually deploys | the truth, and it is not in this repository. |
A default cannot stop a production pod running with PHI encryption off. A startup assertion can, and it is the only one of the three an operator cannot silently skip.
ProductionSecurityPostureValidator
core/.../config/ProductionSecurityPostureValidator.java is a @Profile("prod") bean that fails
context refresh when any of the following is not in force. It collects every violation and
reports them in one message, so fixing a misconfigured cluster takes one restart rather than one per
mistake, and it never echoes a configured value — only the name of the property that is wrong.
| Control | Refuses to boot when | What goes wrong if it is not enforced |
|---|---|---|
pii.encryption.enabled (PII_ENCRYPTION_ENABLED) | false | Every name, address, phone number and email is written to disk in plaintext, and SEC-001's cipher sits unused. |
local.auth.refresh-cookie.secure (LOCAL_AUTH_REFRESH_COOKIE_SECURE) | false | The refresh cookie is httpOnly, so XSS cannot read it — but without Secure the browser sends it over plain HTTP, where anything on the path can lift and replay it for the full LOCAL_JWT_REFRESH_TOKEN_TTL (7 days). |
sslmode in spring.datasource.url (DB_SSL_MODE) | not require, verify-ca or verify-full | The pod-to-Postgres link carries PHI and DB_PASSWORD in the clear. |
local.auth.jwt.secret (LOCAL_JWT_SECRET), only when keycloak.enabled=false | missing, shorter than 32 bytes, or a placeholder published in this repository | Missing: the pod reports healthy and every login fails at request time — a configuration fault wearing the costume of an outage. Published placeholder: anyone who has read this repo can sign a token for any account. |
Why the assertion is prod-only
The safe default and the required posture are genuinely different things here, so they are expressed separately — a lenient default plus a rule that binds where it matters.
- PII encryption fails fast without key material. Flipping the
application.ymldefault totruewould break every developer machine and every test context. - A browser will not store a
Securecookie fromhttp://localhost, so local development genuinely needsLOCAL_AUTH_REFRESH_COOKIE_SECURE=false. - A developer's Postgres serves no certificate, so
sslmode=requirewould refuse to connect.
:::warning The assertion only fires under the prod profile
SPRING_PROFILES_ACTIVE=prod must actually be set on the deployment. A pod that runs the default
dev profile skips every check here. Setting it is owned by ehr/deployment — see
M33-015.
:::
sslmode is now a parameter
Before this ticket the JDBC URL was:
jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?createDatabaseIfNotExist=true
It declared no sslmode, so no environment variable could turn TLS on — the deployment had no way
to require an encrypted database link even if it wanted one. createDatabaseIfNotExist=true is a
MySQL Connector/J parameter and a no-op on PostgreSQL; it never created a database, it only made
the URL read as though a missing one would be handled. Now:
jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSL_MODE:prefer}
prefer is also pgjdbc's own built-in default, so local development and CI are unchanged — the
URL now merely states what was already happening. It is not acceptable in a deployment: prefer
falls back to plaintext when the server declines TLS, without saying so, which is precisely the case
worth failing on. Hence the prod assertion.
require encrypts but does not authenticate the server, so it stops passive sniffing and not an
active MITM. verify-ca / verify-full also authenticate it, and need a CA the pod trusts.
:::info Open decision
Whether the Patroni/pgbouncer endpoint presents a verifiable server certificate is not settled.
Until it is, require is the floor the assertion enforces; move to verify-full once the endpoint
and CA are confirmed. require alone is not bank-grade.
:::
The same parameter was added to the standalone application.yml of every domain module (workforce,
lab, pharmacy, imaging, hl7), which had no sslmode at all.
.env.example
The example file now ships the deployable value for every security switch, not the convenient one. It is the file people copy, and the copy ends up in an environment nobody re-reads.
| Key | Was | Now |
|---|---|---|
LOCAL_AUTH_REFRESH_COOKIE_SECURE | false | true — put the local deviation in your own gitignored .env |
LOCAL_JWT_SECRET | replace-with-at-least-32-random-bytes | blank — the old placeholder was 36 characters, so it cleared the 32-byte minimum and a copied-verbatim .env would have signed real tokens with a value printed in this repository |
DB_SSL_MODE | (absent) | prefer, documented as a local-only value |
SecureConfigurationDefaultsTest guards this file: it asserts each security switch holds its
deployable value and that every named credential key stays blank. .env.example is plain text, and
plain text regresses silently.
Local development still works
Nothing here changes a developer's machine:
DB_SSL_MODE=preferrestates pgjdbc's existing default.LOCAL_AUTH_REFRESH_COOKIE_SECURE=falseremains correct locally and stays in your gitignored.env; only the example flipped.- The
prodprofile is not active locally, so the assertion never runs. docker-compose.ymlnow requiresDB_PASSWORDinstead of shippingchange-me. Docker Compose reads the same gitignored.env, so a machine that already runs the stack is unaffected; a machine that is not configured gets a clear message atuprather than a database secured by a password printed in the repository.
What this repository cannot enforce
The Helm values live in ehr/deployment and set no PII_* variable at all, which is why
production ran with SEC-001 off regardless of what the example file said. Adding PII_*,
DB_SSL_MODE and SPRING_PROFILES_ACTIVE=prod there as secret references — following the existing
uhp-jwt pattern — is M33-015. The assertion documented above is what makes forgetting it loud
instead of silent.