From 405c996b6ae0fa354111f50e32673c4ad94309fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 19:22:03 +0200 Subject: [PATCH 1/6] fix(backup): refuse a restore that needs a dropped key or reaches into another account A backup carries every encrypted column as the stored ciphertext, and a restore writes it back verbatim. Key rotation re-seals the stored copy's envelope but cannot reach inside it, so a copy taken before a rotation still needs the retired key. Without it the restore went through and left rows no reader can open. The restore, the restore preview, the admin upload and the monthly restore drill now collect the key ids of every inner ciphertext value, hold them against ENCRYPTION_KEYS and decrypt one value per key. A missing key, or an id configured with different key material, is refused before anything is deleted, with the key id named. The restore status and the preview say which key to put back. The restore also checks, inside its transaction, that it added no reference from the account's rows to rows another account owns. The check is derived from the schema's foreign keys rather than written per section, so the tag and category upserts keyed on a global key are covered along with every link table. The illness parent update is scoped to the owner as well. The off-host listing now follows every page: the first page held only the oldest dates, so the drill could read an old object and report the chain as stale. --- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- .../api/admin/backups/[id]/summary/route.ts | 25 +- src/app/api/admin/backups/upload/route.ts | 29 ++ .../admin/backup-restore-status.tsx | 11 +- src/components/admin/backups-section.tsx | 22 +- .../export/__tests__/backup-key-ids.test.ts | 173 +++++++++ .../restore-backup-round-trip.test.ts | 10 +- src/lib/export/backup-key-ids.ts | 334 ++++++++++++++++++ src/lib/export/restore-backup.ts | 70 +++- src/lib/export/streamed-backup.ts | 12 + src/lib/export/tenant-boundary.ts | 178 ++++++++++ .../__tests__/backup-restore-outcome.test.ts | 2 + src/lib/jobs/__tests__/restore-drill.test.ts | 38 ++ src/lib/jobs/backup-restore.ts | 8 + src/lib/jobs/offhost-backup.ts | 33 +- src/lib/jobs/restore-drill.ts | 27 ++ src/lib/openapi/routes/admin-backups.ts | 5 +- .../backup-restore-boundaries.test.ts | 262 ++++++++++++++ 24 files changed, 1249 insertions(+), 25 deletions(-) create mode 100644 src/lib/export/__tests__/backup-key-ids.test.ts create mode 100644 src/lib/export/backup-key-ids.ts create mode 100644 src/lib/export/tenant-boundary.ts create mode 100644 tests/integration/backup-restore-boundaries.test.ts diff --git a/messages/de.json b/messages/de.json index 06801e37e..bd57dc6e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7309,7 +7309,10 @@ "restoreFailureNotStarted": "Der Hintergrunddienst hat die Wiederherstellung nicht übernommen. Prüfe, ob er läuft.", "restoreFailureEnqueue": "Die Wiederherstellung konnte nicht an den Hintergrunddienst übergeben werden. Prüfe, ob er läuft.", "restoreFailureAfterCommit": "Die Daten wurden wiederhergestellt, aber ein Schritt danach ist fehlgeschlagen. Diagramme holen das womöglich erst beim nächsten nächtlichen Lauf nach.", - "restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler." + "restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.", + "restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.", + "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.", + "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu." }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index fa1fb5458..f1d2fa36c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7309,7 +7309,10 @@ "restoreFailureNotStarted": "The background worker did not pick the restore up. Check that the worker is running.", "restoreFailureEnqueue": "The restore could not be handed to the background worker. Check that the worker is running.", "restoreFailureAfterCommit": "The data was restored, but a step after it failed. Charts may take until the next nightly run to catch up.", - "restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error." + "restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.", + "restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.", + "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.", + "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first." }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 46f903102..6381fb29a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7309,7 +7309,10 @@ "restoreFailureNotStarted": "El proceso en segundo plano no recogió la restauración. Comprueba que está en marcha.", "restoreFailureEnqueue": "No se pudo entregar la restauración al proceso en segundo plano. Comprueba que está en marcha.", "restoreFailureAfterCommit": "Los datos se restauraron, pero falló un paso posterior. Puede que los gráficos no se pongan al día hasta la próxima ejecución nocturna.", - "restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error." + "restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.", + "restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.", + "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.", + "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero." }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 722b77a24..8308f8e1a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7309,7 +7309,10 @@ "restoreFailureNotStarted": "Le processus en arrière-plan n'a pas pris la restauration en charge. Vérifie qu'il tourne.", "restoreFailureEnqueue": "La restauration n'a pas pu être transmise au processus en arrière-plan. Vérifie qu'il tourne.", "restoreFailureAfterCommit": "Les données ont été restaurées, mais une étape suivante a échoué. Les graphiques ne seront peut-être à jour qu'après la prochaine exécution nocturne.", - "restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur." + "restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur.", + "restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.", + "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.", + "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez." }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index ae8310f2d..39e7a2901 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7309,7 +7309,10 @@ "restoreFailureNotStarted": "Il processo in background non ha preso in carico il ripristino. Controlla che sia in esecuzione.", "restoreFailureEnqueue": "Non è stato possibile passare il ripristino al processo in background. Controlla che sia in esecuzione.", "restoreFailureAfterCommit": "I dati sono stati ripristinati, ma un passaggio successivo non è riuscito. I grafici potrebbero aggiornarsi solo con la prossima esecuzione notturna.", - "restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore." + "restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.", + "restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.", + "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.", + "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia." }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 674b6c9a3..38a032204 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7288,7 +7288,10 @@ "restoreFailureNotStarted": "백그라운드 작업자가 복원을 가져가지 않았어요. 작업자가 실행 중인지 확인해 주세요.", "restoreFailureEnqueue": "복원을 백그라운드 작업자에게 넘기지 못했어요. 작업자가 실행 중인지 확인해 주세요.", "restoreFailureAfterCommit": "데이터는 복원됐지만 그다음 단계가 실패했어요. 차트는 다음 야간 실행 때 따라잡을 수 있어요.", - "restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요." + "restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.", + "restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.", + "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", + "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요." }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index a2c23d8b3..ec48365ea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7309,7 +7309,10 @@ "restoreFailureNotStarted": "Proces w tle nie przejął przywracania. Sprawdź, czy działa.", "restoreFailureEnqueue": "Nie udało się przekazać przywracania do procesu w tle. Sprawdź, czy działa.", "restoreFailureAfterCommit": "Dane zostały przywrócone, ale krok po nich się nie powiódł. Wykresy mogą nadrobić to dopiero przy następnym nocnym przebiegu.", - "restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd." + "restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.", + "restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.", + "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.", + "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie." }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts index d8f89eb6d..b1499a1ed 100644 --- a/src/app/api/admin/backups/[id]/summary/route.ts +++ b/src/app/api/admin/backups/[id]/summary/route.ts @@ -22,9 +22,15 @@ import { storedBackupRefusal, STORED_BACKUP_SELECT, } from "@/lib/export/stored-backup"; +import { + assessBackupKeys, + BACKUP_KEY_MISSING_CODE, + describeBackupKeyProblem, +} from "@/lib/export/backup-key-ids"; import { readStreamedBackup, type BackupSource, + type StreamedBackup, } from "@/lib/export/streamed-backup"; import { annotate } from "@/lib/logging/context"; import { @@ -72,8 +78,9 @@ export const GET = apiHandler( let payload; let measurementCount: number; + let streamed: StreamedBackup; try { - const streamed = await readStreamedBackup(source); + streamed = await readStreamedBackup(source); measurementCount = streamed.measurementCount; payload = parseBackupPayload(streamed.raw); } catch (err) { @@ -93,6 +100,22 @@ export const GET = apiHandler( ); } + // The preview is where the operator decides, so it says what the restore + // would refuse: a key the file's inner ciphertext needs and this host + // does not have. The instance settings are left out here, as the restore + // leaves them out unless asked; the restore itself checks them when they + // are asked for. + const keyVerdict = assessBackupKeys(streamed.keys, { + ignoreSections: new Set(["appSettings"]), + }); + const keyProblem = describeBackupKeyProblem(keyVerdict); + if (keyProblem) { + return apiError(keyProblem, 422, { + errorCode: BACKUP_KEY_MISSING_CODE, + keyIds: [...keyVerdict.missing, ...keyVerdict.unreadable], + }); + } + const summary = { ...summarizeBackup(payload), measurements: measurementCount, diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 50e74d36f..6e6ba6e4a 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -38,6 +38,12 @@ import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan"; +import { + assessBackupKeys, + BACKUP_KEY_MISSING_CODE, + BackupKeyIdCollector, + describeBackupKeyProblem, +} from "@/lib/export/backup-key-ids"; import { BACKUP_UPLOAD_TOO_LARGE_CODE, BackupBlobTooLargeError, @@ -235,6 +241,7 @@ export const POST = apiHandler(async (request: NextRequest) => { } } let firstWithoutId: number | null = null; + const keys = new BackupKeyIdCollector(); let scanned; try { scanned = await scanBackupJson(storedAsRead(), { @@ -251,6 +258,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!parsed.data.id && firstWithoutId === null) { firstWithoutId = index; } + keys.visit(element, "measurements"); }, }); } catch (err) { @@ -319,6 +327,27 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } + // A file whose inner ciphertext needs a key this host does not have + // can be stored but never restored: the restore would refuse it, or + // worse, a restore on a host that had lost the key would write rows + // nobody can open. Refused here, where the operator is looking, with + // the key named. The instance settings are left to the restore, which + // checks them only when they are asked for back. + keys.visit(scanned.document); + const keyVerdict = assessBackupKeys(keys, { + ignoreSections: new Set(["appSettings"]), + }); + const keyProblem = describeBackupKeyProblem(keyVerdict); + if (keyProblem) { + const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable]; + throw new UploadRefused( + 422, + keyProblem, + { reason: "key_missing", keyIds }, + { errorCode: BACKUP_KEY_MISSING_CODE, keyIds }, + ); + } + // Make sure the userId on the file points at a user that exists in // this DB. Restore would fail later anyway, but failing here gives // the admin a precise error before any side-effect. diff --git a/src/components/admin/backup-restore-status.tsx b/src/components/admin/backup-restore-status.tsx index 7707f7aa4..3b9fed7c7 100644 --- a/src/components/admin/backup-restore-status.tsx +++ b/src/components/admin/backup-restore-status.tsx @@ -71,6 +71,7 @@ function restoredCount(job: BackupRestoreJobView): number { function failureText( code: BackupRestoreFailureCode, t: ReturnType["t"], + keyIds: readonly string[] = [], ): string { switch (code) { case "backup_not_found": @@ -91,6 +92,12 @@ function failureText( return t("admin.section.backups.restoreFailureSectionMissing"); case "document_ciphertext_missing": return t("admin.section.backups.restoreFailureDocument"); + case "backup.key.missing": + return t("admin.section.backups.restoreFailureKeyMissing", { + keys: keyIds.join(", "), + }); + case "backup.foreign_reference": + return t("admin.section.backups.restoreFailureForeignReference"); case "time_budget": return t("admin.section.backups.restoreFailureTimeBudget"); case "transaction_failed": @@ -289,7 +296,9 @@ export function RestoreJobStatus({

{t("admin.section.backups.restoreJobFailedTitle", { username })}

-

{failureText(code, t)}

+

+ {failureText(code, t, job.failure?.keyIds)} +

{changedNothing ? (

{t("admin.section.backups.restoreJobFailedKept")} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index b3bde1269..8cd2faf09 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -233,11 +233,23 @@ function RestoreRowDialog({ {t("admin.section.backups.previewLoading")}

)} - {preview.isError && ( -

- {t("admin.section.backups.previewUnavailable")} -

- )} + {preview.isError && + (preview.error instanceof ApiError && + preview.error.meta?.errorCode === "backup.key.missing" ? ( + // The one preview failure the restore would repeat: say which + // key, so the operator can put it back before trying. +

+ {t("admin.section.backups.previewKeyMissing", { + keys: Array.isArray(preview.error.meta.keyIds) + ? preview.error.meta.keyIds.map(String).join(", ") + : "", + })} +

+ ) : ( +

+ {t("admin.section.backups.previewUnavailable")} +

+ ))} {preview.isSuccess && (