Skip to main content

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:

SurfaceWhat it isWhat it can and cannot do
.env.examplethe file an operator copiesadvice. No cluster ever reads it.
application.yml (core, app, and each domain module)the fallback baked into the jara default. It only applies to the environment that forgot to set the value.
Helm values (ehr/deployment, cross-repo)what actually deploysthe 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.

ControlRefuses to boot whenWhat goes wrong if it is not enforced
pii.encryption.enabled (PII_ENCRYPTION_ENABLED)falseEvery 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)falseThe 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-fullThe pod-to-Postgres link carries PHI and DB_PASSWORD in the clear.
local.auth.jwt.secret (LOCAL_JWT_SECRET), only when keycloak.enabled=falsemissing, shorter than 32 bytes, or a placeholder published in this repositoryMissing: 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.yml default to true would break every developer machine and every test context.
  • A browser will not store a Secure cookie from http://localhost, so local development genuinely needs LOCAL_AUTH_REFRESH_COOKIE_SECURE=false.
  • A developer's Postgres serves no certificate, so sslmode=require would 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.

KeyWasNow
LOCAL_AUTH_REFRESH_COOKIE_SECUREfalsetrue — put the local deviation in your own gitignored .env
LOCAL_JWT_SECRETreplace-with-at-least-32-random-bytesblank — 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=prefer restates pgjdbc's existing default.
  • LOCAL_AUTH_REFRESH_COOKIE_SECURE=false remains correct locally and stays in your gitignored .env; only the example flipped.
  • The prod profile is not active locally, so the assertion never runs.
  • docker-compose.yml now requires DB_PASSWORD instead of shipping change-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 at up rather 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.