feat(backup): say whether an account can take an erased account's copy

A restore replaces the account's records and has no merge, so the copy
of an account that no longer exists may only go into an account that
holds none of the records a backup carries. The check reads every
backed-up model by its userId, reaches the owner-less children through
their parent, and is held to the schema so a new model cannot be read
as empty. Managed profiles and accounts pending deletion are refused as
well. The admin console asks GET /api/admin/backups/targets/{userId}
before it sends a file again, so a refusal costs no upload.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:11:08 +01:00
1 parent 973bb8e5fc
commit 9baeb95bab
4 files changed
+266

No files matched your search

@@ -225,6 +225,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
// newer schema) an operator has to be able to look up the same way. See
// `src/lib/openapi/routes/admin-backups.ts`.
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backups/targets/{userId}": {
kind: "adminConsole",
methods: ["GET"],
},
"/api/admin/central-codex": {
kind: "adminConsole",
methods: ["DELETE", "GET"],
@@ -0,0 +1,59 @@
/**
* GET /api/admin/backups/targets/{userId} — whether the copy of an erased
* account may be restored into this account.
*
* The backups console asks before it sends a file a second time with that
* account as its target, so a refusal costs no upload. The upload decides
* again on its own (`checkRestoreTarget` in `restore-target.ts`); this answer
* is advice, not a reservation. It says no for a managed profile, an account
* pending deletion, and an account that already holds records, naming the
* models that hold them. Counts and record content are not read.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { checkRestoreTarget } from "@/lib/export/restore-target";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
export const dynamic = "force-dynamic";
const paramsSchema = z.object({ userId: z.string().min(1).max(64) });
export const GET = apiHandler(
async (
_request: NextRequest,
{ params }: { params: Promise<{ userId: string }> },
) => {
const { user: admin } = await requireAdmin();
annotate({ action: { name: "admin.backups.target.check" } });
const rl = await checkRateLimit(
`admin-backups-target:${admin.id}`,
30,
60 * 1000,
);
if (!rl.allowed) {
throw new HttpError(429, "Too many account checks");
}
const parsed = paramsSchema.safeParse(await params);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const verdict = await checkRestoreTarget(prisma, parsed.data.userId);
if (!verdict.ok && verdict.reason === "not_found") {
throw new HttpError(404, "User not found");
}
const target = verdict.target!;
return apiSuccess({
id: target.id,
username: target.username,
eligible: verdict.ok,
reason: verdict.ok ? null : verdict.reason,
models: verdict.ok ? [] : verdict.models,
});
},
);
@@ -0,0 +1,45 @@
/**
* The emptiness check behind restoring an erased account's copy into another
* account reads every backed-up model by its `userId`, except the children
* that have none, which it reaches through their parent. Both halves are held
* to `prisma/schema.prisma` here, so a backed-up model added later is either
* checked or fails this file; it cannot be skipped and read as empty.
*
* Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first
* case names it (no `userId`); add an owned model to it and the second does.
*/
import { describe, expect, it } from "vitest";
import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape";
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
import {
BACKED_UP_CHILD_MODELS,
RESTORE_TARGET_OWNED_MODELS,
} from "@/lib/export/restore-target";
const models = parseWipeSchema();
describe("restore target models", () => {
it("reads every owned backed-up model by its userId", () => {
const missing = RESTORE_TARGET_OWNED_MODELS.filter(
(model) => !models.get(model)?.scalars.includes("userId"),
);
expect(missing).toEqual([]);
expect(RESTORE_TARGET_OWNED_MODELS.length).toBeGreaterThan(60);
});
it("reaches each child through a required cascade to an owned parent", () => {
for (const [child, parent] of Object.entries(BACKED_UP_CHILD_MODELS)) {
expect(BACKED_UP_MODELS, child).toContain(child);
const shape = models.get(child);
expect(shape?.scalars, child).not.toContain("userId");
expect(
shape?.relations.some(
(r) => r.target === parent && r.cascade && !r.optional,
),
`${child} -> ${parent}`,
).toBe(true);
expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent);
}
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* Which account the copy of an erased account may be restored into.
*
* A restore replaces: every row of the account in a section the copy carries
* is deleted and the copy's rows are written in their place
* (`docs/ops/backup-restore.md`). There is no merge. So putting the record of
* an account that no longer exists into another account is only safe when
* that account holds none of those rows, and the check is exactly that: a row
* of any model the backup carries ({@link BACKED_UP_MODELS}) under the
* account refuses it. Everything a restore leaves alone (credentials,
* sessions, devices, grants) does not count.
*
* The other two 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.
*/
import type { Prisma } from "@/generated/prisma/client";
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
/**
* The backed-up models with no owner column of their own, each with the owned
* model it hangs off through a required cascade. A row of one cannot exist
* without a row of its parent, so checking the parent checks it.
* `restore-target-models.test.ts` holds this list to the schema.
*/
export const BACKED_UP_CHILD_MODELS: Readonly<Record<string, string>> = {
MedicationSchedule: "Medication",
MedicationScheduleRevision: "Medication",
MedicationDoseChange: "Medication",
MedicationEfficacyTarget: "Medication",
MedicationInventoryEvent: "Medication",
MedicationCategoryAssignment: "Medication",
ReminderPhaseConfig: "Medication",
MoodEntryTagLink: "MoodEntry",
IllnessSymptomLink: "IllnessDayLog",
CycleSymptomLink: "CycleDayLog",
WorkoutRoute: "Workout",
WorkoutSamples: "Workout",
CoachMessage: "CoachConversation",
CoachConversationDocument: "CoachConversation",
};
/** Every backed-up model that carries `userId`, in the plan's order. */
export const RESTORE_TARGET_OWNED_MODELS: readonly string[] =
BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS));
interface OwnedDelegate {
findFirst(args: {
where: { userId: string };
select: { userId: true };
}): Promise<unknown>;
}
function ownedDelegate(db: object, model: string): OwnedDelegate {
const key = model.charAt(0).toLowerCase() + model.slice(1);
const delegate = (db as Record<string, OwnedDelegate | undefined>)[key];
if (!delegate || typeof delegate.findFirst !== "function") {
// Never narrower than the plan: a model this cannot read is a model it
// would otherwise call empty.
throw new Error(`No Prisma delegate for backed-up model "${model}"`);
}
return delegate;
}
/** The backed-up models under which `userId` holds at least one row. */
export async function modelsHoldingRecords(
db: Prisma.TransactionClient,
userId: string,
): Promise<string[]> {
const held: string[] = [];
for (const model of RESTORE_TARGET_OWNED_MODELS) {
const row = await ownedDelegate(db, model).findFirst({
where: { userId },
select: { userId: true },
});
if (row) held.push(model);
}
return held;
}
export type RestoreTargetRefusal =
"not_found" | "managed_profile" | "pending_deletion" | "holds_records";
export type RestoreTargetVerdict =
| { ok: true; target: { id: string; username: string } }
| {
ok: false;
reason: RestoreTargetRefusal;
target: { id: string; username: string } | null;
/** For `holds_records`: the models that hold rows, in plan order. */
models: string[];
};
/** Whether the copy of an erased account may go into `userId`. */
export async function checkRestoreTarget(
db: Prisma.TransactionClient,
userId: string,
): Promise<RestoreTargetVerdict> {
const row = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
managedProfileAt: true,
deletionRequestedAt: true,
},
});
if (!row) {
return { ok: false, reason: "not_found", target: null, models: [] };
}
const target = { id: row.id, username: row.username };
if (row.managedProfileAt) {
return { ok: false, reason: "managed_profile", target, models: [] };
}
if (row.deletionRequestedAt) {
return { ok: false, reason: "pending_deletion", target, models: [] };
}
const models = await modelsHoldingRecords(db, row.id);
if (models.length > 0) {
return { ok: false, reason: "holds_records", target, models };
}
return { ok: true, target };
}
/** The status and stable code each refusal answers with. */
export const RESTORE_TARGET_REFUSALS: Readonly<
Record<
RestoreTargetRefusal,
{ status: number; code: string; message: string }
>
> = {
not_found: {
status: 404,
code: "backup.target.not_found",
message: "The chosen account does not exist.",
},
managed_profile: {
status: 409,
code: "backup.target.managed_profile",
message:
"The chosen account is a managed profile. Its Guardians run it, so a copy cannot be restored into it.",
},
pending_deletion: {
status: 409,
code: "backup.target.pending_deletion",
message:
"The chosen account is pending deletion. Undo the deletion first, or choose another account.",
},
holds_records: {
status: 409,
code: "backup.target.holds_records",
message:
"The chosen account already holds records, and a restore replaces them rather than adding to them. Choose an empty account or create a new one.",
},
};