fix: enforce notification delivery authority

- Require active Guardian authority before cross-record delivery\n- Serialize managed delivery checks with lifecycle changes
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 04:29:18 +02:00
1 parent b78d64cb98
commit 1ba73a4d51
3 files changed
+58 -9

No files matched your search

@@ -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<unknown>) =>
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 () => {
+39 -8
View File
@@ -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 };
}),
);
}
@@ -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,