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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:22:03 +02:00
1 parent 4af6b87a2a
commit 405c996b6a
24 files changed
+1249 -25

No files matched your search

+10 -1
View File
@@ -71,6 +71,7 @@ function restoredCount(job: BackupRestoreJobView): number {
function failureText(
code: BackupRestoreFailureCode,
t: ReturnType<typeof useTranslations>["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({
<p className="font-medium">
{t("admin.section.backups.restoreJobFailedTitle", { username })}
</p>
<p className="text-xs">{failureText(code, t)}</p>
<p className="text-xs">
{failureText(code, t, job.failure?.keyIds)}
</p>
{changedNothing ? (
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.section.backups.restoreJobFailedKept")}
+17 -5
View File
@@ -233,11 +233,23 @@ function RestoreRowDialog({
{t("admin.section.backups.previewLoading")}
</p>
)}
{preview.isError && (
<p className="text-muted-foreground">
{t("admin.section.backups.previewUnavailable")}
</p>
)}
{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.
<p role="alert" className="text-destructive">
{t("admin.section.backups.previewKeyMissing", {
keys: Array.isArray(preview.error.meta.keyIds)
? preview.error.meta.keyIds.map(String).join(", ")
: "",
})}
</p>
) : (
<p className="text-muted-foreground">
{t("admin.section.backups.previewUnavailable")}
</p>
))}
{preview.isSuccess && (
<ul className="grid list-none grid-cols-1 gap-x-4 gap-y-0.5 p-0 sm:grid-cols-2">
{previewRows.map((entry) => (