diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b2586c84e..5e38a7de8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14190,12 +14190,19 @@ paths: tags: - Records summary: Plan a booster reminder from a dose - description: "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: + description: >- + Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as - `vaccination.booster.planned`." + `vaccination.booster.planned`. + + + The operation is declared under the health background and the row it mints belongs to the measurements section, + so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 + `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does + not. parameters: - in: path name: id @@ -14222,7 +14229,25 @@ paths: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 - "403": *a8 + "403": + description: >- + Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there + (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "404": *a25 "422": *a4 "429": *a5 diff --git a/src/app/api/vaccinations/[id]/booster/route.ts b/src/app/api/vaccinations/[id]/booster/route.ts index da406db47..19a87d846 100644 --- a/src/app/api/vaccinations/[id]/booster/route.ts +++ b/src/app/api/vaccinations/[id]/booster/route.ts @@ -13,11 +13,21 @@ * even when a delegate is transcribing. The antigen is read from the dose's * catalogue entry server-side, never from the body — a client cannot key a * reminder onto an antigen the dose does not contain. + * + * The row it mints is a `MeasurementReminder`, and that model belongs to the + * `measurements` section: every direct route over it declares that domain, and + * creating one needs MANAGE there. So the act crosses a seam, and a grant + * scoped to the health background alone was never consent for the other side + * of it — it would produce a row on the owner's checkup list that the delegate + * can neither read nor change through the section that owns it. The seam is + * fenced the way the visits service fences a checkup closure: the mint runs + * only while the caller is inside `measurements`. */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -36,9 +46,24 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("write", "profile"); + const { user, grantId } = await requireRecordAuth("write", "profile"); const { id } = await params; + // Refused rather than skipped, and that is the difference from the visit's + // checkup closure: there the closure is one effect of a larger save and + // saying "not done" keeps the save honest, while here the reminder IS the + // request. Reporting success over a row that was never written would tell + // the person their booster is planned when nothing rings. + const visible = await actingDomainVisibility(prisma, grantId); + if (!visible("measurements")) { + annotate({ meta: { sharing_refusal: "booster_out_of_scope" } }); + return apiError( + "Planning a booster needs access to this record's measurements", + 403, + { errorCode: "vaccination.booster-out-of-scope" }, + ); + } + const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 8 * 1024, }); diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 98ac9376d..b13cff14e 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -37,6 +37,10 @@ export function VaccinationsView() { // next door. const canAddDose = canWriteDomain("profile"); const canManageProfile = canManageDomain("profile"); + // Planning the booster mints a `MeasurementReminder`, which lives in the + // measurements section rather than this one. A grant that opened only the + // health background is refused there, so the offer is not made. + const canPlanBooster = canWriteDomain("measurements"); const { data, isLoading, isError, refetch } = useVaccinations(); const records = data?.vaccinations ?? []; @@ -91,7 +95,8 @@ export function VaccinationsView() { onCreated={(created) => { // Offer the booster only when the catalogue entry carries an // interval; a free-text or one-off dose never prompts. - if (boosterOfferFor(created)) setBoosterFor(created); + if (canPlanBooster && boosterOfferFor(created)) + setBoosterFor(created); }} /> diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 193a7904a..2943df691 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -362,14 +362,17 @@ export const vaccinationPaths: NonNullable = { tags: ["Records"], summary: "Plan a booster reminder from a dose", description: - "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.", + "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.\n\n" + + "The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not.", requestParams: idPath, requestBody: { required: true, content: { "application/json": { schema: vaccinationBoosterRequest } }, }, responses: { - ...recordRefusal(), + ...recordRefusal( + "Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.", + ), "200": { description: "Existing booster reminder re-anchored.", content: { diff --git a/tests/integration/vaccination-booster-scoped-delegate.test.ts b/tests/integration/vaccination-booster-scoped-delegate.test.ts new file mode 100644 index 000000000..3427956f0 --- /dev/null +++ b/tests/integration/vaccination-booster-scoped-delegate.test.ts @@ -0,0 +1,166 @@ +/** + * `POST /api/vaccinations/{id}/booster` crosses a section boundary, and the + * grant has to be on both sides of it. + * + * The operation is declared `("write", "profile")`, which is right for the act + * — filing the Impfpass and arming the booster it suggests are one thing to + * the person doing them. The ROW it mints is not: a `MeasurementReminder` + * belongs to the measurements section, every direct route over that model says + * so, and creating one there needs MANAGE. So a grant opening the health + * background alone would otherwise produce a reminder on the owner's checkup + * list that the delegate can neither read nor change through the section that + * owns it. + * + * Refused rather than silently skipped: the reminder is the whole request, and + * a 201 over a row that was never written would tell the person their booster + * is planned while nothing rings. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "booster-scope-owner"; +const DELEGATE_ID = "booster-scope-delegate"; + +/** A catalogue antigen that carries a booster interval. */ +const ANTIGEN = "tetanus"; + +let doseId: string; + +async function seedRecords() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `booster-scope-${name}`, + email: `booster-scope-${name}@example.test`, + timezone: "UTC", + }, + }); + } + const dose = await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: new Date("2026-03-01T00:00:00.000Z"), + antigenSlug: ANTIGEN, + }, + }); + doseId = dose.id; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function switchInto( + access: "WRITE" | "MANAGE", + scope: ShareDomain[] | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access, + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +async function planBooster(): Promise { + const { POST } = await import("@/app/api/vaccinations/[id]/booster/route"); + return POST( + new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }), + }), + { params: Promise.resolve({ id: doseId }) }, + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecords(); +}); + +describe("planning a booster asks for the section the reminder lives in", () => { + it("mints for the owner", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(1); + }); + + it("mints for a delegate holding both sections", async () => { + await switchInto("WRITE", ["profile", "measurements"]); + expect((await planBooster()).status).toBe(201); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(1); + }); + + it("refuses a profile-only delegate and writes no reminder", async () => { + await switchInto("WRITE", ["profile"]); + const response = await planBooster(); + expect(response.status).toBe(403); + const body = (await response.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("vaccination.booster-out-of-scope"); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + "no reminder was left on the owner's checkup list", + ).toBe(0); + }); + + it("mints for a whole-record delegate", async () => { + await switchInto("MANAGE", null); + expect((await planBooster()).status).toBe(201); + }); +});