fix: let the audit window an operator sets reach the container

AUDIT_LOG_RETENTION_DAYS is not only enforced, it is published: the
shared-access panel prints the window under the activity list, and the dialog
that ends somebody else's access states how long "who entered what" stays
answerable. Both read the value the server resolved. It was missing from the
compose environment: whitelist and from both example files, so an operator who
set 90 got 365 told to every user, in a sentence about their own data.

That block is a whitelist and Compose substitutes from .env regardless, which
is what makes the variable look configured while never arriving — the same
shape as the session-cookie flag in v1.5.2. A guard now covers the three places
an operator meets such a variable, and its control leg found that the cookie
flag itself is still absent from the production example. Documented there too.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 00:43:39 +02:00
1 parent f91f701e13
commit b1667ef692
4 files changed
+151

No files matched your search

+14
View File
@@ -210,6 +210,20 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
# -----------------------------------------------------------------------------
# Audit-trail retention (optional)
# -----------------------------------------------------------------------------
# How long rows in `audit_logs` are kept. Default 365 days; the floor is 7 and
# anything below it is ignored, so a window set in seconds by mistake cannot
# empty a fresh table. A daily pg-boss job does the pruning.
#
# The number is not internal. Settings -> Shared access states it under the
# activity list, and the dialog that ends somebody's access says how long
# "who entered what" stays answerable. Both read the value the server
# resolved, so an instance running 90 says 90 everywhere.
# AUDIT_LOG_RETENTION_DAYS="365"
# -----------------------------------------------------------------------------
# APNs — Apple Push Notification service (v1.4.23+, optional)
# -----------------------------------------------------------------------------
+26
View File
@@ -184,6 +184,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# SMTP_SECURE="false"
# -----------------------------------------------------------------------------
# Session cookie Secure flag -- the most common self-host snag
# -----------------------------------------------------------------------------
# Unset, the flag tracks NODE_ENV, so a production image sets `Secure` and a
# browser on plain HTTP silently drops the session cookie: login appears to
# succeed and every following request is anonymous. Set it to false ONLY on a
# LAN, Tailscale or VPN-only host that deliberately serves HTTP. Behind a TLS
# reverse proxy, leave it alone.
# SESSION_COOKIE_SECURE="false"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
@@ -230,6 +241,21 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# BACKUP_ENCRYPTION_KEY=""
# -----------------------------------------------------------------------------
# Audit-trail retention -- optional
# -----------------------------------------------------------------------------
# How long rows in `audit_logs` survive, in days. Default 365. The floor is 7:
# a smaller value is ignored rather than obeyed, so a window entered in seconds
# by mistake cannot empty a fresh table. A daily job does the pruning.
#
# This number is shown to users, not just enforced. Settings -> Shared access
# prints the window under the activity list, and the dialog that ends somebody
# else's access states how long "who entered what" stays answerable. Both take
# the value the server resolved from this variable, so an instance running 90
# says 90 on both surfaces.
# AUDIT_LOG_RETENTION_DAYS="365"
# -----------------------------------------------------------------------------
# FHIR health-record export -- optional
# -----------------------------------------------------------------------------
+10
View File
@@ -98,6 +98,16 @@ x-healthlog-env: &healthlog_env
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
# How long the audit trail is kept, in days. Default 365, floor 7 (a value
# below the floor is ignored rather than obeyed, so a window set in seconds
# by mistake cannot empty a fresh table). It is not only a retention knob:
# the shared-access panel PUBLISHES this number — "who entered what stays
# answerable for N days" is on the dialog that ends somebody's access, and
# the activity view states the same window. An instance running 90 says 90,
# so the var has to actually reach the container. Listed here for the reason
# the block exists: `environment:` is a whitelist, and a value in .env that
# is not named here never arrives.
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
# Per-export ceiling on MedicationAdministration rows in the FHIR
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
# an operator override in .env actually reaches the container (the
@@ -0,0 +1,101 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/**
* An environment variable the product SHOWS a user has to reach the container.
*
* ## The failure this exists for
*
* `docker-compose.yml`'s `environment:` block is a whitelist. Docker Compose
* reads `.env` for `${VAR}` substitution, so a value an operator sets there
* appears to be configured, and a variable not named in the block never
* arrives in the process. v1.5.2 shipped that exact defect for
* `SESSION_COOKIE_SECURE`: documented, settable, and silently absent, which on
* a plain-HTTP self-host reads as "login is broken".
*
* `AUDIT_LOG_RETENTION_DAYS` is a sharper version of the same class, because
* the number is not merely enforced — it is PUBLISHED. `GET
* /api/account/grants` and `GET /api/account/activity` both send it, the
* activity list prints it, and the dialog that ends somebody's access states
* how long "who entered what" stays answerable. An operator who sets 90 and
* does not get it told every user 365, in a sentence about their own data.
*
* ## What this checks, and what it cannot
*
* The three places an operator meets a variable: the compose whitelist, the
* self-host example, and the production example. It cannot check that the
* value is honoured — `getAuditLogRetentionDays()` has its own tests — only
* that a value set the documented way is not dropped on the floor between
* `.env` and the process.
*
* The list is deliberately short. It is not every variable in the manifest:
* `scripts/check-env.ts` owns that question and CI runs it against
* `.env.production.example`. This is the narrower one that check-env cannot
* answer, because it reads a file rather than the compose whitelist.
*/
const ROOT = process.cwd();
const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
const ENV_EXAMPLE = readFileSync(join(ROOT, ".env.example"), "utf8");
const PROD_EXAMPLE = readFileSync(
join(ROOT, ".env.production.example"),
"utf8",
);
/**
* Variables whose value the product states back to a user.
*
* A variable earns a place here by being readable somewhere in the interface,
* not by being important. That is the property that makes a missing whitelist
* entry a lie rather than an inconvenience.
*/
const PUBLISHED_TO_USERS = [
{
name: "AUDIT_LOG_RETENTION_DAYS",
shownBy: "the shared-access activity window and the revoke dialog",
},
{
name: "SESSION_COOKIE_SECURE",
shownBy:
"nothing directly — the original of this defect class, kept as a control",
},
] as const;
describe("a variable the product publishes reaches the container", () => {
it("has a matcher that can fail", () => {
// The pins. Every leg below asserts a substring is PRESENT, and a file
// that failed to load would be an empty string in which nothing is
// present — so the failure would be loud rather than silent. These make
// the fixtures' own size the evidence.
expect(PUBLISHED_TO_USERS.length).toBeGreaterThan(1);
expect(COMPOSE).toContain("environment:");
expect(ENV_EXAMPLE.length).toBeGreaterThan(1000);
expect(PROD_EXAMPLE.length).toBeGreaterThan(1000);
// And a name nothing declares is genuinely absent from all three, so the
// matchers below are not passing on a substring that is everywhere.
for (const file of [COMPOSE, ENV_EXAMPLE, PROD_EXAMPLE]) {
expect(file).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE");
}
});
for (const { name, shownBy } of PUBLISHED_TO_USERS) {
describe(name, () => {
it(`is on the compose whitelist, so a value in .env arrives (${shownBy})`, () => {
// The substitution form, not merely the name: a bare mention in a
// comment is how this class of defect hides.
expect(COMPOSE).toMatch(
new RegExp(`^\\s*${name}:\\s*"\\$\\{${name}`, "m"),
);
});
it("is documented in the self-host example", () => {
expect(ENV_EXAMPLE).toContain(name);
});
it("is documented in the production example", () => {
expect(PROD_EXAMPLE).toContain(name);
});
});
}
});