diff --git a/prisma/migrations/0301_notification_egress_authorization/migration.sql b/prisma/migrations/0301_notification_egress_authorization/migration.sql index 3ff6c1da4..fb76cc9c5 100644 --- a/prisma/migrations/0301_notification_egress_authorization/migration.sql +++ b/prisma/migrations/0301_notification_egress_authorization/migration.sql @@ -30,3 +30,12 @@ CREATE INDEX "notification_egress_authorizations_record_user_id_recipient_user_i "recipient_user_id", "authorized_at" DESC ); + +-- A recipient can erase their account without owning the underlying record. +-- PostgreSQL needs a recipient-first access path for that FK cascade and for +-- the explicit Delete All Data predicate over recipient_user_id. +CREATE INDEX "notification_egress_auth_recipient_authorized_at_idx" + ON "notification_egress_authorizations" ( + "recipient_user_id", + "authorized_at" DESC + ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 021f06b0d..30290011e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5850,6 +5850,7 @@ model NotificationEgressAuthorization { @@index([authorizedAt], map: "notification_egress_authorizations_authorized_at_idx") @@index([recordUserId, recipientUserId, authorizedAt(sort: Desc)]) + @@index([recipientUserId, authorizedAt(sort: Desc)], map: "notification_egress_auth_recipient_authorized_at_idx") @@map("notification_egress_authorizations") } diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index f02ba8651..00811393c 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -146,6 +146,15 @@ describe("every schema model has a backup verdict", () => { "a one-word reason is how a decision stops being reviewable", ).toEqual([]); }); + + it("excludes Guardian egress claims rather than exporting delivery metadata", () => { + expect(backupVerdict("NotificationEgressAuthorization")).toBe( + "NOT_IN_BACKUP", + ); + expect(NOT_IN_BACKUP_MODELS.NotificationEgressAuthorization).toMatch( + /no notification content, credentials, or deduplication key/i, + ); + }); }); /** diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index 55451e619..745992427 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -341,4 +341,10 @@ describe("data-wipe completeness", () => { }); expect(wipeWhere("Measurement", "u1")).toEqual({ userId: "u1" }); }); + + it("selects both record and recipient ownership of a Guardian egress claim", () => { + expect(wipeWhere("NotificationEgressAuthorization", "u1")).toEqual({ + OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }], + }); + }); }); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 482fefb4a..eac7a533f 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -9,6 +9,10 @@ import { } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { invalidateUserData } from "@/lib/cache/invalidate"; +import { + LastManagedGuardianError, + protectManagedProfilesDuringDataWipe, +} from "@/lib/managed-profiles/lifecycle"; import { resolveWipeDelegate, USER_RESET, @@ -66,27 +70,45 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const userId = user.id; - const counts = await prisma.$transaction( - async (tx) => { - const perModel: Record = {}; - for (const model of WIPE_MODELS) { - const { count } = await resolveWipeDelegate(tx, model).deleteMany({ - where: { userId }, - }); - if (count > 0) perModel[wipeDelegateKey(model)] = count; - } + let counts: Record; + try { + counts = await prisma.$transaction( + async (tx) => { + // This precedes every data delete and holds the managed-record locks + // through AccountGrant removal. A queued Guardian egress claim thus + // rechecks after this wipe commits instead of sending from a grant + // this transaction has already deleted. + await protectManagedProfilesDuringDataWipe(tx, userId); - // The account row survives; the personal data carried on its own columns - // does not. Same classification contract as the model list. - await tx.user.update({ where: { id: userId }, data: USER_RESET }); + const perModel: Record = {}; + for (const model of WIPE_MODELS) { + const { count } = await resolveWipeDelegate(tx, model).deleteMany({ + where: { userId }, + }); + if (count > 0) perModel[wipeDelegateKey(model)] = count; + } - return perModel; - }, - { - timeout: WIPE_TRANSACTION_TIMEOUT_MS, - maxWait: WIPE_TRANSACTION_MAX_WAIT_MS, - }, - ); + // The account row survives; the personal data carried on its own columns + // does not. Same classification contract as the model list. + await tx.user.update({ where: { id: userId }, data: USER_RESET }); + + return perModel; + }, + { + timeout: WIPE_TRANSACTION_TIMEOUT_MS, + maxWait: WIPE_TRANSACTION_MAX_WAIT_MS, + }, + ); + } catch (error) { + if (error instanceof LastManagedGuardianError) { + return apiError( + "Add another Guardian or delete the managed profile first", + 409, + { errorCode: "managed_profile.guardian.required" }, + ); + } + throw error; + } const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index ae0ddc698..b5f14fcd2 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -162,6 +162,10 @@ export const WIPE_MODELS = [ "PushSubscription", "PushAttempt", "NotificationEvent", + // A Guardian egress claim belongs to both the record whose data prompted + // the notification and the recipient whose channel would receive it. A + // wipe by either account must remove it; see WIPE_OWNER_FIELDS below. + "NotificationEgressAuthorization", "Device", "TelegramScheduledDeletion", "TelegramPromptContext", @@ -423,6 +427,7 @@ export const WIPE_OWNER_FIELDS: Readonly> = { // import history. MedicationIntakeImportJob: ["recordUserId"], NotificationEvent: ["recordUserId"], + NotificationEgressAuthorization: ["recordUserId", "recipientUserId"], // Both sides. Wiping only the grantor side would leave the account still // holding read access to other people's records after it asked for // everything of its own to be deleted. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index ef5dee16d..3a58e8206 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -408,6 +408,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "A 90-day delivery ledger for this host's sends. Restoring it onto another host would assert sends it never made.", NotificationEvent: "A local notification dedup anchor. Restoring it onto another host would suppress reminders and safety notices that host still owes.", + NotificationEgressAuthorization: + "A short-lived, deployment-local Guardian egress ledger. It retains no notification content, credentials, or deduplication key, and its outcome is observation rather than authorization; restoring it would nevertheless carry stale send history to a host that must recheck the live grant before every send. Exclude it so a backup never exports notification-derived privacy metadata or makes a restored host reason from another host's delivery work.", AuditLog: "An append-only record of actions taken on this instance. Merging one instance's audit trail into another's would make the trail untrue.", CoachUsage: diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 8bb929660..6865d3f52 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -121,6 +121,68 @@ export async function deleteGuardianAccountWithLifecycle( }); } +/** + * Hold every managed-record lock affected by a data wipe, then prove that the + * wipe will not leave a managed profile with no active Guardian. The caller + * keeps the transaction open through the AccountGrant deletion, so a final + * notification egress claim observes either the live grants before the wipe + * or the committed deletion afterwards; it cannot authorize in between. + * + * A data wipe keeps the account and its managed-profile marker. It therefore + * refuses rather than silently turning the last Guardian removal into an + * unmanaged profile. Deleting the managed profile remains the explicit + * product action for that case. + */ +export async function protectManagedProfilesDuringDataWipe( + tx: Transaction, + userId: string, +): Promise { + const affectedProfiles = await tx.accountGrant.findMany({ + where: { + OR: [{ grantorId: userId }, { granteeId: userId }], + grantor: { managedProfileAt: { not: null } }, + }, + select: { grantorId: true }, + distinct: ["grantorId"], + orderBy: { grantorId: "asc" }, + }); + + // Acquire every key before reading the final state. Sorting is required + // because a wipe can affect more than one managed profile. + for (const { grantorId } of affectedProfiles) { + await withManagedProfileLock(tx, grantorId, async () => undefined); + } + + // Re-read after all locks are held. A concurrent acceptance, revocation, + // expiry reduction, or notification claim uses the same key and is now + // ordered entirely before or after this transaction. + const now = new Date(); + for (const { grantorId } of affectedProfiles) { + await withManagedProfileLock(tx, grantorId, async (profile) => { + if (!profile?.managedProfileAt) return; + + const [activeGuardians, guardiansRemovedByWipe] = await Promise.all([ + tx.accountGrant.count({ + where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + }), + tx.accountGrant.count({ + where: { + AND: [ + { grantorId: profile.id }, + { OR: [{ grantorId: userId }, { granteeId: userId }] }, + activeGuardianWhere(now), + ], + }, + }), + ]); + + if (activeGuardians <= guardiansRemovedByWipe) { + throw new LastManagedGuardianError(); + } + }); + } +} + export async function deleteManagedProfile(input: { profileId: string; guardianId: string; diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts index 0d572d012..60449a68d 100644 --- a/tests/integration/settings-data-wipe.test.ts +++ b/tests/integration/settings-data-wipe.test.ts @@ -41,6 +41,10 @@ import { WIPE_OWNER_FIELDS, } from "@/lib/data-wipe/wipe-plan"; import type { PrismaClient } from "@/generated/prisma/client"; +import { + claimManagedGuardianEgressAuthorization, + resolveNotificationDeliveryIdentity, +} from "@/lib/notifications/delivery-identity"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -309,6 +313,18 @@ async function countRows( return Number(rows[0].n); } +async function managedProfileLockIsHeld( + prisma: PrismaClient, + profileId: string, +): Promise { + const rows = await prisma.$queryRaw>` + SELECT NOT pg_try_advisory_xact_lock( + hashtextextended(${`managed-profile:${profileId}`}, 0) + ) AS locked + `; + return rows[0]?.locked === true; +} + describe("DELETE /api/settings/data leaves nothing it promised to delete", () => { beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -467,6 +483,182 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () => ).toEqual([]); }); + it("refuses a wipe that would remove a managed profile's last Guardian", async () => { + const prisma = getPrismaClient(); + const [guardian, profile] = await Promise.all([ + prisma.user.create({ + data: { + username: "wipe-last-guardian", + email: "wipe-last-guardian@example.test", + }, + }), + prisma.user.create({ + data: { + username: "wipe-last-profile", + email: "wipe-last-profile@example.test", + managedProfileAt: new Date(), + }, + }), + ]); + await prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: guardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + await prisma.measurement.create({ + data: { + userId: guardian.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const { DELETE } = await import("@/app/api/settings/data/route"); + const response = await DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ); + + expect(response.status).toBe(409); + expect( + await prisma.accountGrant.count({ + where: { grantorId: profile.id, granteeId: guardian.id }, + }), + ).toBe(1); + expect( + await prisma.measurement.count({ where: { userId: guardian.id } }), + ).toBe(1); + }); + + it("makes a queued Guardian claim observe a completed data wipe", async () => { + const prisma = getPrismaClient(); + const [guardian, reserveGuardian, profile] = await Promise.all([ + prisma.user.create({ + data: { + username: "wipe-race-guardian", + email: "wipe-race-guardian@example.test", + }, + }), + prisma.user.create({ + data: { + username: "wipe-race-reserve", + email: "wipe-race-reserve@example.test", + }, + }), + prisma.user.create({ + data: { + username: "wipe-race-profile", + email: "wipe-race-profile@example.test", + managedProfileAt: new Date(), + }, + }), + ]); + await Promise.all([ + prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: guardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }), + prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: reserveGuardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }), + ]); + expect( + await prisma.accountGrant.count({ + where: { + grantorId: profile.id, + access: "MANAGE", + acceptedAt: { not: null }, + revokedAt: null, + }, + }), + ).toBe(2); + const measurement = await prisma.measurement.create({ + data: { + userId: guardian.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + const delivery = await resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: profile.id, + recordUserId: profile.id, + recipientUserId: guardian.id, + title: "record reminder", + message: "record message", + }); + expect(delivery).toMatchObject({ managed: true }); + + let releaseMeasurement!: () => void; + let markMeasurementLocked!: () => void; + const measurementLocked = new Promise((resolve) => { + markMeasurementLocked = resolve; + }); + const holdMeasurement = new Promise((resolve) => { + releaseMeasurement = resolve; + }); + const blocker = prisma.$transaction(async (tx) => { + await tx.measurement.update({ + where: { id: measurement.id }, + data: { value: 81 }, + }); + markMeasurementLocked(); + await holdMeasurement; + }); + await measurementLocked; + + const { DELETE } = await import("@/app/api/settings/data/route"); + const wipe = DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ); + await vi.waitFor(async () => { + expect(await managedProfileLockIsHeld(prisma, profile.id)).toBe(true); + }); + + const claim = claimManagedGuardianEgressAuthorization( + delivery!, + "NTFY", + "MEDICATION_REMINDER", + ); + await expectPromiseToRemainPending(claim); + + releaseMeasurement(); + await blocker; + expect((await wipe).status).toBe(200); + await expect(claim).resolves.toBeNull(); + expect(await prisma.notificationEgressAuthorization.count()).toBe(0); + expect(await prisma.pushAttempt.count()).toBe(0); + }); + it("refuses without the typed confirmation and leaves the record alone", async () => { const prisma = getPrismaClient(); @@ -542,3 +734,14 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () => expect(await resolveShareToken(rawToken)).toBeNull(); }); }); + +async function expectPromiseToRemainPending(promise: Promise) { + const settled = await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(() => resolve(false), 25)), + ]); + expect(settled).toBe(false); +}