mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Exporting the whole record, making a clinician share link, minting a token, and the admin backup download, upload and restore, data wipe and password reset ran on a live session alone. The plaintext full backup did, while the encrypted one beside it asked for a second factor. Whoever held a stolen session could take the record, or leave behind a credential that outlives the session. These now go through one gate, requireRecentProof (assertRecentCookieProof for the routes that resolve their caller themselves). A browser session passes after a sign-in or re-proof within five minutes: a second factor on an account that has one, otherwise the password or a passkey. The refusal is 401 auth.reproof.required with meta.methods, and the web opens the re-proof dialog, posts the proof to the new POST /api/auth/reproof and retries. A password proof stamps the new Session.reproofAt (migration 0359), never mfaVerifiedAt, so it cannot stand in for a second factor anywhere. On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted export asks for a second-factor elevation on an account that has one. Share links and connector tokens keep taking the token alone, because the shipped app calls them without an elevation and reads a 401 there as a dead session; the guard test names them. Key rotation keeps its stricter second-factor gate.
151 lines
5.5 KiB
TypeScript
151 lines
5.5 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import {
|
|
apiHandler,
|
|
requireAdmin,
|
|
assertRecentCookieProof,
|
|
} from "@/lib/api-handler";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { invalidateAllCaches } from "@/lib/cache/invalidate";
|
|
import {
|
|
ADMIN_WIPE_MODELS,
|
|
resolveGlobalWipeDelegate,
|
|
USER_RESET,
|
|
wipeDelegateKey,
|
|
WIPE_TRANSACTION_MAX_WAIT_MS,
|
|
WIPE_TRANSACTION_TIMEOUT_MS,
|
|
} from "@/lib/data-wipe/wipe-plan";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* Admin-only global wipe of every account's data.
|
|
* Keeps users/passkeys so access to the app remains possible.
|
|
*
|
|
* The set of tables is not written here. It is declared once in
|
|
* `@/lib/data-wipe/wipe-plan` — the same declaration the per-account wipe
|
|
* reads — and held against `prisma/schema.prisma` by
|
|
* `src/__tests__/data-wipe-completeness.test.ts` plus its admin-scope sibling,
|
|
* so a model added to the schema cannot quietly land outside this wipe. It had
|
|
* done exactly that: the inline list this route used to carry named nine
|
|
* tables, written when nine was most of the schema, and stayed at nine while
|
|
* the schema grew past a hundred. Laboratory results, documents, cycle logs,
|
|
* journals, mood, workouts, sleep and Coach conversations all survived the
|
|
* wipe an operator runs before handing the box on.
|
|
*
|
|
* `ADMIN_WIPE_MODELS` is `WIPE_MODELS` minus the entries in
|
|
* `ADMIN_WIPE_EXEMPT`, and every survivor — sign-in credentials, instance
|
|
* configuration, the delegation grants that keep managed profiles reachable —
|
|
* carries its reason in that file.
|
|
*/
|
|
export const DELETE = apiHandler(async (request: NextRequest) => {
|
|
const ip = getClientIp(request);
|
|
const rl = await checkRateLimit(`admin-data-delete:${ip}`, 5, 60 * 1000);
|
|
if (!rl.allowed) {
|
|
return NextResponse.json(
|
|
{ data: null, error: "Rate limit exceeded" },
|
|
{ status: 429, headers: rateLimitHeaders(rl) },
|
|
);
|
|
}
|
|
|
|
const { user, session } = await requireAdmin();
|
|
// A fresh proof on top of the admin session: this reaches every account's
|
|
// data, so a stolen admin session alone must not be enough.
|
|
await assertRecentCookieProof(user, session.id);
|
|
annotate({ action: { name: "admin.data.delete" } });
|
|
|
|
// v1.18.1 — the documented convention buckets authenticated admin
|
|
// mutations on `userId`, not the (pre-auth, spoofable) client IP. The
|
|
// IP bucket above is the anonymous first-line throttle; this is the
|
|
// canonical per-admin bucket the rate-limit contract specifies.
|
|
const userRl = await checkRateLimit(
|
|
`admin-data-delete:${user.id}`,
|
|
5,
|
|
60 * 1000,
|
|
);
|
|
if (!userRl.allowed) {
|
|
return NextResponse.json(
|
|
{ data: null, error: "Rate limit exceeded" },
|
|
{ status: 429, headers: rateLimitHeaders(userRl) },
|
|
);
|
|
}
|
|
|
|
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 ALL") {
|
|
return apiError("Confirmation missing", 422);
|
|
}
|
|
|
|
// Audit-log the *intent* before the transaction begins so the trail
|
|
// describing "an admin is about to wipe data" survives a crash midway or a
|
|
// rollback. On success the transaction takes this row with the rest of the
|
|
// history — an audit trail is a record of who did what from where, which is
|
|
// exactly the personal data an operator clearing a box means to remove — and
|
|
// the receipt written after the commit replaces it.
|
|
await auditLog("admin.data.clear.start", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { confirm: "DELETE ALL" },
|
|
});
|
|
|
|
const counts = await prisma.$transaction(
|
|
async (tx) => {
|
|
const perModel: Record<string, number> = {};
|
|
for (const model of ADMIN_WIPE_MODELS) {
|
|
const { count } = await resolveGlobalWipeDelegate(tx, model).deleteMany(
|
|
{},
|
|
);
|
|
if (count > 0) perModel[wipeDelegateKey(model)] = count;
|
|
}
|
|
|
|
// Every account row survives; the personal data carried on its own
|
|
// columns does not. Same classification contract as the model list.
|
|
await tx.user.updateMany({ data: USER_RESET });
|
|
|
|
return perModel;
|
|
},
|
|
{
|
|
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
|
|
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
|
|
},
|
|
);
|
|
|
|
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 instance's whole audit history goes; this
|
|
// one row stays as the receipt for the erasure the operator asked for.
|
|
await auditLog("admin.data.clear", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { deletedRows, models: counts },
|
|
});
|
|
|
|
annotate({
|
|
meta: {
|
|
deleted_rows: deletedRows,
|
|
models_wiped: ADMIN_WIPE_MODELS.length,
|
|
models_with_rows: Object.keys(counts).length,
|
|
},
|
|
});
|
|
|
|
// v1.16.9 — the wipe touched every user's rows; clear every cache
|
|
// bucket so no per-user payload survives the reset.
|
|
invalidateAllCaches();
|
|
|
|
return apiSuccess({ cleared: true, deletedRows, models: counts });
|
|
});
|