mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(export): every whole-record export asks for a fresh proof
GET /api/export with type=all (the default), POST /api/export/health-record and GET /api/fhir/Patient/$everything hand over the whole record but took a live session alone, so a stolen browser session could still walk off with everything the full-backup gate was meant to protect. A browser session now needs a sign-in or re-proof within five minutes, with the second factor on an account that has one, and the doctor-report panel opens the re-proof dialog and retries. When a manager builds the report for a record they manage, their own session is the one asked. On a token the shipped app keeps working: health-record and $everything take the token as before, including a fhir:read token, and the recent-proof guard names both. GET /api/export?type=all, which no shipped client calls with a token, wants a step-up elevation there. Single-type exports are unchanged. The API reference now also describes the enrollment rule for accounts with a second factor and the passkey refusal it adds.
This commit is contained in:
1 parent
0692017f34
commit
a4d27c618e
13 files changed
+304
-76
No files matched your search
@@ -318,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export — the whole record asks for a fresh proof", () => {
|
||||
const stale = {
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
};
|
||||
|
||||
it("type=all on a session that signed in an hour ago is asked to confirm", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(mkReq("http://localhost/api/export?type=all"));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no type means the whole record, and is gated the same way", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(mkReq("http://localhost/api/export"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("type=all right after signing in goes through", async () => {
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(mkReq("http://localhost/api/export?type=all"));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("a single-type export stays on the session alone", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never);
|
||||
const { GET } = await import("../route");
|
||||
const res = await GET(
|
||||
mkReq("http://localhost/api/export?type=measurements"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
toJson: (value: unknown) => value,
|
||||
}));
|
||||
@@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => {
|
||||
expect(text).toContain("A123456789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/export/health-record — a fresh proof on the browser", () => {
|
||||
it("a session that signed in an hour ago is asked to confirm first", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(mkReq({ format: "pdf", selection: allLeaves() }));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,11 @@
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
|
||||
// reaches, because it renders a prepared artefact from a declared selection
|
||||
// rather than handing over the record whole.
|
||||
const { user, actor } = await requireRecordAuth("manage", "record");
|
||||
const auth = await requireRecordAuth("manage", "record");
|
||||
const { user, actor } = auth;
|
||||
// A whole-record artefact: a browser session must have signed in or
|
||||
// re-proved within five minutes, the gate every whole-record export takes.
|
||||
// The proof is the person at the keyboard, so it is the actor's session
|
||||
// that is asked, whether they export their own record or one they manage.
|
||||
// A token is its own proof here, as before: the shipped app calls this
|
||||
// route with its token and no elevation.
|
||||
if (auth.authMethod === "cookie") {
|
||||
await assertRecentCookieProof(actor, auth.session.id);
|
||||
}
|
||||
annotate({ action: { name: "export.health-record.build" } });
|
||||
|
||||
// The whole doctor-report surface is the `doctorReport` module. Refuse with
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
* Export user data.
|
||||
* Query params:
|
||||
* format: "csv" | "json" (default: json)
|
||||
* type: "measurements" | "medications" | "intake" | "all" (default: all)
|
||||
* type: "measurements" | "medications" | "intake" | "mood" | "all"
|
||||
* (default: all)
|
||||
*
|
||||
* Re-authentication is required via session (user must be logged in).
|
||||
* `type=all` (the default) is the whole record in one file, so it takes the
|
||||
* same fresh-proof gate as every other whole-record export
|
||||
* (`requireRecentProof`): a browser session must have signed in or re-proved
|
||||
* within five minutes, a token must present a step-up elevation. No shipped
|
||||
* client calls this route with a token. The single-type exports stay on the
|
||||
* session alone, like their dedicated routes under `/api/export/*`.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const wholeRecord =
|
||||
(new URL(request.url).searchParams.get("type") ?? "all") === "all";
|
||||
const auth = wholeRecord
|
||||
? await requireRecentProof({ bearer: "elevation" })
|
||||
: { ...(await requireAuth()), commitElevation: async () => {} };
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
@@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Spend a Bearer elevation only once the request is about to act.
|
||||
await auth.commitElevation();
|
||||
|
||||
const userId = user.id;
|
||||
await auditLog("export.download", {
|
||||
userId,
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
* resolves inside the response and the operation can never quietly carry less
|
||||
* than the document does.
|
||||
*
|
||||
* Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across
|
||||
* Read-only; `userId` narrowed from `requireAuth`. A browser session needs a
|
||||
* recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across
|
||||
* the flattened resource list via `_count` (clamped ≤200) / `_offset`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -32,7 +37,16 @@ import {
|
||||
} from "@/lib/fhir/rest";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
const auth = await requireAuth(FHIR_READ_SCOPE);
|
||||
const { user } = auth;
|
||||
// The whole record in one response: a browser session must have signed in
|
||||
// or re-proved within five minutes, like every other whole-record export.
|
||||
// A token is its own proof here, as before: the shipped app and the FHIR
|
||||
// read tokens call this route without an elevation, and a refusal would
|
||||
// read to the app as a dead session.
|
||||
if (auth.authMethod === "cookie") {
|
||||
await assertRecentCookieProof(user, auth.session.id);
|
||||
}
|
||||
annotate({ action: { name: "fhir.everything.read" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
|
||||
@@ -15,6 +15,21 @@ import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { GET as metadataGet } from "../metadata/route";
|
||||
import { GET as everythingGet } from "../Patient/$everything/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { GET as observationRead } from "../Observation/[id]/route";
|
||||
import { GET as patientRead } from "../Patient/[id]/route";
|
||||
import { GET as coverageRead } from "../Coverage/[id]/route";
|
||||
@@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => {
|
||||
});
|
||||
|
||||
describe("GET /api/fhir/Patient/$everything", () => {
|
||||
it("asks a browser session that signed in an hour ago to confirm first", async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
const res = await everythingGet(req("/api/fhir/Patient/$everything"));
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
|
||||
});
|
||||
|
||||
it("returns every resource family the document bundle carries", async () => {
|
||||
const res = await everythingGet(
|
||||
req("/api/fhir/Patient/$everything?_count=200"),
|
||||
|
||||
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
|
||||
Reference in new issue
Block a user