diff --git a/prisma/migrations/0390_handover_creator_set_null/migration.sql b/prisma/migrations/0390_handover_creator_set_null/migration.sql new file mode 100644 index 000000000..22525bd7d --- /dev/null +++ b/prisma/migrations/0390_handover_creator_set_null/migration.sql @@ -0,0 +1,14 @@ +-- v1.42 (#959): a handover outlives the Guardian who minted it. +-- +-- `created_by_id` cascaded, so a Guardian deleting their own account deleted +-- every handover they had issued, including a claimed one whose decision the +-- new owner had not made yet. The claim had already applied the proposal +-- (possibly MANAGE for a co-Guardian), and the owner lost the one place that +-- asks them to confirm it. The creator is now cleared instead; an unclaimed +-- link without a creator is refused by the claim like any link whose creator +-- lost access. +ALTER TABLE "managed_profile_handovers" ALTER COLUMN "created_by_id" DROP NOT NULL; + +ALTER TABLE "managed_profile_handovers" DROP CONSTRAINT "managed_profile_handovers_created_by_id_fkey"; + +ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23d5a7755..a9118035e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3927,8 +3927,11 @@ model ManagedProfileHandover { id String @id @default(cuid()) /// The managed profile (`User` row with `managedProfileAt` set). profileId String @map("profile_id") - /// The guardian who minted the link. - createdById String @map("created_by_id") + /// The guardian who minted the link. Cleared when that account is deleted + /// (0390): a claimed handover keeps the new owner's pending decision, and + /// an unclaimed link without a creator is refused like any link whose + /// creator lost access. + createdById String? @map("created_by_id") /// HMAC-SHA256 of the token, unique; the claim and its preview look the /// link up by it (`src/lib/managed-profiles/handover.ts`). tokenHash String @unique @map("token_hash") @@ -3947,8 +3950,8 @@ model ManagedProfileHandover { /// creator lost access. revokedAt DateTime? @map("revoked_at") - profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade) - createdBy User @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: Cascade) + profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade) + createdBy User? @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: SetNull) @@index([profileId]) // `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS diff --git a/src/lib/managed-profiles/handover.ts b/src/lib/managed-profiles/handover.ts index b9a83affa..2aae9b2a7 100644 --- a/src/lib/managed-profiles/handover.ts +++ b/src/lib/managed-profiles/handover.ts @@ -199,9 +199,12 @@ function proposalFor(state: HandoverState, grantId: string): HandoverAccess { async function isActiveGuardian( db: Pick, profileId: string, - guardianId: string, + guardianId: string | null, now: Date, ): Promise { + // A link whose creator's account is gone (`created_by_id` cleared) has no + // Guardian behind it. + if (guardianId === null) return false; const grant = await db.accountGrant.findFirst({ where: { grantorId: profileId, @@ -793,7 +796,23 @@ export async function decideHandover(input: { throw new HandoverError("no_pending"); } const claim = state.claim; - const known = new Set(claim.guardians.map((e) => e.grantId)); + // A former Guardian whose account is gone has nothing left to decide: + // the pending read leaves them out, and a decision that names them + // anyway is refused like one about a stranger, rather than failing on + // a grant written for an account that no longer exists. + const present = new Set( + ( + await tx.user.findMany({ + where: { id: { in: claim.guardians.map((e) => e.guardianId) } }, + select: { id: true }, + }) + ).map((u) => u.id), + ); + const known = new Set( + claim.guardians + .filter((e) => present.has(e.guardianId)) + .map((e) => e.grantId), + ); if (input.decisions.some((d) => !known.has(d.grantId))) { throw new HandoverError("unknown_guardian"); } @@ -807,6 +826,16 @@ export async function decideHandover(input: { }[] = []; const guardians: StoredClaimEntry[] = []; for (const entry of claim.guardians) { + if (!present.has(entry.guardianId)) { + guardians.push(entry); + outcome.push({ + grantId: entry.grantId, + access: entry.applied, + changed: false, + skipped: true, + }); + continue; + } const target = input.decisions.find((d) => d.grantId === entry.grantId)?.access ?? entry.applied; diff --git a/tests/integration/managed-profile-handover.test.ts b/tests/integration/managed-profile-handover.test.ts index 9a4d49091..fd356ee34 100644 --- a/tests/integration/managed-profile-handover.test.ts +++ b/tests/integration/managed-profile-handover.test.ts @@ -990,6 +990,48 @@ describe("the new owner's decision", () => { expect(response.status).toBe(422); }); + it("keeps the decision waiting when the issuing Guardian deletes their account", async () => { + const h = await claimedHousehold((x) => [ + { grantId: x.creatorGrant.id, proposal: "end" }, + { grantId: x.secondGrant.id, proposal: "manage" }, + ]); + const prisma = getPrismaClient(); + await prisma.user.delete({ where: { id: h.creator.id } }); + + const handover = await prisma.managedProfileHandover.findFirstOrThrow({ + where: { profileId: h.profile.id }, + }); + expect(handover.createdById).toBeNull(); + + const pending = (await (await readDecision()).json()).data.pending; + expect(pending).not.toBeNull(); + expect(pending.guardians).toEqual([ + expect.objectContaining({ + grantId: h.secondGrant.id, + proposal: "manage", + current: "manage", + decidable: true, + }), + ]); + expect( + (await decide([{ grantId: h.secondGrant.id, access: "read" }])).status, + ).toBe(200); + }); + + it("refuses a decision about a Guardian whose account is gone", async () => { + const h = await claimedHousehold((x) => [ + { grantId: x.secondGrant.id, proposal: "end" }, + ]); + await getPrismaClient().user.delete({ where: { id: h.second.id } }); + const response = await decide([ + { grantId: h.secondGrant.id, access: "manage" }, + ]); + expect(response.status).toBe(422); + expect((await response.json()).meta.errorCode).toBe( + "managed_profile.handover.unknown_guardian", + ); + }); + it("answers no pending decision to an account that never was a profile", async () => { const someone = await person("someone"); signIn(someone);