Files
HealthLog/src/app/api/settings/data/route.ts
T
Marc-André Bombeck c56d1ca50b fix(02-11): protect guardian egress data lifecycle
- Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races
2026-08-06 14:05:59 +02:00

141 lines
4.8 KiB
TypeScript

import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { invalidateUserData } from "@/lib/cache/invalidate";
import {
LastManagedGuardianError,
protectManagedProfilesDuringDataWipe,
} from "@/lib/managed-profiles/lifecycle";
import {
resolveWipeDelegate,
USER_RESET,
wipeDelegateKey,
WIPE_MODELS,
} from "@/lib/data-wipe/wipe-plan";
export const dynamic = "force-dynamic";
/**
* Budget for the wipe transaction.
*
* Prisma's default interactive-transaction timeout is 5 s, which was already
* optimistic for thirteen `deleteMany` statements and is not survivable for
* the full user-scoped set on an account with a large import behind it. The
* whole action is one transaction on purpose — a half-applied wipe is worse
* than none, because the person is told it succeeded either way.
*/
const WIPE_TRANSACTION_TIMEOUT_MS = 120_000;
const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
/**
* Delete every row this account owns, keeping the account and the credentials
* that sign into it.
*
* The set of tables is not written here. It is declared once in
* `@/lib/data-wipe/wipe-plan` and held against `prisma/schema.prisma` by
* `src/__tests__/data-wipe-completeness.test.ts`, so a model added to the
* schema cannot quietly land outside the wipe — which is exactly how this
* route came to delete thirteen tables out of eighty-odd while telling people
* it had deleted all of them.
*
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
* deletion route); single-factor accounts keep the typed-confirmation-only
* contract.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
let confirm = "";
try {
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
}
if (confirm !== "DELETE") {
return apiError("Confirmation missing", 422);
}
const userId = user.id;
let counts: Record<string, number>;
try {
counts = await prisma.$transaction(
async (tx) => {
// This precedes every data delete and holds the managed-record locks
// through AccountGrant removal. A queued Guardian egress claim thus
// rechecks after this wipe commits instead of sending from a grant
// this transaction has already deleted.
await protectManagedProfilesDuringDataWipe(tx, userId);
const perModel: Record<string, number> = {};
for (const model of WIPE_MODELS) {
const { count } = await resolveWipeDelegate(tx, model).deleteMany({
where: { userId },
});
if (count > 0) perModel[wipeDelegateKey(model)] = count;
}
// The account row survives; the personal data carried on its own columns
// does not. Same classification contract as the model list.
await tx.user.update({ where: { id: userId }, data: USER_RESET });
return perModel;
},
{
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
},
);
} catch (error) {
if (error instanceof LastManagedGuardianError) {
return apiError(
"Add another Guardian or delete the managed profile first",
409,
{ errorCode: "managed_profile.guardian.required" },
);
}
throw error;
}
const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
// Written after the transaction commits, so the row cannot be removed by the
// AuditLog delete inside it. The person's whole audit history goes; this one
// row stays as the receipt for the erasure they asked for. Account deletion
// takes the other branch and purges even this, because there is no longer an
// account for it to belong to.
await auditLog("user.data.clear", {
userId,
ipAddress: getClientIp(request),
details: { deletedRows, models: counts },
});
annotate({
action: { name: "settings.data.clear" },
meta: {
deleted_rows: deletedRows,
models_wiped: WIPE_MODELS.length,
models_with_rows: Object.keys(counts).length,
},
});
// v1.16.9 — every cached payload was built on the rows just deleted;
// hard-evict the user's buckets so no surface serves pre-wipe data.
invalidateUserData(userId);
return apiSuccess({ cleared: true, deletedRows, models: counts });
});