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 && (