fix: recover managed sharing boundaries

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 00:19:35 +02:00
1 parent 01562b4aef
commit d86358f3e4
96 files changed
+3578 -397

No files matched your search

+83 -7
View File
@@ -12,9 +12,13 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
@@ -49,12 +53,26 @@ export const GET = apiHandler(
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting an allergy entry, with the replaced fields
// on the audit row.
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
// The pre-image the audit row needs (C4). Encrypted columns stay out of
// the select: the feed names them and never quotes them.
const existing = await prisma.allergy.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
select: {
id: true,
userId: true,
deletedAt: true,
substance: true,
category: true,
type: true,
severity: true,
status: true,
onsetAt: true,
},
});
if (
!existing ||
@@ -101,7 +119,34 @@ export const PATCH = apiHandler(
await auditLog("allergy.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { allergyId: id },
// C4 — the replaced fields. The reaction and the note are named and
// never quoted; both are encrypted at rest and the audit table is not a
// second store for them.
details: {
allergyId: id,
...overwriteDetails({
before: {
substance: existing.substance,
category: existing.category,
type: existing.type,
severity: existing.severity,
status: existing.status,
onsetAt: existing.onsetAt,
},
after: {
substance: updated.substance,
category: updated.category,
type: updated.type,
severity: updated.severity,
status: updated.status,
onsetAt: updated.onsetAt,
},
redacted: [
...(entry.reaction !== undefined ? ["reaction"] : []),
...(entry.note !== undefined ? ["note"] : []),
],
}),
},
});
annotate({
@@ -114,12 +159,27 @@ export const PATCH = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and a hard delete: the row goes and the audit row is
// what is left. C3 below is the condition this admission rests on.
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
// Widened for the audit row (C3): what was destroyed, in plaintext columns
// only. `reactionEncrypted` and `notesEncrypted` are deliberately absent —
// lifting a decrypted reaction into the audit table would give it a second
// retention story and no rotation.
const existing = await prisma.allergy.findUnique({
where: { id },
select: { id: true, userId: true },
select: {
id: true,
userId: true,
substance: true,
category: true,
type: true,
severity: true,
status: true,
onsetAt: true,
},
});
if (!existing || existing.userId !== user.id) {
return apiError("Allergy not found", 404);
@@ -136,7 +196,23 @@ export const DELETE = apiHandler(
await auditLog("allergy.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { allergyId: id },
// C3 — the allergen, its severity and its recorded date, and never the
// encrypted reaction text.
details: {
allergyId: id,
...destroyedDetails({
model: "Allergy",
id,
label: existing.substance,
effectiveAt: existing.onsetAt,
extra: {
category: existing.category,
type: existing.type,
severity: existing.severity,
status: existing.status,
},
}),
},
});
annotate({
+6 -3
View File
@@ -11,7 +11,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -91,13 +91,16 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// and family history have exactly one home today, that home is a personal
// account surface a switch rightly closes, and bolting a second copy onto a
// shared page would split one concept across two places. Re-admitting is
// one line here plus one entry in `delegable-surface-guard.test.ts` plus the
// one line here plus one entry in `sharing-surface-guard.test.ts` plus the
// paragraph that argues it — which is exactly the reviewed diff that guard
// exists to force.
//
// The half that was always the point is untouched: a caregiver can still
// READ the allergy list inside the record.
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Recording an allergy for somebody whose record you
// manage is the case the level exists for; on a managed profile it is the
// guardian's ordinary act. Additive, audited, and the row is the record's.
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,