Files
HealthLog/src/__tests__/container-scripts-ship-guard.test.ts
T
Marc-André Bombeck 485161a657 feat(db): run the app as a role that cannot change the schema
After the migrations the entrypoint ensures healthlog_app, a login role
with row access to the app's schema, MAINTAIN on measurements and
ownership of the pgboss schema, and starts the app with a DATABASE_URL
for it in place of the owner's. The role cannot alter or drop tables,
create roles, schemas or functions in the app's schema, read server
files or run programs, or write the migration history.

The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a
separate worker container derives the same one, and it is sent as a
SCRAM verifier. Every boot re-applies the grants in one transaction
under an advisory lock, and drops as the owner the indexes the
maintenance job may no longer drop. When the owner cannot create roles,
the server is older than Postgres 17, or the role cannot log in, the
boot says why and the app stays on the owner as before. DB_APP_ROLE=off
skips it.
2026-11-03 20:36:37 +01:00

145 lines
5.5 KiB
TypeScript

/**
* Structural guard: a script a runbook tells the operator to run inside the
* container is actually in the image, together with every local file it
* imports.
*
* ## The failure this freezes
*
* `docs/ops/password-reset.md` told operators to run
* `docker compose exec app node scripts/reset-password.mjs`, and the runner
* stage of the Dockerfile copied nothing from `scripts/`: the standalone
* output does not carry it. The recovery path for a locked-out admin failed
* with "Cannot find module" at the moment it was needed. The intake-repair
* runbook had the same gap under the `healthlog-tsx` launcher.
*
* ## What it proves and what it does not
*
* Every `docker compose exec … node scripts/…` or `healthlog-tsx scripts/…`
* command in `docs/` names
* a file the runner stage copies, and every relative import of that file is
* copied too. Whether the package imports resolve is proved at image build by
* the `RUN` checks next to the copies; this file cannot see the traced tree.
* A command written in some other form (a different launcher) is outside the
* matcher.
*/
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
const ROOT = process.cwd();
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
/**
* In-container command forms: `docker compose exec <service> node scripts/…`
* or the image's own `healthlog-tsx scripts/…` launcher. A bare
* `node scripts/…` is a workstation or source-checkout command and is out of
* scope.
*/
const CONTAINER_COMMAND =
/(?:docker compose exec\s+(?:-\S+\s+)*\S+\s+node|\bhealthlog-tsx)\s+(scripts\/[\w.-]+)/g;
function markdownFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true, encoding: "utf8" })
.filter((rel) => rel.endsWith(".md"))
.map((rel) => join(dir, rel));
}
function runnerStage(): string {
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
return DOCKERFILE.slice(start);
}
function copiedIntoRunner(path: string): boolean {
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`^COPY --from=builder (?:--chown=\\S+ )?/app/${escaped} \\./${escaped}$`,
"m",
).test(runnerStage());
}
/** Scripts named by a container command anywhere under docs/. */
function documentedContainerScripts(): string[] {
const found = new Set<string>();
for (const file of markdownFiles(join(ROOT, "docs"))) {
for (const m of readFileSync(file, "utf8").matchAll(CONTAINER_COMMAND)) {
found.add(m[1]);
}
}
return [...found].sort();
}
/** Scripts `docker-entrypoint.sh` runs with `node scripts/…` on every boot. */
function entrypointScripts(): string[] {
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
return [
...new Set(
// A line that starts with the call, or a command substitution that
// makes it (`url="$(node scripts/db-app-role.mjs …)"`).
[
...entrypoint.matchAll(/(?:^\s*|\$\(\s*)node\s+(scripts\/[\w.-]+)/gm),
].map((m) => m[1]),
),
].sort();
}
/** Relative import specifiers of a script, resolved against its location. */
function localImports(script: string): string[] {
const src = readFileSync(join(ROOT, script), "utf8");
const specs = [
...src.matchAll(/^\s*import\s[^;]*?from\s+["'](\.{1,2}\/[^"']+)["']/gm),
].map((m) => m[1]);
return specs.map((spec) => normalize(join(dirname(script), spec)));
}
describe("scripts the runbooks run inside the container ship in the image", () => {
it("finds the commands it is meant to check", () => {
const scripts = documentedContainerScripts();
// A matcher that found nothing would agree with any Dockerfile.
expect(scripts).toContain("scripts/reset-password.mjs");
expect(scripts).toContain("scripts/repair-intake-anomalies.ts");
expect(localImports("scripts/reset-password.mjs")).toContain(
"src/lib/auth/argon2-params.mjs",
);
expect(copiedIntoRunner("scripts/not-a-real-script.mjs")).toBe(false);
});
it("copies every documented script into the runner stage", () => {
const missing = documentedContainerScripts().filter(
(script) => !copiedIntoRunner(script),
);
expect(
missing,
"A runbook runs these inside the container, but the Dockerfile runner " +
"stage does not COPY them. Add the COPY (and its local imports), or " +
"change the runbook to a source-checkout command.",
).toEqual([]);
});
it("copies every local file those scripts import", () => {
const missing = documentedContainerScripts()
.flatMap((script) => localImports(script))
.filter((path) => !copiedIntoRunner(path));
expect(missing).toEqual([]);
});
});
describe("scripts the entrypoint runs ship in the image", () => {
it("finds the boot scripts it is meant to check", () => {
// An empty match would agree with any Dockerfile.
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
expect(entrypointScripts()).toContain("scripts/db-app-role.mjs");
});
it("copies every boot script and its local imports into the runner stage", () => {
const missing = entrypointScripts()
.flatMap((script) => [script, ...localImports(script)])
.filter((path) => !copiedIntoRunner(path));
expect(
missing,
"docker-entrypoint.sh runs these on every boot, but the Dockerfile " +
"runner stage does not COPY them; the container would fail to start.",
).toEqual([]);
});
});