diff --git a/src/app/api/admin/users/[id]/undo-deletion/route.ts b/src/app/api/admin/users/[id]/undo-deletion/route.ts index fa122c415..540a0e51d 100644 --- a/src/app/api/admin/users/[id]/undo-deletion/route.ts +++ b/src/app/api/admin/users/[id]/undo-deletion/route.ts @@ -43,6 +43,13 @@ export const POST = apiHandler( ipAddress: getClientIp(request), }); if (outcome === "not_found") return apiError("User not found", 404); + if (outcome === "no_guardian") { + return apiError( + "This managed profile has no Guardian left to give it back to. Its Guardians deleted their accounts or left it during the window.", + 409, + { errorCode: "account.deletion.no_guardian" }, + ); + } if (outcome === "not_pending") { return apiError("This account is not pending deletion", 409, { errorCode: "account.deletion.not_pending", diff --git a/src/lib/account-deletion/lifecycle.ts b/src/lib/account-deletion/lifecycle.ts index 713116171..20ea4f488 100644 --- a/src/lib/account-deletion/lifecycle.ts +++ b/src/lib/account-deletion/lifecycle.ts @@ -19,6 +19,7 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { credentialRevocations } from "@/lib/auth/credential-revocations"; +import { remainingGuardianWhere } from "@/lib/managed-profiles/guardian-where"; import { requestOffhostPurge, type OffhostPurgeReason, @@ -57,6 +58,18 @@ export async function lockAccountForDeletion( for (const revoke of credentialRevocations(tx, input.userId)) { await revoke; } + // A handover link is a credential for the profile it hands over: claimed + // during the window, it would give someone an account that is locked at + // once and erased when the window ends. The links this account minted for + // a profile it guards go too: nothing acts on its behalf from now on. + await tx.managedProfileHandover.updateMany({ + where: { + OR: [{ profileId: input.userId }, { createdById: input.userId }], + usedAt: null, + revokedAt: null, + }, + data: { revokedAt: input.requestedAt }, + }); } /** @@ -113,11 +126,17 @@ export async function lockPendingAccount( }; } -type CancelDeletionOutcome = "cancelled" | "not_found" | "not_pending"; +type CancelDeletionOutcome = + "cancelled" | "not_found" | "not_pending" | "no_guardian"; /** * Undo a pending deletion. Audit-logged twice: once for the admin who undid * it, once on the account itself so its own activity shows what happened. + * + * A managed profile comes back only to a Guardian: one whose own account is + * not pending deletion. Its Guardians may have left during the window (the + * last-Guardian rule no longer counts a profile pending deletion), and a + * profile nobody can reach is not something to give back. */ export async function cancelAccountDeletion(input: { userId: string; @@ -125,9 +144,25 @@ export async function cancelAccountDeletion(input: { ipAddress?: string | null; }): Promise { return prisma.$transaction(async (tx) => { + // The record lock first (the key `withManagedProfileLock` takes), then + // the row, in the order the purge takes them, so a Guardian leaving and + // this undo are ordered too. + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended(${`managed-profile:${input.userId}`}, 0)) + `; const state = await lockPendingAccount(tx, input.userId); if (!state) return "not_found"; if (!state.pending) return "not_pending"; + if (state.managedProfile) { + const guardians = await tx.accountGrant.count({ + where: { + grantorId: input.userId, + ...remainingGuardianWhere(new Date()), + }, + }); + if (guardians === 0) return "no_guardian"; + } await tx.user.update({ where: { id: input.userId }, data: { diff --git a/src/lib/managed-profiles/guardian-where.ts b/src/lib/managed-profiles/guardian-where.ts new file mode 100644 index 000000000..ffa103049 --- /dev/null +++ b/src/lib/managed-profiles/guardian-where.ts @@ -0,0 +1,25 @@ +/** + * Which `AccountGrant` rows are a managed profile's Guardians. Apart from + * `lifecycle.ts` so the account-deletion undo, which `lifecycle.ts` itself + * imports, can read the same rule. + */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; + +export const activeGuardianWhere = (now: Date) => ({ + access: "MANAGE" as const, + acceptedAt: { not: null }, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], +}); + +/** + * The Guardians the "last Guardian" rule counts: active grants whose holder + * is not pending deletion. A Guardian pending deletion cannot sign in, acts + * for nobody, and is erased when the window ends, so counting them let a + * second Guardian leave on the strength of the first, and left the profile + * with nobody once both windows ended. + */ +export const remainingGuardianWhere = (now: Date) => ({ + ...activeGuardianWhere(now), + grantee: ACTIVE_ACCOUNT, +}); diff --git a/src/lib/managed-profiles/handover.ts b/src/lib/managed-profiles/handover.ts index 76e725663..a5e1e4bbe 100644 --- a/src/lib/managed-profiles/handover.ts +++ b/src/lib/managed-profiles/handover.ts @@ -248,7 +248,11 @@ export async function createHandover(input: { const now = input.now ?? new Date(); return prisma.$transaction((tx) => withManagedProfileLock(tx, input.profileId, async (profile) => { - if (!profile?.managedProfileAt) throw new HandoverError("not_found"); + // A profile pending deletion has left every roster: there is nothing + // to hand over. + if (!profile?.managedProfileAt || profile.deletionRequestedAt) { + throw new HandoverError("not_found"); + } const guardians = await tx.accountGrant.findMany({ where: { grantorId: profile.id, ...activeGuardianWhere(now) }, @@ -334,9 +338,9 @@ export async function readHandoverStatus(input: { const now = input.now ?? new Date(); const profile = await prisma.user.findUnique({ where: { id: input.profileId }, - select: { managedProfileAt: true }, + select: { managedProfileAt: true, deletionRequestedAt: true }, }); - if (!profile?.managedProfileAt) return null; + if (!profile?.managedProfileAt || profile.deletionRequestedAt) return null; if ( !(await isActiveGuardian(prisma, input.profileId, input.guardianId, now)) ) { @@ -446,7 +450,8 @@ export interface HandoverPreview { /** * The open link behind a stored row, or null when it can no longer be * claimed: used, withdrawn, expired, a state this code cannot read, a profile - * no longer managed, or a creator who lost access. The one definition of + * no longer managed or pending deletion, or a creator who lost access. The + * one definition of * "usable" the preview, the single-sign-on start and the callback share. */ async function usableHandover( @@ -463,9 +468,13 @@ async function usableHandover( const profile = await prisma.user.findUnique({ where: { id: row.profileId }, - select: { displayName: true, managedProfileAt: true }, + select: { + displayName: true, + managedProfileAt: true, + deletionRequestedAt: true, + }, }); - if (!profile?.managedProfileAt) return null; + if (!profile?.managedProfileAt || profile.deletionRequestedAt) return null; if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) { return null; } @@ -589,7 +598,13 @@ export async function claimManagedProfile(input: { try { return await prisma.$transaction((tx) => withManagedProfileLock(tx, found.profileId, async (profile) => { - if (!profile?.managedProfileAt) throw new HandoverError("invalid"); + // A profile pending deletion is not claimable: the new owner would be + // locked out at once and erased when the window ends. Its deletion + // revokes the open link too; this holds for a claim that waited on + // the lock while the deletion committed. + if (!profile?.managedProfileAt || profile.deletionRequestedAt) { + throw new HandoverError("invalid"); + } // Re-read under the lock: a new link, a withdrawal or a second claim // that committed while this one waited is seen here. diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 8c2d9c73f..0197f009f 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -7,6 +7,10 @@ import { eraseAccount, lockAccountForDeletion, } from "@/lib/account-deletion/lifecycle"; +import { + activeGuardianWhere, + remainingGuardianWhere, +} from "@/lib/managed-profiles/guardian-where"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; @@ -19,12 +23,26 @@ import { type Transaction = Prisma.TransactionClient; -export const activeGuardianWhere = (now: Date) => ({ - access: "MANAGE" as const, - acceptedAt: { not: null }, - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], -}); +export { activeGuardianWhere, remainingGuardianWhere }; + +/** + * Whether `profileId` keeps a Guardian who is not pending deletion once + * `leavingGuardianId` (if any) is gone. Call it under the record lock. + */ +async function keepsAGuardian( + tx: Transaction, + profileId: string, + leavingGuardianId: string | null, +): Promise { + const remaining = await tx.accountGrant.count({ + where: { + grantorId: profileId, + ...(leavingGuardianId ? { granteeId: { not: leavingGuardianId } } : {}), + ...remainingGuardianWhere(new Date()), + }, + }); + return remaining > 0; +} export type ManagedProfileLifecycleErrorCode = "not_found" | "not_managed" | "not_guardian" | "managed_grantee"; @@ -79,16 +97,17 @@ export async function withManagedProfileLock( export async function reduceManagedProfileGuardian( tx: Transaction, profileId: string, + leavingGuardianId: string, reduction: () => Promise, ): Promise { return withManagedProfileLock(tx, profileId, async (profile) => { - if (!profile?.managedProfileAt) return reduction(); - - const activeGuardians = await tx.accountGrant.count({ - where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) }, - }); - if (activeGuardians <= 1) throw new LastManagedGuardianError(); - + // A profile pending deletion has left every roster; nobody is stranded. + if (!profile?.managedProfileAt || profile.deletionRequestedAt) { + return reduction(); + } + if (!(await keepsAGuardian(tx, profile.id, leavingGuardianId))) { + throw new LastManagedGuardianError(); + } return reduction(); }); } @@ -134,11 +153,15 @@ export async function deleteGuardianAccountWithLifecycle( ): Promise { return prisma.$transaction(async (tx) => { const now = new Date(); + // A profile already pending deletion is not stranded by this account + // leaving: it has left every roster and goes at the end of its own + // window. Counting it refused a Guardian who had just deleted their only + // profile, with nothing left they could do about it. const grants = await tx.accountGrant.findMany({ where: { granteeId: guardianId, ...activeGuardianWhere(now), - grantor: { managedProfileAt: { not: null } }, + grantor: { managedProfileAt: { not: null }, deletionRequestedAt: null }, }, select: { grantorId: true }, distinct: ["grantorId"], @@ -147,7 +170,7 @@ export async function deleteGuardianAccountWithLifecycle( for (const { grantorId } of grants) { await withManagedProfileLock(tx, grantorId, async (profile) => { - if (!profile?.managedProfileAt) return; + if (!profile?.managedProfileAt || profile.deletionRequestedAt) return; const stillActive = await tx.accountGrant.findFirst({ where: { @@ -159,10 +182,12 @@ export async function deleteGuardianAccountWithLifecycle( }); if (!stillActive) return; - const activeGuardians = await tx.accountGrant.count({ - where: { grantorId, ...activeGuardianWhere(new Date()) }, - }); - if (activeGuardians <= 1) throw new LastManagedGuardianError(); + // Another Guardian who is not pending deletion must stay. Counted + // under the record lock, so two Guardians deleting at once cannot + // each count the other. + if (!(await keepsAGuardian(tx, grantorId, guardianId))) { + throw new LastManagedGuardianError(); + } }); } @@ -215,18 +240,21 @@ export async function protectManagedProfilesDuringDataWipe( const now = new Date(); for (const { grantorId } of affectedProfiles) { await withManagedProfileLock(tx, grantorId, async (profile) => { - if (!profile?.managedProfileAt) return; + // A profile pending deletion is stranded by nobody (see the deletion + // above); its grants still go with the wipe, as the caller settles. + if (!profile?.managedProfileAt || profile.deletionRequestedAt) return; + // Guardians pending deletion count on neither side. const [activeGuardians, guardiansRemovedByWipe] = await Promise.all([ tx.accountGrant.count({ - where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + where: { grantorId: profile.id, ...remainingGuardianWhere(now) }, }), tx.accountGrant.count({ where: { AND: [ { grantorId: profile.id }, { OR: [{ grantorId: userId }, { granteeId: userId }] }, - activeGuardianWhere(now), + remainingGuardianWhere(now), ], }, }), diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 699ba45ca..196066420 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -63,6 +63,7 @@ import { LastManagedGuardianError, ManagedProfileLifecycleError, reduceManagedProfileGuardian, + remainingGuardianWhere, withManagedProfileLock, } from "@/lib/managed-profiles/lifecycle"; import { @@ -410,10 +411,15 @@ export async function revokeManagedProfileGuardian(input: { target.acceptedAt !== null && (target.expiresAt === null || target.expiresAt > now); if (targetIsActive) { - const activeGuardians = await tx.accountGrant.count({ - where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + // Another Guardian who is not pending deletion must stay. + const remaining = await tx.accountGrant.count({ + where: { + grantorId: profile.id, + granteeId: { not: target.granteeId }, + ...remainingGuardianWhere(now), + }, }); - if (activeGuardians <= 1) throw new LastManagedGuardianError(); + if (remaining === 0) throw new LastManagedGuardianError(); } await tx.accountGrant.update({ @@ -763,7 +769,12 @@ async function endGrant( select: { id: true }, }); if (!live) return end(); - return reduceManagedProfileGuardian(db, current.grantorId, end); + return reduceManagedProfileGuardian( + db, + current.grantorId, + current.granteeId, + end, + ); }); } diff --git a/tests/integration/account-deletion-grace.test.ts b/tests/integration/account-deletion-grace.test.ts index bddf5bda0..c7f854bc5 100644 --- a/tests/integration/account-deletion-grace.test.ts +++ b/tests/integration/account-deletion-grace.test.ts @@ -516,6 +516,195 @@ describe("managed-profile deletion with a grace window", () => { }); }); +/** A managed profile with the given Guardians, each grant accepted. */ +async function profileGuardedBy(username: string, guardianIds: string[]) { + const prisma = getPrismaClient(); + const profile = await prisma.user.create({ + data: { + username, + displayName: "Kid", + managedProfileAt: new Date(), + timezone: "Europe/Berlin", + }, + }); + for (const granteeId of guardianIds) { + await prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + } + return profile; +} + +describe("a handover link and a profile pending deletion", () => { + it("revokes the open link with the deletion and refuses every way to claim the profile", async () => { + const prisma = getPrismaClient(); + const guardian = await seedAccount("guardian"); + const profile = await profileGuardedBy("managed-teen", [guardian.id]); + const { createHandover, previewHandover, claimManagedProfile } = + await import("@/lib/managed-profiles/handover"); + const { deleteManagedProfile } = + await import("@/lib/managed-profiles/lifecycle"); + const { token } = await createHandover({ + profileId: profile.id, + guardianId: guardian.id, + expiresInDays: 7, + proposals: [], + }); + expect(await previewHandover(token)).not.toBeNull(); + + await deleteManagedProfile({ + profileId: profile.id, + guardianId: guardian.id, + }); + const link = await prisma.managedProfileHandover.findFirstOrThrow({ + where: { profileId: profile.id }, + }); + expect(link.revokedAt).not.toBeNull(); + expect(await previewHandover(token)).toBeNull(); + + // A link the deletion did not reach (one that was reopened by hand here) + // is still refused: the claim decides on the profile under its lock. + await prisma.managedProfileHandover.update({ + where: { id: link.id }, + data: { revokedAt: null }, + }); + expect(await previewHandover(token)).toBeNull(); + await expect( + claimManagedProfile({ + link: { rawToken: token }, + username: "teen-now-adult", + email: "teen@example.test", + passwordHash: "argon2-hash-placeholder", + }), + ).rejects.toMatchObject({ name: "HandoverError" }); + const row = await prisma.user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(row.managedProfileAt).not.toBeNull(); + expect(row.passwordHash).toBeNull(); + }); + + it("revokes the links a Guardian minted when the Guardian deletes their account", async () => { + const prisma = getPrismaClient(); + const leaving = await seedAccount("guardian-leaving"); + const staying = await seedAccount("guardian-staying"); + const profile = await profileGuardedBy("managed-kid", [ + leaving.id, + staying.id, + ]); + const { createHandover, previewHandover } = + await import("@/lib/managed-profiles/handover"); + const { token } = await createHandover({ + profileId: profile.id, + guardianId: leaving.id, + expiresInDays: 7, + proposals: [], + }); + await signIn(leaving.id); + expect((await deleteOwnAccount()).status).toBe(200); + expect(await previewHandover(token)).toBeNull(); + }); +}); + +describe("the last-Guardian rule and accounts pending deletion", () => { + it("lets a Guardian whose only profile is pending deletion delete their own account", async () => { + const guardian = await seedAccount("guardian"); + const profile = await profileGuardedBy("managed-kid", [guardian.id]); + const { deleteManagedProfile } = + await import("@/lib/managed-profiles/lifecycle"); + await deleteManagedProfile({ + profileId: profile.id, + guardianId: guardian.id, + }); + await signIn(guardian.id); + expect((await deleteOwnAccount()).status).toBe(200); + }); + + it("does not let the second of two Guardians leave on the strength of the first, pending deletion", async () => { + const prisma = getPrismaClient(); + const first = await seedAccount("guardian-first"); + const second = await seedAccount("guardian-second"); + const profile = await profileGuardedBy("managed-kid", [ + first.id, + second.id, + ]); + await signIn(first.id); + expect((await deleteOwnAccount()).status).toBe(200); + await signIn(second.id); + const refused = await deleteOwnAccount(); + expect(refused.status).toBe(409); + expect( + (await prisma.user.findUniqueOrThrow({ where: { id: second.id } })) + .deletionRequestedAt, + ).toBeNull(); + + // Nor through a data wipe, which ends the second Guardian's grant. + const { protectManagedProfilesDuringDataWipe } = + await import("@/lib/managed-profiles/lifecycle"); + await expect( + prisma.$transaction((tx) => + protectManagedProfilesDuringDataWipe(tx, second.id), + ), + ).rejects.toMatchObject({ name: "LastManagedGuardianError" }); + expect( + await prisma.accountGrant.count({ where: { grantorId: profile.id } }), + ).toBe(2); + }); + + it("does not give back a managed profile whose Guardians left during the window", async () => { + const guardian = await seedAccount("guardian"); + const profile = await profileGuardedBy("managed-kid", [guardian.id]); + const { deleteManagedProfile } = + await import("@/lib/managed-profiles/lifecycle"); + await deleteManagedProfile({ + profileId: profile.id, + guardianId: guardian.id, + }); + await signIn(guardian.id); + expect((await deleteOwnAccount()).status).toBe(200); + + const admin = await seedAccount("admin", "ADMIN"); + const { cancelAccountDeletion } = + await import("@/lib/account-deletion/lifecycle"); + expect( + await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }), + ).toBe("no_guardian"); + // The Guardian's own deletion undone first: the profile can come back. + expect( + await cancelAccountDeletion({ userId: guardian.id, adminId: admin.id }), + ).toBe("cancelled"); + expect( + await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }), + ).toBe("cancelled"); + }); +}); + +describe("the last admin and admins pending deletion", () => { + it("does not let the last usable admin step down while another is pending deletion", async () => { + const prisma = getPrismaClient(); + const first = await seedAccount("admin-one", "ADMIN"); + const second = await seedAccount("admin-two", "ADMIN"); + await signIn(first.id); + expect((await deleteOwnAccount()).status).toBe(200); + + await signIn(second.id); + const { PUT } = await import("@/app/api/admin/users/[id]/route"); + const res = await PUT( + jsonRequest(`/api/admin/users/${second.id}`, "PUT", { role: "USER" }), + { params: Promise.resolve({ id: second.id }) }, + ); + expect(res.status).toBe(400); + expect( + (await prisma.user.findUniqueOrThrow({ where: { id: second.id } })).role, + ).toBe("ADMIN"); + }); +}); + describe("delete all data with a grace window", () => { it("keeps a copy of the record that an admin can restore", async () => { const prisma = getPrismaClient();