From 16d1b542a5df58ab01ab9cf1bd09bb0016d11a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 21:35:10 +0100 Subject: [PATCH] fix(backup): keep an erased account's copy out of admin accounts A restore read the account settings under the operator's AI endpoint policy whenever the receiving account was an admin. For a copy put into a chosen account, the settings were written by the erased account's owner, not by the operator, yet an admin account could be chosen as the target, and an ordinary target promoted later got the same trust. Admin accounts are now refused as a target, in the check and the picker, with backup.target.admin_account. A copy uploaded into a chosen account is stored as RETARGETED_UPLOAD_, and its restore never reads its settings as the operator's, whatever the account's role is by then. --- docs/api/openapi.yaml | 38 +++++----- docs/ops/backup-restore.md | 17 +++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + src/app/api/admin/backups/upload/route.ts | 15 ++-- .../__tests__/backup-target-dialog.test.tsx | 8 +- .../admin/backups/backup-target-dialog.tsx | 10 ++- .../account-settings-restore.test.ts | 66 +++++++++++++++++ src/lib/export/account-settings-restore.ts | 16 +++- src/lib/export/backup-domains.ts | 1 + src/lib/export/backup-upload-type.ts | 23 ++++++ src/lib/export/restore-backup.ts | 10 +++ src/lib/export/restore-context.ts | 5 ++ src/lib/export/restore-target.ts | 27 ++++++- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/admin-backups.ts | 2 +- .../backup-erased-account-restore.test.ts | 74 +++++++++++++++++-- 24 files changed, 276 insertions(+), 46 deletions(-) create mode 100644 src/lib/export/__tests__/account-settings-restore.test.ts create mode 100644 src/lib/export/backup-upload-type.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 29cc4ea93..a4b9df983 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25189,10 +25189,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "The chosen target cannot take the copy, and nothing was stored: a managed profile - (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an - account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username - already taken (`backup.target.username_taken`), or a file whose own account is still here - (`backup.owner.exists`)." + (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an admin + account (`backup.target.admin_account`), an account that already holds records + (`backup.target.holds_records`, `meta.models` naming them), a username already taken + (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`)." content: application/json: schema: @@ -38717,21 +38717,21 @@ components: auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing, - backup.target.holds_records, backup.target.managed_profile, backup.target.not_found, - backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken, - backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — - coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, - coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, - coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, - coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle — - cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, - cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, - cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — - cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; - data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected, - dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, - dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — + backup.target.admin_account, backup.target.holds_records, backup.target.managed_profile, + backup.target.not_found, backup.target.pending_deletion, backup.target.unconfirmed, + backup.target.username_taken, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — + body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, + consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — + dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, + dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 6f3e1baaa..94bd55a96 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -112,14 +112,21 @@ exists, and the console then asks where the records should go: count (the setup answers and the cycle settings): the restore replaces both with the copy's. A reading or medication entered on setup's last screen is a record, and the account is refused until it is deleted, because the restore - would delete it too. Managed profiles and accounts pending deletion are not - offered. + would delete it too. Managed profiles, accounts pending deletion and admin + accounts are not offered: an admin account is the operator's, its settings + are read under the operator's wider AI endpoint policy, and its role reaches + every account, so another person's record does not go into it. The file is then uploaded a second time, and its owner is changed to the chosen account as it is read. Nothing else in the file changes. The stored copy is -then that account's copy in every respect: its preview, its download and its -restore are the ordinary ones, and **Restore** on its row brings everything -back under the chosen account. The erased account's id is not stored, not in +then that account's copy in every respect but one: its preview, its download +and its restore are the ordinary ones, and **Restore** on its row brings +everything back under the chosen account. The one difference is that it is +stored as `RETARGETED_UPLOAD_