mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
+22
@@ -170,6 +170,24 @@ COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder /app/src/generated ./src/generated
|
||||
|
||||
# Operator maintenance scripts that a runbook tells the operator to run inside
|
||||
# this container (`docs/ops/password-reset.md`, `docs/ops/intake-repair.md`).
|
||||
# The standalone output carries none of `scripts/`, so without these lines the
|
||||
# documented `docker compose exec app node scripts/reset-password.mjs` failed
|
||||
# with "Cannot find module". Only self-contained scripts belong here: plain
|
||||
# `node` or the `healthlog-tsx` launcher below, resolving `pg` and
|
||||
# `@node-rs/argon2` from the traced runtime tree. Scripts that import the
|
||||
# application graph (`@/…`, the generated Prisma client) run from a source
|
||||
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
|
||||
# the runbooks and this list in step. Root-owned on purpose: the app user can
|
||||
# run them but not rewrite them.
|
||||
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
|
||||
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
|
||||
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
|
||||
# Prove the reset CLI's imports resolve in this tree: with every import found
|
||||
# it stops at its own usage line; a missing module throws before reaching it.
|
||||
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
|
||||
|
||||
# Install the migration CLI, its config dependency, and a pinned launcher for
|
||||
# the maintenance scripts already shipped in the standalone tree. The Prisma
|
||||
# config loads dotenv from /app, so expose the isolated copy there before
|
||||
@@ -213,6 +231,10 @@ RUN mkdir -p /opt/prisma-cli && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
|
||||
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
|
||||
|
||||
# Same proof for the intake-repair script under the launcher it is documented
|
||||
# with: its `pg` import resolves, so it reaches its own DATABASE_URL check.
|
||||
RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | grep -q "DATABASE_URL must be set"
|
||||
|
||||
# v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN
|
||||
# lookups. The MMDB files live in `/opt/geolite2/` and are read by
|
||||
# `src/lib/geo.ts` via `mmdb-lib`. They are downloaded outside the
|
||||
|
||||
@@ -70,10 +70,11 @@ worker`. Default `all` keeps the single-container behaviour.
|
||||
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
|
||||
when `ENCRYPTION_KEYS` is empty. No action needed for existing
|
||||
installs.
|
||||
- Rotation: `pnpm dlx tsx scripts/rotate-encryption-key.ts <newKeyId>`
|
||||
re-encrypts every ciphertext under the new key without downtime.
|
||||
- Rotation: **Admin → Encryption → Rotate now** re-encrypts every
|
||||
ciphertext under the new key without downtime; the script
|
||||
`scripts/rotate-encryption-key.ts` does the same from a source checkout.
|
||||
Walk-through in `docs/ops/encryption-key-rotation.md` (the current
|
||||
playbook; the production image strips `tsx`, hence `pnpm dlx`).
|
||||
playbook).
|
||||
|
||||
### Off-host backup target _(optional)_
|
||||
|
||||
|
||||
@@ -41,15 +41,33 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
Restart the app. New writes are now encrypted under `v2`; existing
|
||||
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
|
||||
retained.
|
||||
3. **Run the rotation script** to re-encrypt every registered encrypted column
|
||||
under the new active key:
|
||||
3. **Re-encrypt the stored data under the new key.** Open **Admin →
|
||||
Encryption** and press **Rotate now**. The button needs an admin account
|
||||
with a second factor, confirmed recently; it queues one background run
|
||||
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
|
||||
works on the published image with nothing else installed. The same page
|
||||
shows the per-column counts and the result of the last run.
|
||||
|
||||
Without a second factor on any admin account, run the rotation script
|
||||
instead. It imports the application code and the generated database
|
||||
client, so it runs from a source checkout of the same release, not
|
||||
inside the production container (the image has no package manager and
|
||||
does not contain `scripts/rotate-encryption-key.ts`). It needs the same
|
||||
`ENCRYPTION_KEYS` and `ENCRYPTION_ACTIVE_KEY_ID` as the app and a
|
||||
`DATABASE_URL` that reaches the database:
|
||||
|
||||
```
|
||||
git checkout vX.Y.Z # the release the instance runs
|
||||
pnpm install && pnpm db:generate
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates every column in the canonical registry
|
||||
The bundled `db` service publishes no port. On a stock compose stack,
|
||||
either publish `5432` for the duration, or run the checkout in a
|
||||
throwaway `node:22` container attached to the compose network.
|
||||
|
||||
Both paths are idempotent: running again is a no-op for rows already
|
||||
prefixed with `v2.`. Both rotate every column in the canonical registry
|
||||
(`src/lib/crypto/encrypted-columns.ts`), which covers the `*Encrypted`
|
||||
columns plus the ones whose names say nothing about their contents:
|
||||
`IntegrationStatus.lastError`, `CoachMessage.encryptedContent`,
|
||||
@@ -58,7 +76,8 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
`IdempotencyKey.responseBody`, and `DataBackup.data` — the whole-account
|
||||
backup blob.
|
||||
|
||||
Read three things off the output before going further:
|
||||
When you used the script, read three things off its output before going
|
||||
further:
|
||||
|
||||
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`.
|
||||
`scanned` counts the rows that hold ciphertext in that column, not every
|
||||
@@ -78,10 +97,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
|
||||
4. **Confirm nothing is left on the old key, THEN drop it.** This is the step
|
||||
that destroys data if it is taken on a bad signal, so check the corpus
|
||||
rather than the absence of complaints. Either re-run the script — a clean
|
||||
second pass reports `rotated=0`, `errors=0` and a full `Columns walked`
|
||||
line — or open **Admin → Encryption** and confirm the status view reports
|
||||
zero rows outside the active key. Both read the same registry the rotation
|
||||
rather than the absence of complaints. Open **Admin → Encryption** and
|
||||
confirm the status view reports zero rows outside the active key, or
|
||||
re-run the script: a clean second pass reports `rotated=0`, `errors=0` and
|
||||
a full `Columns walked` line. Both read the same registry the rotation
|
||||
walks.
|
||||
|
||||
A zero only means "nothing left" when the run also says it walked every
|
||||
@@ -117,13 +136,14 @@ remaining.
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
|
||||
# restart, then Admin → Encryption → Rotate now
|
||||
# (or: pnpm dlx tsx scripts/rotate-encryption-key.ts v3 from a checkout)
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
> **Important.** Once the rotation script has run, ciphertexts in the
|
||||
> **Important.** Once a rotation has run, ciphertexts in the
|
||||
> database start with `v2.` (or whatever the active id is). The pre-PR
|
||||
> v1.3.x image cannot read that prefix — it expects bare base64 — and
|
||||
> calling `decrypt()` on those rows will throw.
|
||||
@@ -133,7 +153,7 @@ If you need to revert to the pre-rotation image:
|
||||
- Either keep the new image. The new code reads both formats, so most
|
||||
rollback scenarios don't need to undo rotation.
|
||||
- Or, if you must run the old code, restore a database backup taken
|
||||
_before_ the rotation script ran. There is no script to convert
|
||||
_before_ the rotation ran. There is no script to convert
|
||||
`v2.`-prefixed rows back to legacy format — by design, rotation is a
|
||||
forward-only operation.
|
||||
|
||||
@@ -145,10 +165,10 @@ at least 24 hours.
|
||||
|
||||
- `Encryption key id 'v1' is not configured` — the database still contains
|
||||
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
|
||||
the key, run the rotation script, then remove again.
|
||||
the key, run the rotation again, then remove it.
|
||||
- `Found a legacy-format ciphertext but no v1 key is configured` — same
|
||||
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
|
||||
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
|
||||
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation again before
|
||||
removing it.
|
||||
- `Refusing to rotate: argv key id ... does not match the currently active
|
||||
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
|
||||
|
||||
@@ -18,10 +18,12 @@ instance).
|
||||
|
||||
## Run it
|
||||
|
||||
The reset CLI ships in the image and runs under plain `node` against the built
|
||||
runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
|
||||
in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
|
||||
parameters):
|
||||
The reset CLI ships in the image from v1.39.3 on. It runs under plain `node`
|
||||
against the built runtime: a `.mjs` file that uses the in-image `pg` and
|
||||
`@node-rs/argon2` directly and reuses the app's exact Argon2id parameters.
|
||||
Images up to and including v1.39.2 did not contain the script, so on those
|
||||
the command below fails with "Cannot find module"; use the source-checkout
|
||||
form at the end of this page instead.
|
||||
|
||||
```sh
|
||||
docker compose exec app node scripts/reset-password.mjs <username-or-email>
|
||||
@@ -32,7 +34,7 @@ confirmation that names only the user — never the password:
|
||||
|
||||
```
|
||||
New password (input hidden):
|
||||
reset-password: password updated for "alice"
|
||||
reset-password: password updated for "alice"; revoked 2 sessions, 0 API tokens, 1 refresh tokens, 0 trusted devices, and 0 step-up elevations
|
||||
```
|
||||
|
||||
You can also pass the password as a second argument for non-interactive use,
|
||||
@@ -57,8 +59,10 @@ docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphra
|
||||
|
||||
## Source checkout
|
||||
|
||||
From a source checkout (not the production image) the same script runs under
|
||||
Node directly:
|
||||
From a source checkout of the same release (after `pnpm install`) the same
|
||||
script runs under Node directly. It needs a `DATABASE_URL` that reaches the
|
||||
database; the bundled `db` service publishes no port, so on a stock compose
|
||||
stack run it inside the compose network or publish the port temporarily:
|
||||
|
||||
```sh
|
||||
DATABASE_URL='postgres://…' node scripts/reset-password.mjs <username-or-email>
|
||||
|
||||
+1
-1
@@ -7348,7 +7348,7 @@
|
||||
"rotateEnqueued": "Rotation gestartet.",
|
||||
"rotateFailed": "Rotation konnte nicht gestartet werden.",
|
||||
"stepUpNeeded": "Ein frischer zweiter Faktor ist erforderlich. Bestätige deinen zweiten Faktor oder nutze das Kommandozeilen-Werkzeug, falls kein zweiter Faktor eingerichtet ist.",
|
||||
"cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Betreiber ohne zweiten Faktor rotieren mit scripts/rotate-encryption-key.ts.",
|
||||
"cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Ohne ihn führen Betreiber scripts/rotate-encryption-key.ts aus einem Quellcode-Checkout dieser Version aus; die Schritte stehen in docs/ops/encryption-key-rotation.md.",
|
||||
"columnsTitle": "Abdeckung pro Spalte",
|
||||
"columnsDescription": "Jede verschlüsselte Spalte mit ihren Zeilenzahlen auf dem aktiven Schlüssel gegenüber älteren Schlüsseln.",
|
||||
"colColumn": "Spalte",
|
||||
|
||||
+1
-1
@@ -7348,7 +7348,7 @@
|
||||
"rotateEnqueued": "Rotation started.",
|
||||
"rotateFailed": "Could not start the rotation.",
|
||||
"stepUpNeeded": "A fresh second factor is required. Confirm your second factor, or use the command-line tool if you have no second factor enrolled.",
|
||||
"cliNote": "A second factor is required to use this button. Operators without one rotate with scripts/rotate-encryption-key.ts.",
|
||||
"cliNote": "A second factor is required to use this button. Without one, run scripts/rotate-encryption-key.ts from a source checkout of this release; the steps are in docs/ops/encryption-key-rotation.md.",
|
||||
"columnsTitle": "Per-column coverage",
|
||||
"columnsDescription": "Each encrypted column with its row counts on the active key versus older keys.",
|
||||
"colColumn": "Column",
|
||||
|
||||
+1
-1
@@ -7348,7 +7348,7 @@
|
||||
"rotateEnqueued": "Rotación iniciada.",
|
||||
"rotateFailed": "No se pudo iniciar la rotación.",
|
||||
"stepUpNeeded": "Se requiere un segundo factor reciente. Confirma tu segundo factor o usa la herramienta de línea de comandos si no tienes ninguno configurado.",
|
||||
"cliNote": "Se necesita un segundo factor para usar este botón. Los operadores sin él rotan con scripts/rotate-encryption-key.ts.",
|
||||
"cliNote": "Se necesita un segundo factor para usar este botón. Sin él, ejecuta scripts/rotate-encryption-key.ts desde una copia del código fuente de esta versión; los pasos están en docs/ops/encryption-key-rotation.md.",
|
||||
"columnsTitle": "Cobertura por columna",
|
||||
"columnsDescription": "Cada columna cifrada con sus recuentos de filas en la clave activa frente a claves anteriores.",
|
||||
"colColumn": "Columna",
|
||||
|
||||
+1
-1
@@ -7348,7 +7348,7 @@
|
||||
"rotateEnqueued": "Rotation démarrée.",
|
||||
"rotateFailed": "Impossible de démarrer la rotation.",
|
||||
"stepUpNeeded": "Un second facteur récent est requis. Confirmez votre second facteur, ou utilisez l'outil en ligne de commande si aucun n'est configuré.",
|
||||
"cliNote": "Un second facteur est requis pour ce bouton. Les opérateurs sans second facteur utilisent scripts/rotate-encryption-key.ts.",
|
||||
"cliNote": "Un second facteur est requis pour ce bouton. Sans lui, lancez scripts/rotate-encryption-key.ts depuis une copie des sources de cette version ; les étapes sont dans docs/ops/encryption-key-rotation.md.",
|
||||
"columnsTitle": "Couverture par colonne",
|
||||
"columnsDescription": "Chaque colonne chiffrée avec ses décomptes de lignes sur la clé active par rapport aux clés plus anciennes.",
|
||||
"colColumn": "Colonne",
|
||||
|
||||
+1
-1
@@ -7348,7 +7348,7 @@
|
||||
"rotateEnqueued": "Rotazione avviata.",
|
||||
"rotateFailed": "Impossibile avviare la rotazione.",
|
||||
"stepUpNeeded": "È richiesto un secondo fattore recente. Conferma il secondo fattore oppure usa lo strumento da riga di comando se non ne hai configurato uno.",
|
||||
"cliNote": "Per questo pulsante serve un secondo fattore. Gli operatori senza secondo fattore ruotano con scripts/rotate-encryption-key.ts.",
|
||||
"cliNote": "Per questo pulsante serve un secondo fattore. Senza, esegui scripts/rotate-encryption-key.ts da una copia dei sorgenti di questa versione; i passaggi sono in docs/ops/encryption-key-rotation.md.",
|
||||
"columnsTitle": "Copertura per colonna",
|
||||
"columnsDescription": "Ogni colonna cifrata con i conteggi di righe sulla chiave attiva rispetto alle chiavi più vecchie.",
|
||||
"colColumn": "Colonna",
|
||||
|
||||
+1
-1
@@ -7327,7 +7327,7 @@
|
||||
"rotateEnqueued": "교체를 시작했어요.",
|
||||
"rotateFailed": "교체를 시작하지 못했어요.",
|
||||
"stepUpNeeded": "2단계 인증을 다시 확인해야 해요. 2단계 인증을 확인하거나, 등록한 수단이 없으면 명령줄 도구를 써 주세요.",
|
||||
"cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않은 운영자는 scripts/rotate-encryption-key.ts로 교체해 주세요.",
|
||||
"cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않았다면 이 버전의 소스 코드 체크아웃에서 scripts/rotate-encryption-key.ts를 실행해 주세요. 절차는 docs/ops/encryption-key-rotation.md에 있어요.",
|
||||
"columnsTitle": "열별 적용 범위",
|
||||
"columnsDescription": "암호화된 열마다 활성 키와 이전 키의 행 수를 보여줘요.",
|
||||
"colColumn": "열",
|
||||
|
||||
+1
-1
@@ -7348,7 +7348,7 @@
|
||||
"rotateEnqueued": "Rotacja rozpoczęta.",
|
||||
"rotateFailed": "Nie udało się rozpocząć rotacji.",
|
||||
"stepUpNeeded": "Wymagany jest świeży drugi składnik. Potwierdź drugi składnik lub użyj narzędzia wiersza poleceń, jeśli nie masz skonfigurowanego żadnego.",
|
||||
"cliNote": "Do tego przycisku wymagany jest drugi składnik. Operatorzy bez niego rotują przez scripts/rotate-encryption-key.ts.",
|
||||
"cliNote": "Do tego przycisku wymagany jest drugi składnik. Bez niego uruchom scripts/rotate-encryption-key.ts z kopii kodu źródłowego tej wersji; kroki opisuje docs/ops/encryption-key-rotation.md.",
|
||||
"columnsTitle": "Pokrycie wg kolumn",
|
||||
"columnsDescription": "Każda zaszyfrowana kolumna z liczbą wierszy na aktywnym kluczu względem starszych kluczy.",
|
||||
"colColumn": "Kolumna",
|
||||
|
||||
@@ -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