mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(image): ship the operator scripts the runbooks run in the container
The password-reset runbook tells an operator to run `docker compose exec app node scripts/reset-password.mjs`, and the intake-repair runbook runs `healthlog-tsx scripts/repair-intake-anomalies.ts` inside the container. The runner stage copied nothing from scripts/, so both failed with "Cannot find module", the reset at the moment an admin is locked out. The runner stage now copies both scripts and the Argon2 parameter module the reset imports, and proves at build time that each reaches its own first check with every import resolved. A structural test holds every in-container command in docs/ and the Dockerfile copies in step. Key rotation is documented the way it works on the published image: the Rotate now button under Admin > Encryption is the primary path. The rotation script imports the application graph and runs from a source checkout of the same release; the runbook, the admin hint in all seven languages and the decrypt error now say so.
This commit is contained in:
1 parent
3c3e5ceab7
commit
13db7935f6
16 files changed
+203
-44
No files matched your search
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/** 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([]);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,10 @@
|
||||
*
|
||||
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
|
||||
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
|
||||
* column corpus to the configured ACTIVE key id. The CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
|
||||
* convenience that reuses the same column registry and is safe by construction:
|
||||
* column corpus to the configured ACTIVE key id. This is the primary path on
|
||||
* the published image, which contains neither a package manager nor
|
||||
* `scripts/rotate-encryption-key.ts`; the CLI runs from a source checkout and
|
||||
* reuses the same column registry. Safe by construction:
|
||||
*
|
||||
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
|
||||
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
|
||||
@@ -16,8 +17,8 @@
|
||||
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
|
||||
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
|
||||
* high-blast-radius action. An admin without a second factor cannot use the
|
||||
* button and falls back to the CLI (this is intentional: step-up cannot be
|
||||
* softened, and the CLI is the documented canonical path).
|
||||
* button and falls back to the CLI from a source checkout (intentional:
|
||||
* step-up cannot be softened).
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ export function decrypt(encoded: string): string {
|
||||
throw new Error(
|
||||
"Found a legacy-format ciphertext but no v1 key is configured. " +
|
||||
"Restore the original ENCRYPTION_KEY (or add a 'v1' entry to " +
|
||||
"ENCRYPTION_KEYS) and run scripts/rotate-encryption-key.ts before " +
|
||||
"ENCRYPTION_KEYS) and run the key rotation (Admin > Encryption) before " +
|
||||
"removing it.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
* `rotateCorpus()` to re-encrypt every row that is not already on the
|
||||
* active key.
|
||||
*
|
||||
* The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical
|
||||
* rotation path and stays independent (its own Prisma client). This module is
|
||||
* the in-app convenience that reuses the SAME registry, so the guard test keeps
|
||||
* both in lock-step.
|
||||
* The admin-triggered job is the primary rotation path on the published image.
|
||||
* The standalone CLI (`scripts/rotate-encryption-key.ts`) runs from a source
|
||||
* checkout with its own Prisma client and reuses the SAME registry, so the
|
||||
* guard test keeps both in lock-step.
|
||||
*
|
||||
* GUARANTEES (the security review must confirm these on the rotation path):
|
||||
* - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()`
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* v1.23 — admin-triggered encryption-key rotation, on pg-boss.
|
||||
*
|
||||
* Re-encrypts every registered encrypted column to the configured ACTIVE key
|
||||
* id, reusing the canonical column registry via `rotateCorpus()`. This is the
|
||||
* in-app convenience the admin panel triggers; the CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) remains the documented canonical path.
|
||||
* id, reusing the canonical column registry via `rotateCorpus()`. The admin
|
||||
* panel triggers it, and it is the documented primary path; the CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) does the same from a source checkout.
|
||||
* pg-boss is the right home because the standalone production image strips
|
||||
* `tsx`, so a button that ran the CLI inside the container would fail.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user