fix(02-11): protect guardian egress data lifecycle

- Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 14:05:59 +02:00
1 parent e8ce0e277a
commit c56d1ca50b
9 files changed
+338 -19

No files matched your search

@@ -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
);
+1
View File
@@ -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")
}
@@ -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,
);
});
});
/**
@@ -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" }],
});
});
});
+41 -19
View File
@@ -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<string, number> = {};
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<string, number>;
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<string, number> = {};
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);
+5
View File
@@ -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<Record<string, readonly string[]>> = {
// 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.
+2
View File
@@ -408,6 +408,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
"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:
+62
View File
@@ -121,6 +121,68 @@ export async function deleteGuardianAccountWithLifecycle<T>(
});
}
/**
* 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<void> {
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;
@@ -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<boolean> {
const rows = await prisma.$queryRaw<Array<{ locked: boolean }>>`
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<void>((resolve) => {
markMeasurementLocked = resolve;
});
const holdMeasurement = new Promise<void>((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<unknown>) {
const settled = await Promise.race([
promise.then(
() => true,
() => true,
),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 25)),
]);
expect(settled).toBe(false);
}