mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user