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