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_<ms>, and its restore never reads
its settings as the operator's, whatever the account's role is by then.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 21:36:33 +01:00
1 parent dfe34386be
commit 16d1b542a5
24 files changed
+276 -46

No files matched your search

+19 -19
View File
@@ -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,
+12 -5
View File
@@ -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_<time>` rather than `MANUAL_UPLOAD_<time>`, and its
restore reads the file's settings as the file's author's, never as the
operator's: an AI endpoint on a private address comes back only when this host
grants it to ordinary accounts (`AI_PRIVATE_ORIGINS`), even if the account has
been made an admin since. The erased account's id is not stored, not in
the copy and not in the audit trail.
A copy whose own account still exists on this server can only go back into
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "Dieses Konto hat schon Datensätze. Eine Wiederherstellung ersetzt Datensätze, statt sie zu ergänzen. Wähle ein Konto ohne Datensätze oder lege ein neues an.",
"failureManagedProfile": "Dieses Konto ist ein verwaltetes Profil. Die betreuenden Personen führen es, deshalb kann keine Kopie hinein.",
"failurePendingDeletion": "Dieses Konto wird gerade gelöscht. Mach die Löschung zuerst rückgängig oder wähle ein anderes Konto.",
"failureAdminAccount": "Das ist ein Admin-Konto. Die Datensätze einer anderen Person können nicht hinein. Wähle ein normales Konto oder lege ein neues an.",
"failureNotFound": "Dieses Konto gibt es nicht mehr.",
"failureUnconfirmed": "Tippe den Benutzernamen des Kontos genau ab, um zu bestätigen.",
"failureUsernameTaken": "Dieser Benutzername ist schon vergeben.",
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "This account already holds records. A restore replaces records instead of adding to them, so choose an account without records or create a new one.",
"failureManagedProfile": "This account is a managed profile. The people who look after it run it, so a copy cannot go into it.",
"failurePendingDeletion": "This account is pending deletion. Undo the deletion first, or choose another account.",
"failureAdminAccount": "This is an admin account. Another person's records cannot go into it. Choose an ordinary account or create a new one.",
"failureNotFound": "This account no longer exists.",
"failureUnconfirmed": "Type the account's username exactly to confirm.",
"failureUsernameTaken": "This username is already taken.",
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "Esta cuenta ya tiene registros. Una restauración reemplaza los registros en lugar de añadirlos, así que elige una cuenta sin registros o crea una nueva.",
"failureManagedProfile": "Esta cuenta es un perfil gestionado. La llevan las personas responsables, así que una copia no puede ir a ella.",
"failurePendingDeletion": "Esta cuenta está pendiente de eliminación. Deshaz primero la eliminación o elige otra cuenta.",
"failureAdminAccount": "Esta es una cuenta de administrador. Los registros de otra persona no pueden ir en ella, así que elige una cuenta normal o crea una nueva.",
"failureNotFound": "Esta cuenta ya no existe.",
"failureUnconfirmed": "Escribe exactamente el nombre de usuario de la cuenta para confirmar.",
"failureUsernameTaken": "Este nombre de usuario ya está en uso.",
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "Ce compte contient déjà des enregistrements. Une restauration remplace les enregistrements au lieu de les compléter : choisissez un compte sans enregistrements ou créez-en un nouveau.",
"failureManagedProfile": "Ce compte est un profil géré. Les personnes responsables le gèrent, une copie ne peut donc pas y aller.",
"failurePendingDeletion": "Ce compte est en cours de suppression. Annulez d'abord la suppression ou choisissez un autre compte.",
"failureAdminAccount": "Il s’agit d’un compte administrateur. Les enregistrements d’une autre personne ne peuvent pas y être placés : choisissez un compte ordinaire ou créez-en un nouveau.",
"failureNotFound": "Ce compte n'existe plus.",
"failureUnconfirmed": "Tapez exactement le nom d'utilisateur du compte pour confirmer.",
"failureUsernameTaken": "Ce nom d'utilisateur est déjà pris.",
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "Questo account ha già dei record. Un ripristino sostituisce i record invece di aggiungerli, quindi scegli un account senza record o creane uno nuovo.",
"failureManagedProfile": "Questo account è un profilo gestito. Lo gestiscono le persone responsabili, quindi una copia non può andarci.",
"failurePendingDeletion": "Questo account è in attesa di eliminazione. Annulla prima l’eliminazione o scegli un altro account.",
"failureAdminAccount": "Questo è un account amministratore. I record di un’altra persona non possono andarci, quindi scegli un account normale o creane uno nuovo.",
"failureNotFound": "Questo account non esiste più.",
"failureUnconfirmed": "Digita esattamente il nome utente dell’account per confermare.",
"failureUsernameTaken": "Questo nome utente è già in uso.",
+1
View File
@@ -8372,6 +8372,7 @@
"failureHoldsRecords": "이 계정에는 이미 기록이 있어요. 복원은 기록을 더하지 않고 바꿔 쓰기 때문에 기록이 없는 계정을 고르거나 새 계정을 만들어 주세요.",
"failureManagedProfile": "이 계정은 관리 프로필이에요. 보호자들이 운영하는 프로필이라 사본을 넣을 수 없어요.",
"failurePendingDeletion": "이 계정은 삭제 대기 중이에요. 먼저 삭제를 취소하거나 다른 계정을 골라 주세요.",
"failureAdminAccount": "관리자 계정이에요. 다른 사람의 기록은 이 계정에 넣을 수 없으니 일반 계정을 고르거나 새 계정을 만들어 주세요.",
"failureNotFound": "이 계정은 더 이상 없어요.",
"failureUnconfirmed": "확인하려면 계정의 사용자 이름을 정확히 입력해 주세요.",
"failureUsernameTaken": "이미 사용 중인 사용자 이름이에요.",
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "Dit account heeft al records. Terugzetten vervangt records in plaats van ze aan te vullen, dus kies een account zonder records of maak een nieuw account aan.",
"failureManagedProfile": "Dit account is een beheerd profiel. De verzorgers beheren het, dus er kan geen kopie in.",
"failurePendingDeletion": "Dit account wordt verwijderd. Maak de verwijdering eerst ongedaan of kies een ander account.",
"failureAdminAccount": "Dit is een beheerdersaccount. De records van iemand anders kunnen er niet in, dus kies een gewoon account of maak een nieuw account aan.",
"failureNotFound": "Dit account bestaat niet meer.",
"failureUnconfirmed": "Typ de gebruikersnaam van het account precies over om te bevestigen.",
"failureUsernameTaken": "Deze gebruikersnaam is al in gebruik.",
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "To konto ma już rekordy. Przywracanie zastępuje rekordy zamiast je uzupełniać, więc wybierz konto bez rekordów albo utwórz nowe.",
"failureManagedProfile": "To konto jest profilem zarządzanym. Prowadzą je osoby opiekujące się nim, więc kopia nie może do niego trafić.",
"failurePendingDeletion": "To konto czeka na usunięcie. Najpierw cofnij usunięcie albo wybierz inne konto.",
"failureAdminAccount": "To jest konto administratora. Nie można w nim umieścić rekordów innej osoby, więc wybierz zwykłe konto albo utwórz nowe.",
"failureNotFound": "To konto już nie istnieje.",
"failureUnconfirmed": "Wpisz dokładnie nazwę użytkownika konta, aby potwierdzić.",
"failureUsernameTaken": "Ta nazwa użytkownika jest już zajęta.",
+1
View File
@@ -8397,6 +8397,7 @@
"failureHoldsRecords": "Esta conta já tem registros. Uma restauração substitui os registros em vez de somá-los, então escolha uma conta sem registros ou crie uma nova.",
"failureManagedProfile": "Esta conta é um perfil gerenciado. Os guardiões cuidam dela, então uma cópia não pode ir para ela.",
"failurePendingDeletion": "Esta conta está aguardando exclusão. Desfaça a exclusão primeiro ou escolha outra conta.",
"failureAdminAccount": "Esta é uma conta de administrador. Os registros de outra pessoa não podem ir para ela, então escolha uma conta comum ou crie uma nova.",
"failureNotFound": "Esta conta não existe mais.",
"failureUnconfirmed": "Digite exatamente o nome de usuário da conta para confirmar.",
"failureUsernameTaken": "Este nome de usuário já está em uso.",
+9 -6
View File
@@ -10,8 +10,10 @@
* 3. Parses and validates the JSON against the canonical schema.
* 4. Rejects files written by a future schema version (incompatible).
* 5. Encrypts the file as it arrives and inserts a new `DataBackup` row
* of type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
* the rolling `WEEKLY_AUTO` snapshot AND with each other.
* of type `MANUAL_UPLOAD_<unix-ms>` (`RETARGETED_UPLOAD_<unix-ms>` for
* a copy put into a chosen account, `backup-upload-type.ts`) so
* multiple uploads coexist with the rolling `WEEKLY_AUTO` snapshot AND
* with each other.
*
* The file is read as a stream and never held whole (#1031): an export of an
* account with 1.25 million measurements is a few hundred megabytes of JSON,
@@ -85,6 +87,7 @@ import {
type RestoreTargetVerdict,
} from "@/lib/export/restore-target";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
import { uploadedBackupType } from "@/lib/export/backup-upload-type";
import {
readStreamedBackup,
StreamedBackupInvalidError,
@@ -476,10 +479,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
// `MANUAL_UPLOAD_<unix-ms>` keeps the (userId, type) unique-constraint
// intact so multiple uploads can coexist for the same user without
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
// The millisecond suffix keeps uploads and WEEKLY_AUTO apart under the
// (userId, type) unique constraint; a copy put into a chosen account is
// marked as such, for its restore (`backup-upload-type.ts`).
const uploadType = uploadedBackupType(Boolean(choice), Date.now());
let payload: BackupPayload | undefined;
let measurementCount = 0;
let owner: { id: string; username: string } | null = null;
@@ -80,10 +80,9 @@ function render(
}
describe("pickableAccounts", () => {
it("offers neither managed profiles nor accounts pending deletion", () => {
it("offers neither managed profiles, accounts pending deletion nor admin accounts", () => {
expect(pickableAccounts(USERS).map((u) => u.username)).toEqual([
"returning-person",
"operator",
]);
});
});
@@ -152,6 +151,9 @@ describe("targetUploadFailureKey", () => {
expect(key("backup.target.pending_deletion")).toBe(
"admin.section.backups.target.failurePendingDeletion",
);
expect(key("backup.target.admin_account")).toBe(
"admin.section.backups.target.failureAdminAccount",
);
expect(key("backup.target.not_found")).toBe(
"admin.section.backups.target.failureNotFound",
);
@@ -190,7 +192,7 @@ describe("<BackupTargetForm>", () => {
{ data: ELIGIBLE, loading: false, error: null },
);
expect(html).toContain("returning-person (r@example.test)");
expect(html).toContain(">operator<");
expect(html).not.toContain(">operator<");
expect(html).not.toContain("managed-cafe01");
expect(html).not.toContain(">leaving<");
expect(html).toContain('data-slot="backup-target-eligible"');
@@ -49,11 +49,15 @@ export interface BackupTargetCheck {
/**
* The accounts the picker offers: not a managed profile (its Guardians run
* it) and not pending deletion. Whether one holds records is the server's to
* it), not pending deletion and not an admin account (the server refuses all
* three, `restore-target.ts`). Whether one holds records is the server's to
* say, per account, once it is picked.
*/
export function pickableAccounts(users: readonly AdminUser[]): AdminUser[] {
return users.filter((user) => !user.managedProfile && user.deletion === null);
return users.filter(
(user) =>
!user.managedProfile && user.deletion === null && user.role !== "ADMIN",
);
}
/**
@@ -87,6 +91,8 @@ export function targetFailureKey(code: string): string {
return "admin.section.backups.target.failureManagedProfile";
case "backup.target.pending_deletion":
return "admin.section.backups.target.failurePendingDeletion";
case "backup.target.admin_account":
return "admin.section.backups.target.failureAdminAccount";
case "backup.target.not_found":
return "admin.section.backups.target.failureNotFound";
case "backup.target.unconfirmed":
@@ -0,0 +1,66 @@
/**
* Whose word a restored AI endpoint is taken on. An admin's own copy is the
* operator's configuration, so the operator's wider endpoint policy applies;
* a copy uploaded into an account other than the one that wrote it is the
* word of whoever wrote it, whatever the receiving account's role.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { restoreAccountSettings } from "@/lib/export/account-settings-restore";
const PRIVATE_ENDPOINT = "http://10.20.30.40:11434";
function adminTx() {
const update = vi.fn(async () => ({}));
const tx = {
user: {
findUniqueOrThrow: vi.fn(async () => ({
role: "ADMIN",
aiProvider: null,
unitPreference: "metric",
glucoseUnit: "mg/dL",
})),
update,
},
} as unknown as Prisma.TransactionClient;
return { tx, update };
}
const payload = {
accountSettings: { aiProvider: "LOCAL", aiBaseUrl: PRIVATE_ENDPOINT },
};
afterEach(() => {
vi.unstubAllEnvs();
});
describe("restoring an admin account's settings", () => {
it("takes a private AI endpoint from the admin's own copy under the operator's policy", async () => {
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
const { tx, update } = adminTx();
const result = await restoreAccountSettings(tx, "admin-1", payload, {
retargeted: false,
});
expect(result.refused).toEqual([]);
expect(update).toHaveBeenCalledWith({
where: { id: "admin-1" },
data: { aiProvider: "LOCAL", aiBaseUrl: PRIVATE_ENDPOINT },
});
});
it("refuses it from a copy that was uploaded into the account from someone else's file", async () => {
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
const { tx, update } = adminTx();
const result = await restoreAccountSettings(tx, "admin-1", payload, {
retargeted: true,
});
expect(result.refused).toEqual(["aiBaseUrl"]);
expect(update).toHaveBeenCalledWith({
where: { id: "admin-1" },
data: { aiProvider: "LOCAL" },
});
});
});
+15 -1
View File
@@ -15,6 +15,15 @@ export interface AccountSettingsRestoreInput {
accountSettings?: AccountSettingsBackupEntry | null;
}
/** Where the copy being restored came from. */
export interface AccountSettingsRestoreSource {
/**
* The copy was uploaded into this account from a file another account
* wrote (`backup-upload-type.ts`), so its settings are that author's word.
*/
retargeted: boolean;
}
export interface AccountSettingsRestoreResult {
/** How many setting columns were written. */
applied: number;
@@ -32,11 +41,16 @@ export interface AccountSettingsRestoreResult {
* the row are never in the write, so they stay exactly as the receiving
* account has them. A file without the section leaves the row untouched,
* which is how every backup written before the section existed restores.
*
* An admin's own copy is the operator's configuration and is read under the
* operator's AI endpoint policy. A retargeted copy never is: the admin chose
* where it went, but the file's author wrote its settings.
*/
export async function restoreAccountSettings(
tx: Prisma.TransactionClient,
ownerId: string,
payload: AccountSettingsRestoreInput,
source: AccountSettingsRestoreSource,
): Promise<AccountSettingsRestoreResult> {
const entry = payload.accountSettings;
if (!entry) return { applied: 0, refused: [], unitsChanged: false };
@@ -52,7 +66,7 @@ export async function restoreAccountSettings(
});
const { data, refused } = admitAccountSettings(
entry,
{ operatorTrusted: current.role === "ADMIN" },
{ operatorTrusted: !source.retargeted && current.role === "ADMIN" },
current.aiProvider,
);
+1
View File
@@ -190,6 +190,7 @@ export const RESTORE_STEPS: readonly RestoreStep[] = [
ctx.tx,
ctx.ownerId,
ctx.payload,
{ retargeted: ctx.retargeted },
);
for (const column of result.refused) {
ctx.skips.push({ catalogue: "accountSetting", key: column, links: 1 });
+23
View File
@@ -0,0 +1,23 @@
/**
* The `DataBackup.type` of a copy an admin uploaded.
*
* A file uploaded for the account it names is `MANUAL_UPLOAD_<ms>`. A file
* uploaded into another account (the copy of an erased account, put into the
* account chosen for it) is `RETARGETED_UPLOAD_<ms>`, and the mark stays with
* the stored copy: its settings were written by the file's author, not by the
* account it now belongs to, so its restore never takes them on the
* operator's word, whatever that account's role is by then. The millisecond
* suffix keeps the `(userId, type)` unique constraint free for several
* uploads beside the rolling `WEEKLY_AUTO` copy.
*/
const MANUAL_UPLOAD_PREFIX = "MANUAL_UPLOAD_";
const RETARGETED_UPLOAD_PREFIX = "RETARGETED_UPLOAD_";
export function uploadedBackupType(retargeted: boolean, now: number): string {
return `${retargeted ? RETARGETED_UPLOAD_PREFIX : MANUAL_UPLOAD_PREFIX}${now}`;
}
/** Whether a stored copy was uploaded into an account other than its own. */
export function isRetargetedUploadType(type: string): boolean {
return type.startsWith(RETARGETED_UPLOAD_PREFIX);
}
+10
View File
@@ -31,6 +31,7 @@ import { auditLog } from "@/lib/auth/audit";
import { invalidateUserData } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import type { AccountSettingsRestoreResult } from "@/lib/export/account-settings-restore";
import { isRetargetedUploadType } from "@/lib/export/backup-upload-type";
import {
RESTORE_CLEAR_STEPS,
RESTORE_STEPS,
@@ -259,6 +260,7 @@ async function replaceAccount(
read: ReadBackup,
ownerId: string,
restoreInstanceSettings: boolean,
retargeted: boolean,
transactionTimeoutMs: number,
tracker: RestoreProgressTracker,
prepass: MeasurementRestorePrepass,
@@ -285,6 +287,7 @@ async function replaceAccount(
streamed: read.streamed,
skips,
restoreInstanceSettings,
retargeted,
measurementPrepass: prepass,
links: emptyRestoreLinks(),
outputs: {
@@ -592,6 +595,12 @@ export async function restoreBackup(
const checked = await checkBackupBeforeRestore(input, read);
if (!checked.ok) return checked.outcome;
const { owner, transactionTimeoutMs } = checked;
// Read from the row, not passed in, so no caller can leave it out.
const stored = await prisma.dataBackup.findUnique({
where: { id: backup.id },
select: { type: true },
});
const retargeted = isRetargetedUploadType(stored?.type ?? "");
let result: TransactionResult;
try {
@@ -599,6 +608,7 @@ export async function restoreBackup(
read,
owner.id,
restoreInstanceSettings,
retargeted,
transactionTimeoutMs,
tracker,
prepass,
+5
View File
@@ -179,6 +179,11 @@ export interface RestoreContext {
/** Declared inside the transaction, so a rollback takes the report with it. */
readonly skips: RestoreSkipLog;
readonly restoreInstanceSettings: boolean;
/**
* The copy was uploaded into this account from another account's file
* (`backup-upload-type.ts`); its settings are not the operator's word.
*/
readonly retargeted: boolean;
/** What the file's first read noted about its readings. */
readonly measurementPrepass: MeasurementRestorePrepass;
readonly links: RestoreLinks;
+23 -4
View File
@@ -14,12 +14,16 @@
* A reading or a medication entered during setup is a record like any other
* and still refuses, because the restore would delete it.
*
* The other two refusals keep the account rules intact. A managed profile is
* The other refusals keep the account rules intact. A managed profile is
* its Guardians' record, which an admin does not write into (the users list
* offers no admin deletion on one for the same reason). An account pending
* deletion is locked and will be erased, with whatever was restored into it.
* Neither the restore nor this check touches the role, so the last-admin rule
* is not in play.
* An admin account is the operator's: its settings are taken on the
* operator's word (a wider AI endpoint policy) and its role reaches every
* account, so another person's record does not go into it. Someone who has
* signed up again holds an ordinary account, and a new one can always be
* made. Neither the restore nor this check touches the role, so the
* last-admin rule is not in play.
*/
import type { Prisma } from "@/generated/prisma/client";
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
@@ -112,7 +116,11 @@ export async function modelsHoldingRecords(
}
export type RestoreTargetRefusal =
"not_found" | "managed_profile" | "pending_deletion" | "holds_records";
| "not_found"
| "managed_profile"
| "pending_deletion"
| "admin_account"
| "holds_records";
export type RestoreTargetVerdict =
| { ok: true; target: { id: string; username: string } }
@@ -136,6 +144,7 @@ export async function checkRestoreTarget(
username: true,
managedProfileAt: true,
deletionRequestedAt: true,
role: true,
},
});
if (!row) {
@@ -148,6 +157,9 @@ export async function checkRestoreTarget(
if (row.deletionRequestedAt) {
return { ok: false, reason: "pending_deletion", target, models: [] };
}
if (row.role === "ADMIN") {
return { ok: false, reason: "admin_account", target, models: [] };
}
const models = await modelsHoldingRecords(db, row.id);
if (models.length > 0) {
return { ok: false, reason: "holds_records", target, models };
@@ -160,6 +172,7 @@ export const BACKUP_TARGET_REFUSAL_CODES = {
not_found: "backup.target.not_found",
managed_profile: "backup.target.managed_profile",
pending_deletion: "backup.target.pending_deletion",
admin_account: "backup.target.admin_account",
holds_records: "backup.target.holds_records",
} as const satisfies Record<RestoreTargetRefusal, string>;
@@ -187,6 +200,12 @@ export const RESTORE_TARGET_REFUSALS: Readonly<
message:
"The chosen account is pending deletion. Undo the deletion first, or choose another account.",
},
admin_account: {
status: 409,
code: BACKUP_TARGET_REFUSAL_CODES.admin_account,
message:
"The chosen account is an admin account. A copy of another person's record cannot be restored into it; choose an ordinary account or create a new one.",
},
holds_records: {
status: 409,
code: BACKUP_TARGET_REFUSAL_CODES.holds_records,
+1
View File
@@ -105,6 +105,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"backup.payload.undecryptable",
"backup.restore.active",
"backup.section.missing",
"backup.target.admin_account",
"backup.target.holds_records",
"backup.target.managed_profile",
"backup.target.not_found",
+1 -1
View File
@@ -287,7 +287,7 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"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`).",
"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 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: errorEnvelope } },
},
"413": {
@@ -25,11 +25,13 @@
* An account that has just been through the setup routes counts as
* empty, and the copy's setup state replaces its own; one whose setup
* ended on a reading of its own does not.
* 6. A target that holds records, is a managed profile, is pending deletion
* or is not confirmed is refused before the file is read; so is a target
* for a file whose own account is still here, and a new account made for
* a refused file is removed again. A non-admin session and a Bearer
* token are refused outright.
* 6. A target that holds records, is a managed profile, is pending
* deletion, is an admin or is not confirmed is refused before the file
* is read; so is a target for a file whose own account is still here,
* and a new account made for a refused file is removed again. A
* non-admin session and a Bearer token are refused outright. A copy put
* into a chosen account keeps that mark, and its restore takes no
* setting on the operator's word, even after the account is promoted.
*
* Everything runs through production code: the deletion route, the purge job,
* the admin upload, target-check and restore routes and the restore job
@@ -550,6 +552,54 @@ describe("restoring the backup of an erased account into a chosen account", () =
SLOW,
);
it(
"restores a retargeted copy's settings on the file author's word, even into an account promoted to admin since",
async () => {
const prisma = getPrismaClient();
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
const { json } = await eraseAfterExport(prisma);
const payload = JSON.parse(json) as {
accountSettings: Record<string, unknown> | null;
};
payload.accountSettings = {
...(payload.accountSettings ?? {}),
aiProvider: "LOCAL",
aiBaseUrl: "http://10.20.30.40:11434",
};
const person = await prisma.user.create({
data: { username: "returning-person" },
});
const stored = await uploadInto(JSON.stringify(payload), {
into: "existing",
userId: person.id,
confirm: "returning-person",
});
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
const row = await prisma.dataBackup.findUniqueOrThrow({
where: { id: stored.body.data!.id },
select: { type: true },
});
expect(row.type).toMatch(/^RETARGETED_UPLOAD_\d+$/);
// Promoted between the upload and the restore.
await prisma.user.update({
where: { id: person.id },
data: { role: "ADMIN" },
});
const restored = await restore(stored.body.data!.id);
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
expect(
await prisma.user.findUniqueOrThrow({
where: { id: person.id },
select: { aiProvider: true, aiBaseUrl: true },
}),
).toEqual({ aiProvider: "LOCAL", aiBaseUrl: null });
},
SLOW,
);
it(
"refuses an account whose setup ended on a reading of its own, because the restore would delete it",
async () => {
@@ -694,7 +744,15 @@ describe("restoring the backup of an erased account into a chosen account", () =
},
});
const empty = await prisma.user.create({ data: { username: "empty" } });
const otherAdmin = await prisma.user.create({
data: { username: "second-admin", role: "ADMIN" },
});
expect((await checkTarget(otherAdmin.id)).body.data).toMatchObject({
eligible: false,
reason: "admin_account",
models: [],
});
expect((await checkTarget(used.id)).body.data).toMatchObject({
eligible: false,
reason: "holds_records",
@@ -718,6 +776,11 @@ describe("restoring the backup of an erased account into a chosen account", () =
409,
"backup.target.pending_deletion",
],
[
{ into: "existing", userId: otherAdmin.id, confirm: "second-admin" },
409,
"backup.target.admin_account",
],
[
{ into: "existing", userId: "no-such-account", confirm: "x" },
404,
@@ -753,6 +816,7 @@ describe("restoring the backup of an erased account into a chosen account", () =
"target_holds_records",
"target_managed_profile",
"target_pending_deletion",
"target_admin_account",
"target_not_found",
"target_unconfirmed",
"target_username_taken",