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

@@ -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":