From 1ba73a4d51230d6de39b27fb2e69d9ba036f9390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:29:18 +0200 Subject: [PATCH] fix: enforce notification delivery authority - Require active Guardian authority before cross-record delivery\n- Serialize managed delivery checks with lifecycle changes --- .../notification-delivery-identity.test.ts | 18 +++++++ src/lib/notifications/delivery-identity.ts | 47 +++++++++++++++---- .../notification-attribution.test.ts | 2 +- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts index 572c0c28a..7cd591d7d 100644 --- a/src/__tests__/notification-delivery-identity.test.ts +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const transactionMock = vi.fn(); + vi.mock("@/lib/db", () => ({ prisma: { notificationChannel: { @@ -9,6 +11,7 @@ vi.mock("@/lib/db", () => ({ }, user: { findUnique: vi.fn() }, auditLog: { create: vi.fn() }, + $transaction: (...args: unknown[]) => transactionMock(...args), }, })); @@ -76,6 +79,20 @@ beforeEach(() => { vi.mocked(prisma.notificationChannel.update).mockResolvedValue({ consecutiveFailures: 0, } as never); + transactionMock.mockImplementation( + async (operation: (tx: unknown) => Promise) => + operation({ + $queryRaw: vi.fn(), + user: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + where.id === recordUserId + ? { managedProfileAt: new Date() } + : { managedProfileAt: null }, + ), + }, + accountGrant: { findFirst: vi.fn(async () => ({ id: "grant" })) }, + }), + ); sendViaWebPushMock.mockResolvedValue({ ok: true }); }); @@ -110,6 +127,7 @@ describe("notification delivery identity", () => { message: "Record schedule", }), ); + expect(transactionMock).toHaveBeenCalledTimes(1); }); it("refuses a managed record without an explicit recipient", async () => { diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index 5d4174ec8..3283dddb1 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + activeGuardianWhere, + withManagedProfileLock, +} from "@/lib/managed-profiles/lifecycle"; import type { NotificationPayload } from "@/lib/notifications/types"; export interface NotificationDeliveryIdentity { @@ -10,7 +14,13 @@ export interface NotificationDeliveryIdentity { /** * Resolve notification identities without treating a managed record as its * own destination. Legacy payloads remain valid only for ordinary self - * delivery; a managed record must name its recipient explicitly. + * delivery; a managed record must name an active Guardian explicitly. + * + * Cross-principal authorization linearizes at the locked grant read below, + * immediately before the dispatcher selects a channel. A revocation that + * commits before that read prevents delivery; one that commits afterwards + * affects later dispatches but cannot recall provider work already in flight. + * The provider call deliberately runs outside the short database transaction. */ export async function resolveNotificationDeliveryIdentity( payload: NotificationPayload, @@ -32,14 +42,35 @@ export async function resolveNotificationDeliveryIdentity( }); if (!record) return null; - const managed = record.managedProfileAt != null; - if (managed) { + if (record.managedProfileAt == null) { return recipientUserId === recordUserId - ? null - : { recordUserId, recipientUserId, managed }; + ? { recordUserId, recipientUserId, managed: false } + : null; } - return recipientUserId === recordUserId - ? { recordUserId, recipientUserId, managed } - : null; + if (recipientUserId === recordUserId) return null; + + return prisma.$transaction(async (tx) => + withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { + if (lockedRecord?.managedProfileAt == null) return null; + + const recipient = await tx.user.findUnique({ + where: { id: recipientUserId }, + select: { managedProfileAt: true }, + }); + if (!recipient || recipient.managedProfileAt != null) return null; + + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: recordUserId, + granteeId: recipientUserId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!guardian) return null; + + return { recordUserId, recipientUserId, managed: true }; + }), + ); } diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index f1a79eaa8..79c00c95d 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -30,7 +30,7 @@ async function createGuardianGrant(input: { grantorId: input.recordUserId, granteeId: input.recipientUserId, access: input.access ?? "MANAGE", - acceptedAt: input.acceptedAt ?? new Date(), + acceptedAt: input.acceptedAt === undefined ? new Date() : input.acceptedAt, revokedAt: input.revokedAt ?? null, revokedBy: input.revokedAt ? "GRANTOR" : null, expiresAt: input.expiresAt ?? null,