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

@@ -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;