From 51cbbae17f41114a1de69ff20106aa968bd75bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:25:19 +0200 Subject: [PATCH 01/11] test: cover managed profile creation - Prove truthful profile attributes and the initial Guardian grant\n- Prove grant-write failure rolls the profile transaction back --- .../managed-profile-contract.test.ts | 26 +++ .../managed-profile-lifecycle.test.ts | 160 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/__tests__/managed-profile-contract.test.ts create mode 100644 tests/integration/managed-profile-lifecycle.test.ts diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts new file mode 100644 index 000000000..67ef25d37 --- /dev/null +++ b/src/__tests__/managed-profile-contract.test.ts @@ -0,0 +1,26 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); + +describe("managed-profile contract", () => { + it("requires a cookie-backed fresh MFA proof for creation", async () => { + const source = await readFile( + path.join(root, "src/app/api/managed-profiles/route.ts"), + "utf8", + ); + + expect(source).toContain("requireFreshMfa"); + expect(source).not.toContain("requireFreshMfaIfEnrolled"); + }); + + it("does not publish an emancipation API surface", async () => { + const source = await readFile( + path.join(root, "prisma/schema.prisma"), + "utf8", + ); + + expect(source.toLowerCase()).not.toContain("emancipation"); + }); +}); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts new file mode 100644 index 000000000..564412b04 --- /dev/null +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -0,0 +1,160 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +let sequence = 0; + +async function signInWithFreshMfa() { + const suffix = sequence++; + const prisma = getPrismaClient(); + const guardian = await prisma.user.create({ + data: { + username: `guardian-${suffix}`, + email: `guardian-${suffix}@example.test`, + totpConfirmedAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { + userId: guardian.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); + return guardian; +} + +function createRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/managed-profiles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(async () => { + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + 'DROP TRIGGER IF EXISTS managed_profile_creation_failure ON "account_grants"', + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_profile_creation()", + ); +}); + +describe("managed profile lifecycle (real Postgres)", () => { + it("creation records a complete DOB and one accepted non-expiring Guardian grant", async () => { + const guardian = await signInWithFreshMfa(); + const { POST } = await import("@/app/api/managed-profiles/route"); + + const response = await POST( + createRequest({ + displayName: "Managed profile", + dateOfBirth: "2012-03-04", + locale: "de", + timezone: "Europe/Zurich", + }), + ); + expect(response.status).toBe(201); + + const body = await response.json(); + const profile = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: body.data.id }, + }); + expect(profile.username).toMatch(/^managed-/); + expect(profile.username).not.toContain("Managed profile"); + expect(profile.email).toBeNull(); + expect(profile.passwordHash).toBeNull(); + expect(profile.dateOfBirth?.toISOString().slice(0, 10)).toBe("2012-03-04"); + expect(profile.locale).toBe("de"); + expect(profile.timezone).toBe("Europe/Zurich"); + expect(profile.managedProfileAt).not.toBeNull(); + + const grant = await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: profile.id, granteeId: guardian.id }, + }); + expect(grant.access).toBe("MANAGE"); + expect(grant.acceptedAt).not.toBeNull(); + expect(grant.expiresAt).toBeNull(); + }); + + it("creation accepts an absent DOB without fabricating one", async () => { + await signInWithFreshMfa(); + const { POST } = await import("@/app/api/managed-profiles/route"); + + const response = await POST( + createRequest({ + displayName: "No birth date", + locale: "en", + timezone: "UTC", + }), + ); + expect(response.status).toBe(201); + const body = await response.json(); + const profile = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: body.data.id }, + }); + expect(profile.dateOfBirth).toBeNull(); + }); + + it("rolls the profile back when creating its Guardian grant fails", async () => { + await signInWithFreshMfa(); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_profile_creation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'managed profile grant failure'; END; $$", + ); + await prisma.$executeRawUnsafe( + 'CREATE TRIGGER managed_profile_creation_failure BEFORE INSERT ON "account_grants" FOR EACH ROW EXECUTE FUNCTION fail_managed_profile_creation()', + ); + + const { POST } = await import("@/app/api/managed-profiles/route"); + const response = await POST( + createRequest({ + displayName: "Must roll back", + locale: "en", + timezone: "UTC", + }), + ); + expect(response.status).toBe(500); + expect( + await prisma.user.count({ + where: { managedProfileAt: { not: null } }, + }), + ).toBe(0); + expect(await prisma.accountGrant.count()).toBe(0); + }); +}); From 58434a2b446110b2274515779ac7c7c19bb0b606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:28:56 +0200 Subject: [PATCH 02/11] feat: create managed profiles atomically - Require a fresh cookie MFA proof before creation\n- Create the credential-less profile and initial Guardian grant together --- .../0297_managed_profile/migration.sql | 9 +- prisma/schema.prisma | 10 +-- src/app/api/managed-profiles/route.ts | 73 +++++++++++++++ src/lib/managed-profiles/create.ts | 88 +++++++++++++++++++ .../managed-profile-lifecycle.test.ts | 2 +- 5 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 src/app/api/managed-profiles/route.ts create mode 100644 src/lib/managed-profiles/create.ts diff --git a/prisma/migrations/0297_managed_profile/migration.sql b/prisma/migrations/0297_managed_profile/migration.sql index d7d309e46..83b0a252e 100644 --- a/prisma/migrations/0297_managed_profile/migration.sql +++ b/prisma/migrations/0297_managed_profile/migration.sql @@ -16,11 +16,10 @@ -- -- A timestamp rather than a boolean, deliberately. It says since when, which -- the activity trail needs to be readable across the transition, and clearing --- it is the emancipation act: the day the person takes the record over, --- credentials attach (`email` and `password_hash` are already nullable, so --- nothing here has to change for that) and this goes back to NULL. The grants --- survive untouched — the handover changes who holds the revoke button, not --- which rows exist. +-- it leaves a future-compatible NULL state. This migration deliberately adds +-- no transition route or credential attachment flow. If a later handover is +-- designed, `email` and `password_hash` are already nullable and the grants +-- can survive untouched while the product defines who holds the revoke button. -- -- No index. The column is read by primary key, on a row the request already -- loaded, and the one enumeration that scans for marked rows (the reminder diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 651897559..7dbbcdd21 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,11 +62,11 @@ model User { /// former and stay shut for the latter, and the gate is this column rather /// than the grant, so no argument about a grant level can reach them. /// - /// A timestamp rather than a boolean, for a reason the schema has to keep - /// possible: it says since when, and clearing it is the emancipation act — - /// the day the person takes their own record over, credentials attach and - /// this goes back to NULL, with the grants and the whole activity trail - /// spanning the transition untouched. + /// A timestamp rather than a boolean: it says since when, and preserves a + /// future-compatible null state without adding a transition surface here. + /// This release does not expose a way to clear the marker or attach + /// credentials; the grants and activity trail remain intact if a later, + /// separately designed handover ever needs that state. /// /// Consumers land in this release: the guardian resolver in /// `src/lib/api-handler.ts` reads it on every guardian request, the diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts new file mode 100644 index 000000000..964a6e0bf --- /dev/null +++ b/src/app/api/managed-profiles/route.ts @@ -0,0 +1,73 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { annotate } from "@/lib/logging/context"; +import { isValidTimezone } from "@/lib/tz/format"; + +const managedProfileSchema = z + .object({ + displayName: z.string().trim().min(1).max(80), + dateOfBirth: z.iso.date().nullable().optional(), + locale: z.enum(["de", "en", "es", "fr", "it", "pl"]), + timezone: z + .string() + .min(1) + .max(64) + .refine(isValidTimezone, "Invalid IANA timezone"), + }) + .strict(); + +/** + * Create a record a Guardian administers. This deliberately uses the + * cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less + * person and persistent management relationship. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = managedProfileSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: user.id, + displayName: parsed.data.displayName, + dateOfBirth: parsed.data.dateOfBirth + ? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`) + : null, + locale: parsed.data.locale, + timezone: parsed.data.timezone, + }); + + await auditLog("managed_profile.created", { + userId: user.id, + details: { profileId: profile.id, grantId: creatorGrant.id }, + }).catch(() => {}); + annotate({ + action: { name: "managed_profile.create" }, + meta: { profile_id: profile.id }, + }); + + return apiSuccess( + { + id: profile.id, + displayName: profile.displayName, + dateOfBirth: profile.dateOfBirth, + locale: profile.locale, + timezone: profile.timezone, + recordKind: "managed" as const, + }, + 201, + ); +}); diff --git a/src/lib/managed-profiles/create.ts b/src/lib/managed-profiles/create.ts new file mode 100644 index 000000000..431e36373 --- /dev/null +++ b/src/lib/managed-profiles/create.ts @@ -0,0 +1,88 @@ +import { randomBytes } from "node:crypto"; + +import { Prisma } from "@/generated/prisma/client"; +import type { AccountGrant, User } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; + +const USERNAME_ATTEMPTS = 4; + +export interface CreateManagedProfileInput { + creatorId: string; + displayName: string; + dateOfBirth: Date | null; + locale: string; + timezone: string; +} + +export interface CreatedManagedProfile { + profile: User; + creatorGrant: AccountGrant; +} + +export class ManagedProfileCreationError extends Error { + constructor() { + super("Unable to allocate a managed profile identity"); + this.name = "ManagedProfileCreationError"; + } +} + +function managedUsername(): string { + return `managed-${randomBytes(18).toString("hex")}`; +} + +function isUniqueViolation(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); +} + +/** + * Create the credential-less record and its first Guardian grant together. + * + * A profile is never useful before the creator can manage it, and a creator + * grant without its record is invalid by definition. Keeping both writes in + * one transaction makes a failed grant insert leave no login-less user behind. + */ +export async function createManagedProfile( + input: CreateManagedProfileInput, +): Promise { + for (let attempt = 0; attempt < USERNAME_ATTEMPTS; attempt += 1) { + try { + return await prisma.$transaction(async (tx) => { + const now = new Date(); + const profile = await tx.user.create({ + data: { + username: managedUsername(), + email: null, + passwordHash: null, + displayName: input.displayName, + dateOfBirth: input.dateOfBirth, + locale: input.locale, + timezone: input.timezone, + managedProfileAt: now, + }, + }); + const creatorGrant = await tx.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: input.creatorId, + access: "MANAGE", + scopeJson: Prisma.DbNull, + invitedAt: now, + acceptedAt: now, + expiresAt: null, + }, + }); + return { profile, creatorGrant }; + }); + } catch (error) { + if (isUniqueViolation(error) && attempt + 1 < USERNAME_ATTEMPTS) { + continue; + } + throw error; + } + } + + throw new ManagedProfileCreationError(); +} diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 564412b04..ad3071d87 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -131,7 +131,7 @@ describe("managed profile lifecycle (real Postgres)", () => { expect(profile.dateOfBirth).toBeNull(); }); - it("rolls the profile back when creating its Guardian grant fails", async () => { + it("creation rolls the profile back when its Guardian grant fails", async () => { await signInWithFreshMfa(); const prisma = getPrismaClient(); await prisma.$executeRawUnsafe( From 665f223dc474abf09a706606f261532ef2624bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:29:55 +0200 Subject: [PATCH 03/11] test: cover managed profile Guardian consent - Pin ordinary second-Guardian acceptance\n- Pin non-expiring accepted Guardian grants --- .../managed-profile-contract.test.ts | 10 +++- .../managed-profile-lifecycle.test.ts | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index 67ef25d37..a925ecf84 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -22,5 +22,13 @@ describe("managed-profile contract", () => { ); expect(source.toLowerCase()).not.toContain("emancipation"); + + const routes = await readdir( + path.join(root, "src/app/api/managed-profiles"), + { recursive: true }, + ); + expect( + routes.some((entry) => entry.toLowerCase().includes("emancip")), + ).toBe(false); }); }); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index ad3071d87..cd2f47ff1 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -3,6 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { + acceptGrant, + findActiveGrant, + inviteGrant, +} from "@/lib/sharing/grants"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -52,6 +58,16 @@ async function signInWithFreshMfa() { return guardian; } +async function makeAdult(label: string) { + const suffix = sequence++; + return getPrismaClient().user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + }, + }); +} + function createRequest(body: unknown): NextRequest { return new NextRequest("http://localhost/api/managed-profiles", { method: "POST", @@ -157,4 +173,41 @@ describe("managed profile lifecycle (real Postgres)", () => { ).toBe(0); expect(await prisma.accountGrant.count()).toBe(0); }); + + it("second Guardian accepts through the ordinary invitation and does not expire", async () => { + const creator = await signInWithFreshMfa(); + const secondGuardian = await makeAdult("second-guardian"); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitationExpiry = new Date(Date.now() + 60 * 60 * 1000); + + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + expiresAt: invitationExpiry, + }); + expect(invitation.acceptedAt).toBeNull(); + expect(invitation.expiresAt).toEqual(invitationExpiry); + + const accepted = await acceptGrant({ + grantId: invitation.id, + granteeId: secondGuardian.id, + }); + expect(accepted.acceptedAt).not.toBeNull(); + expect(accepted.expiresAt).toBeNull(); + expect( + await findActiveGrant( + { grantorId: profile.id, granteeId: secondGuardian.id }, + getPrismaClient(), + new Date(Date.now() + 24 * 60 * 60 * 1000), + ), + ).not.toBeNull(); + }); }); From e5e3590ca3a1da30f2314da8176dc5ed7a8a2de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:32:59 +0200 Subject: [PATCH 04/11] feat: add managed profile Guardians - Keep the ordinary invitation consent flow for a second Guardian\n- Clear managed Guardian expiry when the invitation is accepted --- prisma/schema.prisma | 5 ++++- src/lib/sharing/grants.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7dbbcdd21..d37aedff9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6036,7 +6036,10 @@ model AccountGrant { revokedBy AccountGrantRevoker? @map("revoked_by") /// Optional lapse date. Checked live on every resolution, so an expired - /// grant stops conferring access without anything having to sweep it. + /// grant stops conferring access without anything having to sweep it. A + /// managed-profile invitation may carry an expiry while awaiting consent; + /// accepting its MANAGE grant clears this field because an active Guardian + /// ends only through an explicit lifecycle transition. expiresAt DateTime? @map("expires_at") /// Refreshed fire-and-forget when the grant is exercised, the same posture /// as `ApiToken.lastUsedAt`. The owner's panel reads it to answer "is this diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 908294b4e..41b3e067f 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -432,6 +432,28 @@ export async function acceptGrant( throw await refusalFor(input.grantId, { granteeId: input.granteeId }, db); } + const accepted = await db.accountGrant.findUniqueOrThrow({ + where: { id: input.grantId }, + include: { grantor: { select: { managedProfileAt: true } } }, + }); + + // The expiry belongs to the invitation, not to a Guardian relationship. A + // managed profile still lets its creator set an expiry while the invitation + // waits for the other adult's consent, but once accepted the MANAGE grant is + // standing until a Guardian explicitly ends it. Ordinary adult-record grants + // retain their requested expiry unchanged. + if (accepted.access === "MANAGE" && accepted.grantor.managedProfileAt) { + await db.accountGrant.updateMany({ + where: { + id: accepted.id, + granteeId: input.granteeId, + acceptedAt: { not: null }, + revokedAt: null, + }, + data: { expiresAt: null }, + }); + } + return db.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } }); } From 5059bc9b69ab4fdd3b430d151a056a4730beba9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:36:08 +0200 Subject: [PATCH 05/11] test: cover managed profile Guardian races - Exercise concurrent final-Guardian reductions against Postgres\n- Cover recoverable account and profile deletion paths --- .../managed-profile-contract.test.ts | 21 +++++ ...naged-profile-guardian-concurrency.test.ts | 82 +++++++++++++++++++ .../managed-profile-lifecycle.test.ts | 71 ++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 tests/integration/managed-profile-guardian-concurrency.test.ts diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index a925ecf84..eea6ec3d3 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -31,4 +31,25 @@ describe("managed-profile contract", () => { routes.some((entry) => entry.toLowerCase().includes("emancip")), ).toBe(false); }); + + it("uses the same record lock for every Guardian-reducing transition", async () => { + const lifecycle = await readFile( + path.join(root, "src/lib/managed-profiles/lifecycle.ts"), + "utf8", + ); + const grants = await readFile( + path.join(root, "src/lib/sharing/grants.ts"), + "utf8", + ); + const accountDeletion = await readFile( + path.join(root, "src/app/api/settings/account/route.ts"), + "utf8", + ); + + expect(lifecycle).toContain("pg_advisory_xact_lock"); + expect(lifecycle).toContain("clearManagedProfileMarker"); + expect(lifecycle).toContain("deleteManagedProfile"); + expect(grants).toContain("reduceManagedProfileGuardian"); + expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle"); + }); }); diff --git a/tests/integration/managed-profile-guardian-concurrency.test.ts b/tests/integration/managed-profile-guardian-concurrency.test.ts new file mode 100644 index 000000000..274f943a8 --- /dev/null +++ b/tests/integration/managed-profile-guardian-concurrency.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { + acceptGrant, + inviteGrant, + renounceGrant, + revokeGrant, +} from "@/lib/sharing/grants"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +let sequence = 0; + +async function makeAdult(label: string) { + const suffix = sequence++; + return getPrismaClient().user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + }, + }); +} + +async function twoGuardians() { + const creator = await makeAdult("creator"); + const secondGuardian = await makeAdult("second-guardian"); + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + }); + const secondGrant = await acceptGrant({ + grantId: invitation.id, + granteeId: secondGuardian.id, + }); + return { creator, secondGuardian, profile, creatorGrant, secondGrant }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("managed profile Guardian concurrency (real Postgres)", () => { + it("allows only one concurrent reduction of the final two Guardians", async () => { + const { creatorGrant, secondGuardian, secondGrant, profile } = + await twoGuardians(); + + const results = await Promise.allSettled([ + revokeGrant({ grantId: creatorGrant.id, grantorId: profile.id }), + renounceGrant({ + grantId: secondGrant.id, + granteeId: secondGuardian.id, + }), + ]); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + expect( + await getPrismaClient().accountGrant.count({ + where: { + grantorId: profile.id, + access: "MANAGE", + acceptedAt: { not: null }, + revokedAt: null, + }, + }), + ).toBe(1); + }); +}); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index cd2f47ff1..f13d8670c 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -9,6 +9,11 @@ import { findActiveGrant, inviteGrant, } from "@/lib/sharing/grants"; +import { + LastManagedGuardianError, + clearManagedProfileMarker, + deleteManagedProfile, +} from "@/lib/managed-profiles/lifecycle"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -210,4 +215,70 @@ describe("managed profile lifecycle (real Postgres)", () => { ), ).not.toBeNull(); }); + + it("refuses to delete a Guardian account when it would leave a managed profile behind", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { DELETE } = await import("@/app/api/settings/account/route"); + + const response = await DELETE( + new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }), + ); + expect(response.status).toBe(409); + expect( + await getPrismaClient().user.findUnique({ where: { id: guardian.id } }), + ).not.toBeNull(); + expect( + await getPrismaClient().user.findUnique({ where: { id: profile.id } }), + ).not.toBeNull(); + }); + + it("deletes a managed profile only through its fresh-MFA Guardian path", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + await deleteManagedProfile({ + profileId: profile.id, + guardianId: guardian.id, + }); + expect( + await getPrismaClient().user.findUnique({ where: { id: profile.id } }), + ).toBeNull(); + }); + + it("keeps marker clearing internal while stopping managed behavior", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + await clearManagedProfileMarker({ profileId: profile.id }); + const cleared = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(cleared.managedProfileAt).toBeNull(); + await expect( + clearManagedProfileMarker({ profileId: "missing-profile" }), + ).rejects.toBeInstanceOf(LastManagedGuardianError); + }); }); From 523ab3220bb61843617c33fd086f37a621b58460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:44:34 +0200 Subject: [PATCH 06/11] fix: serialize managed profile Guardians Protect every Guardian-reducing lifecycle transition with a record lock.\n\nReturn a recoverable refusal when the final Guardian remains. --- .../managed-profile-contract.test.ts | 10 ++ .../api/account/grants/[id]/renounce/route.ts | 6 + src/app/api/account/grants/[id]/route.ts | 6 + src/app/api/managed-profiles/[id]/route.ts | 45 +++++ src/app/api/settings/account/route.ts | 39 ++-- src/lib/managed-profiles/lifecycle.ts | 167 ++++++++++++++++++ src/lib/sharing/grants.ts | 57 ++++-- .../managed-profile-lifecycle.test.ts | 19 +- 8 files changed, 318 insertions(+), 31 deletions(-) create mode 100644 src/app/api/managed-profiles/[id]/route.ts create mode 100644 src/lib/managed-profiles/lifecycle.ts diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index eea6ec3d3..2cbd1e5ec 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -15,6 +15,16 @@ describe("managed-profile contract", () => { expect(source).not.toContain("requireFreshMfaIfEnrolled"); }); + it("requires a cookie-backed fresh MFA proof for profile deletion", async () => { + const source = await readFile( + path.join(root, "src/app/api/managed-profiles/[id]/route.ts"), + "utf8", + ); + + expect(source).toContain("requireFreshMfa"); + expect(source).not.toContain("requireFreshMfaIfEnrolled"); + }); + it("does not publish an emancipation API surface", async () => { const source = await readFile( path.join(root, "prisma/schema.prisma"), diff --git a/src/app/api/account/grants/[id]/renounce/route.ts b/src/app/api/account/grants/[id]/renounce/route.ts index 5ab130a1d..2e5556a07 100644 --- a/src/app/api/account/grants/[id]/renounce/route.ts +++ b/src/app/api/account/grants/[id]/renounce/route.ts @@ -35,6 +35,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle"; import { GrantError, renounceGrantAndClearSwitch } from "@/lib/sharing/grants"; type RouteParams = { params: Promise<{ id: string }> }; @@ -76,6 +77,11 @@ export const POST = apiHandler( }); } catch (err) { if (err instanceof GrantError) return renounceErrorResponse(err); + if (err instanceof LastManagedGuardianError) { + return apiError("Add another Guardian before ending this access", 409, { + errorCode: "managed_profile.guardian.required", + }); + } throw err; } }, diff --git a/src/app/api/account/grants/[id]/route.ts b/src/app/api/account/grants/[id]/route.ts index 0fceca8b0..733098b39 100644 --- a/src/app/api/account/grants/[id]/route.ts +++ b/src/app/api/account/grants/[id]/route.ts @@ -34,6 +34,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle"; import { GrantError, revokeGrantAndClearSwitch } from "@/lib/sharing/grants"; type RouteParams = { params: Promise<{ id: string }> }; @@ -75,6 +76,11 @@ export const DELETE = apiHandler( }); } catch (err) { if (err instanceof GrantError) return revokeErrorResponse(err); + if (err instanceof LastManagedGuardianError) { + return apiError("Add another Guardian before ending this access", 409, { + errorCode: "managed_profile.guardian.required", + }); + } throw err; } }, diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts new file mode 100644 index 000000000..97f6241b7 --- /dev/null +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest } from "next/server"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { + deleteManagedProfile, + ManagedProfileLifecycleError, +} from "@/lib/managed-profiles/lifecycle"; +import { annotate } from "@/lib/logging/context"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** Delete a managed record only after its cookie-backed Guardian proves MFA. */ +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const { id } = await params; + + try { + await deleteManagedProfile({ profileId: id, guardianId: user.id }); + } catch (error) { + if (error instanceof ManagedProfileLifecycleError) { + return apiError("Managed profile not found", 404, { + errorCode: "managed_profile.not_found", + }); + } + throw error; + } + + await auditLog("managed_profile.deleted", { + userId: user.id, + details: { profileId: id }, + }).catch(() => {}); + annotate({ + action: { name: "managed_profile.delete" }, + meta: { profile_id: id }, + }); + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index 1b6125a76..bb8901342 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -1,7 +1,6 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; -import { destroyAllSessions } from "@/lib/auth/session"; import { NextRequest } from "next/server"; import { apiHandler, @@ -9,6 +8,10 @@ import { MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { + deleteGuardianAccountWithLifecycle, + LastManagedGuardianError, +} from "@/lib/managed-profiles/lifecycle"; export const dynamic = "force-dynamic"; @@ -63,18 +66,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => { details: { username }, }); - // Destroy all sessions first - await destroyAllSessions(userId); + try { + await deleteGuardianAccountWithLifecycle(userId, async (tx) => { + // Destroy all sessions before the user row goes, inside the same record + // lifecycle transaction so a refusal leaves every account state intact. + await tx.session.deleteMany({ where: { userId } }); - // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have - // `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login - // city geo) attached to the record after the user is deleted. We explicitly - // purge them inside the same logical operation so account deletion is - // genuinely complete erasure. - await prisma.auditLog.deleteMany({ where: { userId } }); + // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have + // `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login + // city geo) attached to the record after the user is deleted. We explicitly + // purge them inside the same logical operation so account deletion is + // genuinely complete erasure. + await tx.auditLog.deleteMany({ where: { userId } }); - // Delete user — all other related data is removed via onDelete: Cascade - await prisma.user.delete({ where: { id: userId } }); + // Delete user — all other related data is removed via onDelete: Cascade. + await tx.user.delete({ where: { id: userId } }); + }); + } 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; + } annotate({ action: { name: "settings.account.delete" }, diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts new file mode 100644 index 000000000..e94b0024e --- /dev/null +++ b/src/lib/managed-profiles/lifecycle.ts @@ -0,0 +1,167 @@ +import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; + +type Transaction = Prisma.TransactionClient; + +const ACTIVE_GUARDIAN = (now: Date) => ({ + access: "MANAGE" as const, + acceptedAt: { not: null }, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], +}); + +export type ManagedProfileLifecycleErrorCode = + "not_found" | "not_managed" | "not_guardian"; + +export class ManagedProfileLifecycleError extends Error { + constructor(readonly code: ManagedProfileLifecycleErrorCode) { + super(`managed profile lifecycle refused: ${code}`); + this.name = "ManagedProfileLifecycleError"; + } +} + +/** A refusal the caller can recover from by adding another Guardian. */ +export class LastManagedGuardianError extends Error { + constructor() { + super("A managed profile needs another Guardian before this change"); + this.name = "LastManagedGuardianError"; + } +} + +/** + * Serialize lifecycle changes on one managed-record key for the lifetime of + * the caller's transaction. The lock is held before re-reading the marker and + * every active Guardian row, so a second reducer sees the first committed + * change instead of making its decision from a stale count. + */ +export async function withManagedProfileLock( + tx: Transaction, + profileId: string, + operation: ( + profile: { id: string; managedProfileAt: Date | null } | null, + ) => Promise, +): Promise { + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended(${`managed-profile:${profileId}`}, 0)) + `; + const profile = await tx.user.findUnique({ + where: { id: profileId }, + select: { id: true, managedProfileAt: true }, + }); + return operation(profile); +} + +/** + * Run one removal of an active Guardian after holding the record lock and + * re-reading the invariant in the same transaction. + */ +export async function reduceManagedProfileGuardian( + tx: Transaction, + profileId: 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, ...ACTIVE_GUARDIAN(new Date()) }, + }); + if (activeGuardians <= 1) throw new LastManagedGuardianError(); + + return reduction(); + }); +} + +/** + * Keep account deletion and its Guardian checks inside the same transaction. + * Profiles are locked in id order so an account protecting more than one + * profile cannot deadlock with a concurrent lifecycle operation. + */ +export async function deleteGuardianAccountWithLifecycle( + guardianId: string, + deleteAccount: (tx: Transaction) => Promise, +): Promise { + return prisma.$transaction(async (tx) => { + const now = new Date(); + const grants = await tx.accountGrant.findMany({ + where: { + granteeId: guardianId, + ...ACTIVE_GUARDIAN(now), + grantor: { managedProfileAt: { not: null } }, + }, + select: { grantorId: true }, + distinct: ["grantorId"], + orderBy: { grantorId: "asc" }, + }); + + for (const { grantorId } of grants) { + await withManagedProfileLock(tx, grantorId, async (profile) => { + if (!profile?.managedProfileAt) return; + + const stillActive = await tx.accountGrant.findFirst({ + where: { + grantorId, + granteeId: guardianId, + ...ACTIVE_GUARDIAN(new Date()), + }, + select: { id: true }, + }); + if (!stillActive) return; + + const activeGuardians = await tx.accountGrant.count({ + where: { grantorId, ...ACTIVE_GUARDIAN(new Date()) }, + }); + if (activeGuardians <= 1) throw new LastManagedGuardianError(); + }); + } + + return deleteAccount(tx); + }); +} + +export async function deleteManagedProfile(input: { + profileId: string; + guardianId: string; +}): Promise { + await prisma.$transaction(async (tx) => { + await withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) { + throw new ManagedProfileLifecycleError("not_managed"); + } + + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: profile.id, + granteeId: input.guardianId, + ...ACTIVE_GUARDIAN(new Date()), + }, + select: { id: true }, + }); + if (!guardian) throw new ManagedProfileLifecycleError("not_guardian"); + + await tx.user.delete({ where: { id: profile.id } }); + }); + }); +} + +/** + * Internal-only future compatibility hook. There is intentionally no route for + * this state change in the current product; holding the same lock ensures any + * later handover cannot race a Guardian reduction. + */ +export async function clearManagedProfileMarker(input: { + profileId: string; +}): Promise { + await prisma.$transaction(async (tx) => { + await withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) return; + await tx.user.update({ + where: { id: profile.id }, + data: { managedProfileAt: null }, + }); + }); + }); +} diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 41b3e067f..e4be19787 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -54,6 +54,7 @@ import { prisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; import { clearActingSessions } from "@/lib/sharing/acting-session"; +import { reduceManagedProfileGuardian } from "@/lib/managed-profiles/lifecycle"; import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope"; import type { ShareDomain, ShareScope } from "@/lib/sharing/scope"; import { Prisma } from "@/generated/prisma/client"; @@ -473,9 +474,10 @@ export interface RevokeGrantInput { */ export async function revokeGrant( input: RevokeGrantInput, - db: GrantDb = prisma, ): Promise { - return endGrant(input.grantId, { grantorId: input.grantorId }, "GRANTOR", db); + return prisma.$transaction((tx) => + endGrant(input.grantId, { grantorId: input.grantorId }, "GRANTOR", tx), + ); } export interface RenounceGrantInput { @@ -494,9 +496,10 @@ export interface RenounceGrantInput { */ export async function renounceGrant( input: RenounceGrantInput, - db: GrantDb = prisma, ): Promise { - return endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", db); + return prisma.$transaction((tx) => + endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", tx), + ); } /** @@ -533,7 +536,9 @@ export interface EndedGrant { export async function revokeGrantAndClearSwitch( input: RevokeGrantInput, ): Promise { - return endAndClear((tx) => revokeGrant(input, tx)); + return endAndClear((tx) => + endGrant(input.grantId, { grantorId: input.grantorId }, "GRANTOR", tx), + ); } /** @@ -547,7 +552,9 @@ export async function revokeGrantAndClearSwitch( export async function renounceGrantAndClearSwitch( input: RenounceGrantInput, ): Promise { - return endAndClear((tx) => renounceGrant(input, tx)); + return endAndClear((tx) => + endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", tx), + ); } // ── Resolution ────────────────────────────────────────────────────────────── @@ -625,16 +632,42 @@ async function endGrant( grantId: string, actor: { grantorId: string } | { granteeId: string }, revokedBy: "GRANTOR" | "GRANTEE", - db: GrantDb, + db: Prisma.TransactionClient, ): Promise { - const { count } = await db.accountGrant.updateMany({ - where: { id: grantId, ...actor, revokedAt: null }, - data: { revokedAt: new Date(), revokedBy }, + const current = await db.accountGrant.findUnique({ + where: { id: grantId }, + select: { + grantorId: true, + granteeId: true, + access: true, + acceptedAt: true, + revokedAt: true, + expiresAt: true, + }, }); - if (count === 0) throw await refusalFor(grantId, actor, db); + const end = async () => { + const { count } = await db.accountGrant.updateMany({ + where: { id: grantId, ...actor, revokedAt: null }, + data: { revokedAt: new Date(), revokedBy }, + }); + if (count === 0) throw await refusalFor(grantId, actor, db); + return db.accountGrant.findUniqueOrThrow({ where: { id: grantId } }); + }; - return db.accountGrant.findUniqueOrThrow({ where: { id: grantId } }); + const actorOwnsGrant = + current && + ("grantorId" in actor + ? current.grantorId === actor.grantorId + : current.granteeId === actor.granteeId); + const isActiveManagedGuardian = + current?.access === "MANAGE" && + current.acceptedAt !== null && + current.revokedAt === null && + (current.expiresAt === null || current.expiresAt > new Date()); + + if (!actorOwnsGrant || !isActiveManagedGuardian) return end(); + return reduceManagedProfileGuardian(db, current.grantorId, end); } /** diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index f13d8670c..5226a0da9 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -10,9 +10,8 @@ import { inviteGrant, } from "@/lib/sharing/grants"; import { - LastManagedGuardianError, clearManagedProfileMarker, - deleteManagedProfile, + ManagedProfileLifecycleError, } from "@/lib/managed-profiles/lifecycle"; vi.mock("next/headers", async () => { @@ -243,7 +242,7 @@ describe("managed profile lifecycle (real Postgres)", () => { ).not.toBeNull(); }); - it("deletes a managed profile only through its fresh-MFA Guardian path", async () => { + it("deletes a managed profile through its fresh-MFA Guardian route", async () => { const guardian = await signInWithFreshMfa(); const { profile } = await createManagedProfile({ creatorId: guardian.id, @@ -253,10 +252,14 @@ describe("managed profile lifecycle (real Postgres)", () => { timezone: "UTC", }); - await deleteManagedProfile({ - profileId: profile.id, - guardianId: guardian.id, - }); + const { DELETE } = await import("@/app/api/managed-profiles/[id]/route"); + const response = await DELETE( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(200); expect( await getPrismaClient().user.findUnique({ where: { id: profile.id } }), ).toBeNull(); @@ -279,6 +282,6 @@ describe("managed profile lifecycle (real Postgres)", () => { expect(cleared.managedProfileAt).toBeNull(); await expect( clearManagedProfileMarker({ profileId: "missing-profile" }), - ).rejects.toBeInstanceOf(LastManagedGuardianError); + ).rejects.toBeInstanceOf(ManagedProfileLifecycleError); }); }); From 55291ad32d75d042d6e98ea2cb4257efed6a92c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 03:01:12 +0200 Subject: [PATCH 07/11] test: cover managed Guardian lifecycle races Exercise cookie routes, transaction rollback, and record-serialized races. --- .../managed-profile-lifecycle.test.ts | 423 +++++++++++++++++- tests/integration/mock-next-headers.ts | 6 + 2 files changed, 408 insertions(+), 21 deletions(-) diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 5226a0da9..050cc7553 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cookieJar, headerJar } from "./mock-next-headers"; +import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; import { createManagedProfile } from "@/lib/managed-profiles/create"; import { @@ -15,23 +15,31 @@ import { } from "@/lib/managed-profiles/lifecycle"; vi.mock("next/headers", async () => { - const { cookieJar, headerJar } = await import("./mock-next-headers"); + const { cookieJar, headerJar, queuedSessionIds } = + await import("./mock-next-headers"); return { headers: vi.fn(async () => ({ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, })), - cookies: vi.fn(async () => ({ - get: (name: string) => { - const value = cookieJar.get(name); - return value ? { name, value } : undefined; - }, - set: (name: string, value: string) => { - cookieJar.set(name, value); - }, - delete: (name: string) => { - cookieJar.delete(name); - }, - })), + cookies: vi.fn(async () => { + const snapshot = new Map(cookieJar); + const queuedSessionId = queuedSessionIds.shift(); + if (queuedSessionId) { + snapshot.set("healthlog_session", queuedSessionId); + } + return { + get: (name: string) => { + const value = snapshot.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + }; + }), }; }); @@ -41,25 +49,30 @@ vi.mock("@/lib/db-compat", () => ({ let sequence = 0; -async function signInWithFreshMfa() { +async function createFreshMfaUser(label = "guardian") { const suffix = sequence++; const prisma = getPrismaClient(); - const guardian = await prisma.user.create({ + const user = await prisma.user.create({ data: { - username: `guardian-${suffix}`, - email: `guardian-${suffix}@example.test`, + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, totpConfirmedAt: new Date(), }, }); const session = await prisma.session.create({ data: { - userId: guardian.id, + userId: user.id, expiresAt: new Date(Date.now() + 60 * 60 * 1000), mfaVerifiedAt: new Date(), }, }); - cookieJar.set("healthlog_session", session.id); - return guardian; + return { user, sessionId: session.id }; +} + +async function signInWithFreshMfa() { + const { user, sessionId } = await createFreshMfaUser(); + cookieJar.set("healthlog_session", sessionId); + return user; } async function makeAdult(label: string) { @@ -72,6 +85,29 @@ async function makeAdult(label: string) { }); } +async function createTwoGuardians() { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.user.id, + access: "MANAGE", + scope: null, + }); + const secondGrant = await acceptGrant({ + grantId: invitation.id, + granteeId: secondGuardian.user.id, + }); + return { creator, secondGuardian, profile, creatorGrant, secondGrant }; +} + function createRequest(body: unknown): NextRequest { return new NextRequest("http://localhost/api/managed-profiles", { method: "POST", @@ -84,6 +120,7 @@ beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); + queuedSessionIds.length = 0; }); afterEach(async () => { @@ -94,6 +131,18 @@ afterEach(async () => { await prisma.$executeRawUnsafe( "DROP FUNCTION IF EXISTS fail_managed_profile_creation()", ); + await prisma.$executeRawUnsafe( + 'DROP TRIGGER IF EXISTS managed_profile_acceptance_failure ON "account_grants"', + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_profile_acceptance()", + ); + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS managed_profile_delete_audit_failure ON audit_logs", + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_profile_delete_audit()", + ); }); describe("managed profile lifecycle (real Postgres)", () => { @@ -215,6 +264,143 @@ describe("managed profile lifecycle (real Postgres)", () => { ).not.toBeNull(); }); + it("lets a cookie-authenticated Guardian invite and revoke another Guardian", async () => { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); + const invited = await inviteGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: secondGuardian.user.username }), + }, + ), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(invited.status).toBe(201); + const invitation = (await invited.json()).data; + expect(invitation.acceptedAt).toBeNull(); + + cookieJar.set("healthlog_session", secondGuardian.sessionId); + const { POST: acceptInvitation } = + await import("@/app/api/account/grants/[id]/accept/route"); + const accepted = await acceptInvitation( + new NextRequest( + `http://localhost/api/account/grants/${invitation.id}/accept`, + { + method: "POST", + }, + ), + { params: Promise.resolve({ id: invitation.id }) }, + ); + expect(accepted.status).toBe(200); + + cookieJar.set("healthlog_session", creator.sessionId); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); + const revoked = await revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${invitation.id}`, + { method: "DELETE" }, + ), + { params: Promise.resolve({ id: profile.id, grantId: invitation.id }) }, + ); + expect(revoked.status).toBe(200); + expect( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ).toMatchObject({ revokedBy: "GRANTOR" }); + }); + + it("rolls managed Guardian acceptance back when the grant update fails", async () => { + const creator = await signInWithFreshMfa(); + const secondGuardian = await makeAdult("second-guardian"); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + expiresAt, + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_profile_acceptance() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.accepted_at IS NOT NULL AND OLD.accepted_at IS NULL THEN RAISE EXCEPTION 'managed profile acceptance failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + 'CREATE TRIGGER managed_profile_acceptance_failure BEFORE UPDATE ON "account_grants" FOR EACH ROW EXECUTE FUNCTION fail_managed_profile_acceptance()', + ); + + await expect( + acceptGrant({ grantId: invitation.id, granteeId: secondGuardian.id }), + ).rejects.toThrow("managed profile acceptance failure"); + expect( + await prisma.accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ).toMatchObject({ acceptedAt: null, expiresAt }); + expect( + await prisma.user.findUniqueOrThrow({ where: { id: profile.id } }), + ).toMatchObject({ managedProfileAt: expect.any(Date) }); + }); + + it("serializes Guardian acceptance against marker clearing", async () => { + const creator = await signInWithFreshMfa(); + const secondGuardian = await makeAdult("second-guardian"); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + expiresAt, + }); + + await Promise.all([ + acceptGrant({ grantId: invitation.id, granteeId: secondGuardian.id }), + clearManagedProfileMarker({ profileId: profile.id }), + ]); + + const [profileAfter, grantAfter] = await Promise.all([ + getPrismaClient().user.findUniqueOrThrow({ where: { id: profile.id } }), + getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ]); + expect(profileAfter.managedProfileAt).toBeNull(); + expect(grantAfter.acceptedAt).toEqual(expect.any(Date)); + expect([null, expiresAt.getTime()]).toContain( + grantAfter.expiresAt?.getTime() ?? null, + ); + }); + it("refuses to delete a Guardian account when it would leave a managed profile behind", async () => { const guardian = await signInWithFreshMfa(); const { profile } = await createManagedProfile({ @@ -242,6 +428,44 @@ describe("managed profile lifecycle (real Postgres)", () => { ).not.toBeNull(); }); + it("preserves account, session, and audit rows when last-Guardian deletion is refused", async () => { + const guardian = await signInWithFreshMfa(); + const prisma = getPrismaClient(); + await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + await prisma.auditLog.create({ + data: { action: "test.before.guardian.refusal", userId: guardian.id }, + }); + const [sessionsBefore, auditsBefore] = await Promise.all([ + prisma.session.count({ where: { userId: guardian.id } }), + prisma.auditLog.count({ where: { userId: guardian.id } }), + ]); + const { DELETE } = await import("@/app/api/settings/account/route"); + + const response = await DELETE( + new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }), + ); + expect(response.status).toBe(409); + await expect( + Promise.all([ + prisma.session.count({ where: { userId: guardian.id } }), + prisma.auditLog.count({ where: { userId: guardian.id } }), + prisma.auditLog.count({ + where: { userId: guardian.id, action: "user.account.delete" }, + }), + ]), + ).resolves.toEqual([sessionsBefore, auditsBefore, 0]); + }); + it("deletes a managed profile through its fresh-MFA Guardian route", async () => { const guardian = await signInWithFreshMfa(); const { profile } = await createManagedProfile({ @@ -265,6 +489,163 @@ describe("managed profile lifecycle (real Postgres)", () => { ).toBeNull(); }); + it("rolls profile deletion back when its audit entry fails", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_profile_delete_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.deleted' THEN RAISE EXCEPTION 'managed profile deletion audit failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + "CREATE TRIGGER managed_profile_delete_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_profile_delete_audit()", + ); + const { DELETE } = await import("@/app/api/managed-profiles/[id]/route"); + + const response = await DELETE( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(500); + expect( + await prisma.user.findUnique({ where: { id: profile.id } }), + ).not.toBeNull(); + expect( + await prisma.accountGrant.count({ where: { grantorId: profile.id } }), + ).toBe(1); + }); + + it("serializes a Guardian renunciation against that Guardian's account deletion", async () => { + const { profile, secondGuardian, secondGrant } = await createTwoGuardians(); + const { POST: renounce } = + await import("@/app/api/account/grants/[id]/renounce/route"); + const { DELETE: deleteAccount } = + await import("@/app/api/settings/account/route"); + queuedSessionIds.push( + secondGuardian.sessionId, + secondGuardian.sessionId, + secondGuardian.sessionId, + ); + + const [renounced, deleted] = await Promise.all([ + renounce( + new NextRequest( + `http://localhost/api/account/grants/${secondGrant.id}/renounce`, + { method: "POST" }, + ), + { params: Promise.resolve({ id: secondGrant.id }) }, + ), + deleteAccount( + new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }), + ), + ]); + + expect([200, 404]).toContain(renounced.status); + expect(deleted.status).toBe(200); + await expect( + Promise.all([ + getPrismaClient().user.findUnique({ + where: { id: secondGuardian.user.id }, + }), + getPrismaClient().session.count({ + where: { userId: secondGuardian.user.id }, + }), + getPrismaClient().accountGrant.count({ + where: { + grantorId: profile.id, + access: "MANAGE", + acceptedAt: { not: null }, + revokedAt: null, + }, + }), + ]), + ).resolves.toEqual([null, 0, 1]); + }); + + it("serializes a Guardian renunciation against managed profile deletion", async () => { + const { creator, profile, secondGuardian, secondGrant } = + await createTwoGuardians(); + const { POST: renounce } = + await import("@/app/api/account/grants/[id]/renounce/route"); + const { DELETE: deleteProfile } = + await import("@/app/api/managed-profiles/[id]/route"); + queuedSessionIds.push(secondGuardian.sessionId, creator.sessionId); + + const [renounced, deleted] = await Promise.all([ + renounce( + new NextRequest( + `http://localhost/api/account/grants/${secondGrant.id}/renounce`, + { method: "POST" }, + ), + { params: Promise.resolve({ id: secondGrant.id }) }, + ), + deleteProfile( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: profile.id }) }, + ), + ]); + + expect([200, 404]).toContain(renounced.status); + expect(deleted.status).toBe(200); + await expect( + Promise.all([ + getPrismaClient().user.findUnique({ where: { id: profile.id } }), + getPrismaClient().accountGrant.count({ + where: { grantorId: profile.id }, + }), + getPrismaClient().auditLog.count({ + where: { + userId: creator.user.id, + action: "managed_profile.deleted", + }, + }), + ]), + ).resolves.toEqual([null, 0, 1]); + }); + + it("serializes a Guardian revocation route against internal marker clearing", async () => { + const { creator, profile, secondGrant } = await createTwoGuardians(); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); + queuedSessionIds.push(creator.sessionId); + + const [revoked] = await Promise.all([ + revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${secondGrant.id}`, + { method: "DELETE" }, + ), + { + params: Promise.resolve({ id: profile.id, grantId: secondGrant.id }), + }, + ), + clearManagedProfileMarker({ profileId: profile.id }), + ]); + + expect([200, 404]).toContain(revoked.status); + const [profileAfter, grantAfter] = await Promise.all([ + getPrismaClient().user.findUniqueOrThrow({ where: { id: profile.id } }), + getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: secondGrant.id }, + }), + ]); + expect(profileAfter.managedProfileAt).toBeNull(); + expect([null, "GRANTOR"]).toContain(grantAfter.revokedBy); + }); + it("keeps marker clearing internal while stopping managed behavior", async () => { const guardian = await signInWithFreshMfa(); const { profile } = await createManagedProfile({ diff --git a/tests/integration/mock-next-headers.ts b/tests/integration/mock-next-headers.ts index e2acd04f0..edce8a7cc 100644 --- a/tests/integration/mock-next-headers.ts +++ b/tests/integration/mock-next-headers.ts @@ -18,3 +18,9 @@ */ export const cookieJar = new Map(); export const headerJar = new Map(); + +/** + * Session snapshots for invoking concurrent route handlers as different users. + * A request consumes one entry when its mocked cookie store is created. + */ +export const queuedSessionIds: string[] = []; From ffe3225639e03755d59d8a132613ecec4a6d9b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 03:05:19 +0200 Subject: [PATCH 08/11] fix: complete managed Guardian lifecycle Use locked Guardian membership and transactional lifecycle audits.\n\nKeep acceptance and marker changes atomic. --- prisma/schema.prisma | 6 +- .../managed-profile-contract.test.ts | 10 ++ .../[id]/guardians/[grantId]/route.ts | 49 ++++++ .../managed-profiles/[id]/guardians/route.ts | 91 ++++++++++ src/app/api/managed-profiles/[id]/route.ts | 5 - src/app/api/settings/account/route.ts | 18 +- src/lib/managed-profiles/lifecycle.ts | 18 +- src/lib/sharing/grants.ts | 161 ++++++++++++++---- 8 files changed, 299 insertions(+), 59 deletions(-) create mode 100644 src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts create mode 100644 src/app/api/managed-profiles/[id]/guardians/route.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d37aedff9..457e3a151 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,9 +69,9 @@ model User { /// separately designed handover ever needs that state. /// /// Consumers land in this release: the guardian resolver in - /// `src/lib/api-handler.ts` reads it on every guardian request, the - /// notification dispatcher fans reminders out to the guardians' channels on - /// it, and the creation flow is what writes it. + /// `src/lib/api-handler.ts` reads it on every guardian request, and the + /// creation flow is what writes it. A later notification plan may add + /// delivery fanout after it defines consent and preference semantics. managedProfileAt DateTime? @map("managed_profile_at") // Profile diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index 2cbd1e5ec..aff12b1ea 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -42,6 +42,16 @@ describe("managed-profile contract", () => { ).toBe(false); }); + it("does not claim notification fanout before its consent plan exists", async () => { + const source = await readFile( + path.join(root, "prisma/schema.prisma"), + "utf8", + ); + + expect(source).not.toContain("notification dispatcher fans reminders"); + expect(source).toContain("later notification plan"); + }); + it("uses the same record lock for every Guardian-reducing transition", async () => { const lifecycle = await readFile( path.join(root, "src/lib/managed-profiles/lifecycle.ts"), diff --git a/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts b/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts new file mode 100644 index 000000000..6b33c31d0 --- /dev/null +++ b/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest } from "next/server"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { + LastManagedGuardianError, + ManagedProfileLifecycleError, +} from "@/lib/managed-profiles/lifecycle"; +import { revokeManagedProfileGuardian } from "@/lib/sharing/grants"; +import { annotate } from "@/lib/logging/context"; + +type RouteParams = { params: Promise<{ id: string; grantId: string }> }; + +/** End another Guardian's access from a freshly verified browser session. */ +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const { id, grantId } = await params; + + try { + const grant = await revokeManagedProfileGuardian({ + profileId: id, + guardianId: user.id, + grantId, + }); + annotate({ + action: { name: "managed_profile.guardian.revoked" }, + meta: { profile_id: id, grant_id: grant.id }, + }); + return apiSuccess(grant); + } catch (error) { + if (error instanceof LastManagedGuardianError) { + return apiError("Add another Guardian before ending this access", 409, { + errorCode: "managed_profile.guardian.required", + }); + } + if (error instanceof ManagedProfileLifecycleError) { + return apiError("Managed profile or Guardian not found", 404, { + errorCode: "managed_profile.not_found", + }); + } + throw error; + } + }, +); diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts new file mode 100644 index 000000000..94bc12433 --- /dev/null +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -0,0 +1,91 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const INVITE_LIMIT = 10; +const INVITE_WINDOW_MS = 60 * 60 * 1000; + +const guardianInviteSchema = z + .object({ identifier: z.string().trim().min(1).max(320) }) + .strict(); + +/** Invite another Guardian from a cookie-backed, freshly verified session. */ +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const rateLimit = await checkRateLimit( + `managed-profile:guardian-invite:${user.id}`, + INVITE_LIMIT, + INVITE_WINDOW_MS, + ); + if (!rateLimit.allowed) { + return apiError("Too many invitations, try again later", 429); + } + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + const parsed = guardianInviteSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const { id } = await params; + + const invitee = await prisma.user.findFirst({ + where: { + OR: [ + { username: { equals: parsed.data.identifier, mode: "insensitive" } }, + { email: { equals: parsed.data.identifier, mode: "insensitive" } }, + ], + }, + select: { id: true }, + }); + if (!invitee) return apiError("No account with that name or e-mail", 404); + + try { + const grant = await inviteManagedProfileGuardian({ + profileId: id, + guardianId: user.id, + granteeId: invitee.id, + }); + annotate({ + action: { name: "managed_profile.guardian.invited" }, + meta: { profile_id: id, grant_id: grant.id }, + }); + return apiSuccess(grant, 201); + } catch (error) { + if (error instanceof GrantError && error.code === "self_grant") { + return apiError("An account cannot be shared with itself", 422, { + errorCode: "managed_profile.guardian.self", + }); + } + if (error instanceof GrantError) { + return apiError("That account already has access", 409, { + errorCode: "managed_profile.guardian.duplicate", + }); + } + if (error instanceof ManagedProfileLifecycleError) { + return apiError("Managed profile not found", 404, { + errorCode: "managed_profile.not_found", + }); + } + throw error; + } + }, +); diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts index 97f6241b7..e952f5f80 100644 --- a/src/app/api/managed-profiles/[id]/route.ts +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -6,7 +6,6 @@ import { requireFreshMfa, } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; -import { auditLog } from "@/lib/auth/audit"; import { deleteManagedProfile, ManagedProfileLifecycleError, @@ -32,10 +31,6 @@ export const DELETE = apiHandler( throw error; } - await auditLog("managed_profile.deleted", { - userId: user.id, - details: { profileId: id }, - }).catch(() => {}); annotate({ action: { name: "managed_profile.delete" }, meta: { profile_id: id }, diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index bb8901342..d976062ab 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -57,17 +57,17 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const userId = user.id; const username = user.username; - // Log BEFORE deletion. The audit row's userId is SetNull post-cascade - // (per schema), but we then immediately purge it below for GDPR Art. 17 - // erasure completeness — see comment further down. - await auditLog("user.account.delete", { - userId, - ipAddress: getClientIp(request), - details: { username }, - }); - try { await deleteGuardianAccountWithLifecycle(userId, async (tx) => { + // The invariant has now passed, so this success event cannot survive a + // refusal. It is purged with the rest of the account's audit history. + await auditLog("user.account.delete", { + userId, + ipAddress: getClientIp(request), + details: { username }, + client: tx, + }); + // Destroy all sessions before the user row goes, inside the same record // lifecycle transaction so a refusal leaves every account state intact. await tx.session.deleteMany({ where: { userId } }); diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index e94b0024e..55c544049 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -1,9 +1,10 @@ import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import type { Prisma } from "@/generated/prisma/client"; type Transaction = Prisma.TransactionClient; -const ACTIVE_GUARDIAN = (now: Date) => ({ +export const activeGuardianWhere = (now: Date) => ({ access: "MANAGE" as const, acceptedAt: { not: null }, revokedAt: null, @@ -65,7 +66,7 @@ export async function reduceManagedProfileGuardian( if (!profile?.managedProfileAt) return reduction(); const activeGuardians = await tx.accountGrant.count({ - where: { grantorId: profile.id, ...ACTIVE_GUARDIAN(new Date()) }, + where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) }, }); if (activeGuardians <= 1) throw new LastManagedGuardianError(); @@ -87,7 +88,7 @@ export async function deleteGuardianAccountWithLifecycle( const grants = await tx.accountGrant.findMany({ where: { granteeId: guardianId, - ...ACTIVE_GUARDIAN(now), + ...activeGuardianWhere(now), grantor: { managedProfileAt: { not: null } }, }, select: { grantorId: true }, @@ -103,14 +104,14 @@ export async function deleteGuardianAccountWithLifecycle( where: { grantorId, granteeId: guardianId, - ...ACTIVE_GUARDIAN(new Date()), + ...activeGuardianWhere(new Date()), }, select: { id: true }, }); if (!stillActive) return; const activeGuardians = await tx.accountGrant.count({ - where: { grantorId, ...ACTIVE_GUARDIAN(new Date()) }, + where: { grantorId, ...activeGuardianWhere(new Date()) }, }); if (activeGuardians <= 1) throw new LastManagedGuardianError(); }); @@ -135,12 +136,17 @@ export async function deleteManagedProfile(input: { where: { grantorId: profile.id, granteeId: input.guardianId, - ...ACTIVE_GUARDIAN(new Date()), + ...activeGuardianWhere(new Date()), }, select: { id: true }, }); if (!guardian) throw new ManagedProfileLifecycleError("not_guardian"); + await auditLog("managed_profile.deleted", { + userId: input.guardianId, + details: { profileId: profile.id }, + client: tx, + }); await tx.user.delete({ where: { id: profile.id } }); }); }); diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index e4be19787..7696c978c 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -54,7 +54,13 @@ import { prisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; import { clearActingSessions } from "@/lib/sharing/acting-session"; -import { reduceManagedProfileGuardian } from "@/lib/managed-profiles/lifecycle"; +import { + activeGuardianWhere, + LastManagedGuardianError, + ManagedProfileLifecycleError, + reduceManagedProfileGuardian, + withManagedProfileLock, +} from "@/lib/managed-profiles/lifecycle"; import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope"; import type { ShareDomain, ShareScope } from "@/lib/sharing/scope"; import { Prisma } from "@/generated/prisma/client"; @@ -415,47 +421,130 @@ export interface AcceptGrantInput { */ export async function acceptGrant( input: AcceptGrantInput, - db: GrantDb = prisma, ): Promise { - const now = new Date(); - const { count } = await db.accountGrant.updateMany({ - where: { - id: input.grantId, - granteeId: input.granteeId, - acceptedAt: null, - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], - }, - data: { acceptedAt: now }, - }); + return prisma.$transaction((tx) => acceptGrantInTransaction(input, tx)); +} - if (count === 0) { - throw await refusalFor(input.grantId, { granteeId: input.granteeId }, db); - } - - const accepted = await db.accountGrant.findUniqueOrThrow({ +async function acceptGrantInTransaction( + input: AcceptGrantInput, + tx: Prisma.TransactionClient, +): Promise { + const candidate = await tx.accountGrant.findUnique({ where: { id: input.grantId }, - include: { grantor: { select: { managedProfileAt: true } } }, + select: { grantorId: true, access: true }, }); - - // The expiry belongs to the invitation, not to a Guardian relationship. A - // managed profile still lets its creator set an expiry while the invitation - // waits for the other adult's consent, but once accepted the MANAGE grant is - // standing until a Guardian explicitly ends it. Ordinary adult-record grants - // retain their requested expiry unchanged. - if (accepted.access === "MANAGE" && accepted.grantor.managedProfileAt) { - await db.accountGrant.updateMany({ - where: { - id: accepted.id, - granteeId: input.granteeId, - acceptedAt: { not: null }, - revokedAt: null, - }, - data: { expiresAt: null }, - }); + if (!candidate) { + throw await refusalFor(input.grantId, { granteeId: input.granteeId }, tx); } - return db.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } }); + const accept = async (managedProfile: boolean) => { + const now = new Date(); + const { count } = await tx.accountGrant.updateMany({ + where: { + id: input.grantId, + granteeId: input.granteeId, + acceptedAt: null, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { + acceptedAt: now, + ...(managedProfile ? { expiresAt: null } : {}), + }, + }); + if (count === 0) { + throw await refusalFor(input.grantId, { granteeId: input.granteeId }, tx); + } + return tx.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } }); + }; + + if (candidate.access !== "MANAGE") return accept(false); + + return withManagedProfileLock(tx, candidate.grantorId, async (profile) => + accept(Boolean(profile?.managedProfileAt)), + ); +} + +export async function inviteManagedProfileGuardian(input: { + profileId: string; + guardianId: string; + granteeId: string; +}): Promise { + return prisma.$transaction(async (tx) => { + return withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) { + throw new ManagedProfileLifecycleError("not_managed"); + } + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: profile.id, + granteeId: input.guardianId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!guardian) throw new ManagedProfileLifecycleError("not_guardian"); + return inviteGrant( + { + grantorId: profile.id, + granteeId: input.granteeId, + access: "MANAGE", + scope: null, + expiresAt: null, + }, + tx, + ); + }); + }); +} + +export async function revokeManagedProfileGuardian(input: { + profileId: string; + guardianId: string; + grantId: string; +}): Promise { + return prisma.$transaction(async (tx) => { + return withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) { + throw new ManagedProfileLifecycleError("not_managed"); + } + const requester = await tx.accountGrant.findFirst({ + where: { + grantorId: profile.id, + granteeId: input.guardianId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!requester) throw new ManagedProfileLifecycleError("not_guardian"); + + const target = await tx.accountGrant.findFirst({ + where: { + id: input.grantId, + grantorId: profile.id, + ...activeGuardianWhere(new Date()), + }, + select: { id: true, granteeId: true }, + }); + if (!target) throw new ManagedProfileLifecycleError("not_guardian"); + if (target.granteeId === input.guardianId) { + throw new ManagedProfileLifecycleError("not_guardian"); + } + + const activeGuardians = await tx.accountGrant.count({ + where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) }, + }); + if (activeGuardians <= 1) throw new LastManagedGuardianError(); + + await tx.accountGrant.update({ + where: { id: target.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + return tx.accountGrant.findUniqueOrThrow({ where: { id: target.id } }); + }); + }); } export interface RevokeGrantInput { From 32380b1935c6811c3b183c80eecbf03893ca67aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 03:08:51 +0200 Subject: [PATCH 09/11] fix: clear revoked Guardian sessions Return a removed Guardian's browsers to their own record. --- src/lib/sharing/grants.ts | 4 ++++ tests/integration/managed-profile-lifecycle.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 7696c978c..0184f0c43 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -542,6 +542,10 @@ export async function revokeManagedProfileGuardian(input: { where: { id: target.id }, data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, }); + await clearActingSessions( + { grantorId: profile.id, granteeId: target.granteeId }, + tx, + ); return tx.accountGrant.findUniqueOrThrow({ where: { id: target.id } }); }); }); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 050cc7553..2b79f0283 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -306,6 +306,10 @@ describe("managed profile lifecycle (real Postgres)", () => { { params: Promise.resolve({ id: invitation.id }) }, ); expect(accepted.status).toBe(200); + await getPrismaClient().session.update({ + where: { id: secondGuardian.sessionId }, + data: { actingAsUserId: profile.id }, + }); cookieJar.set("healthlog_session", creator.sessionId); const { DELETE: revokeGuardian } = @@ -323,6 +327,11 @@ describe("managed profile lifecycle (real Postgres)", () => { where: { id: invitation.id }, }), ).toMatchObject({ revokedBy: "GRANTOR" }); + expect( + await getPrismaClient().session.findUniqueOrThrow({ + where: { id: secondGuardian.sessionId }, + }), + ).toMatchObject({ actingAsUserId: null }); }); it("rolls managed Guardian acceptance back when the grant update fails", async () => { From 493437207e42776a75d82b851a892e101bd141fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 03:14:47 +0200 Subject: [PATCH 10/11] test: cover managed Guardian invitation lifecycle --- .../managed-profile-lifecycle.test.ts | 194 +++++++++++++++++- 1 file changed, 193 insertions(+), 1 deletion(-) diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 2b79f0283..85dbaad22 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -143,6 +143,12 @@ afterEach(async () => { await prisma.$executeRawUnsafe( "DROP FUNCTION IF EXISTS fail_managed_profile_delete_audit()", ); + await prisma.$executeRawUnsafe( + 'DROP TRIGGER IF EXISTS managed_guardian_audit_failure ON audit_logs', + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_guardian_audit()", + ); }); describe("managed profile lifecycle (real Postgres)", () => { @@ -275,6 +281,7 @@ describe("managed profile lifecycle (real Postgres)", () => { locale: "en", timezone: "UTC", }); + const invitationExpiry = new Date(Date.now() + 60 * 60 * 1000); const { POST: inviteGuardian } = await import("@/app/api/managed-profiles/[id]/guardians/route"); @@ -284,7 +291,10 @@ describe("managed profile lifecycle (real Postgres)", () => { { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ identifier: secondGuardian.user.username }), + body: JSON.stringify({ + identifier: secondGuardian.user.username, + expiresAt: invitationExpiry.toISOString(), + }), }, ), { params: Promise.resolve({ id: profile.id }) }, @@ -292,6 +302,9 @@ describe("managed profile lifecycle (real Postgres)", () => { expect(invited.status).toBe(201); const invitation = (await invited.json()).data; expect(invitation.acceptedAt).toBeNull(); + expect(new Date(invitation.expiresAt).getTime()).toBe( + invitationExpiry.getTime(), + ); cookieJar.set("healthlog_session", secondGuardian.sessionId); const { POST: acceptInvitation } = @@ -306,6 +319,11 @@ describe("managed profile lifecycle (real Postgres)", () => { { params: Promise.resolve({ id: invitation.id }) }, ); expect(accepted.status).toBe(200); + expect( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ).toMatchObject({ acceptedAt: expect.any(Date), expiresAt: null }); await getPrismaClient().session.update({ where: { id: secondGuardian.sessionId }, data: { actingAsUserId: profile.id }, @@ -332,6 +350,180 @@ describe("managed profile lifecycle (real Postgres)", () => { where: { id: secondGuardian.sessionId }, }), ).toMatchObject({ actingAsUserId: null }); + const audit = await getPrismaClient().auditLog.findMany({ + where: { + userId: profile.id, + action: { in: ["managed_profile.guardian.invited", "managed_profile.guardian.revoked"] }, + }, + orderBy: { createdAt: "asc" }, + }); + expect(audit).toHaveLength(2); + expect(audit.map((entry) => entry.actorUserId)).toEqual([ + creator.user.id, + creator.user.id, + ]); + expect(audit.map((entry) => JSON.parse(entry.details ?? "{}"))).toEqual([ + { grantId: invitation.id }, + { grantId: invitation.id }, + ]); + }); + + it("withdraws a pending Guardian invitation without touching the active count and permits re-invitation", async () => { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { POST: inviteGuardian } = await import( + "@/app/api/managed-profiles/[id]/guardians/route" + ); + const { DELETE: revokeGuardian } = await import( + "@/app/api/managed-profiles/[id]/guardians/[grantId]/route" + ); + const invite = async () => { + const response = await inviteGuardian( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}/guardians`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: secondGuardian.user.username }), + }), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(201); + return (await response.json()).data; + }; + + const pending = await invite(); + const withdrawn = await revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${pending.id}`, + { method: "DELETE" }, + ), + { params: Promise.resolve({ id: profile.id, grantId: pending.id }) }, + ); + expect(withdrawn.status).toBe(200); + expect( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: pending.id }, + }), + ).toMatchObject({ acceptedAt: null, revokedAt: expect.any(Date) }); + + const replacement = await invite(); + expect(replacement.id).not.toBe(pending.id); + expect(replacement.acceptedAt).toBeNull(); + }); + + it("refuses a managed profile as a Guardian invitee", async () => { + const creator = await createFreshMfaUser("creator"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { profile: managedInvitee } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Another managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { POST: inviteGuardian } = await import( + "@/app/api/managed-profiles/[id]/guardians/route" + ); + + const response = await inviteGuardian( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}/guardians`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: managedInvitee.username }), + }), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(422); + expect( + await getPrismaClient().accountGrant.count({ where: { grantorId: profile.id } }), + ).toBe(1); + }); + + it("rolls Guardian invitations back when their audit entry fails", async () => { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.invited' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + 'CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()', + ); + const { POST: inviteGuardian } = await import( + "@/app/api/managed-profiles/[id]/guardians/route" + ); + + const response = await inviteGuardian( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}/guardians`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: secondGuardian.user.username }), + }), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(500); + expect( + await prisma.accountGrant.count({ where: { grantorId: profile.id } }), + ).toBe(1); + }); + + it("rolls active Guardian revocation back when its audit entry fails", async () => { + const { creator, profile, secondGuardian, secondGrant } = + await createTwoGuardians(); + cookieJar.set("healthlog_session", creator.sessionId); + await getPrismaClient().session.update({ + where: { id: secondGuardian.sessionId }, + data: { actingAsUserId: profile.id }, + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.revoked' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + 'CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()', + ); + const { DELETE: revokeGuardian } = await import( + "@/app/api/managed-profiles/[id]/guardians/[grantId]/route" + ); + + const response = await revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${secondGrant.id}`, + { method: "DELETE" }, + ), + { params: Promise.resolve({ id: profile.id, grantId: secondGrant.id }) }, + ); + expect(response.status).toBe(500); + expect( + await prisma.accountGrant.findUniqueOrThrow({ where: { id: secondGrant.id } }), + ).toMatchObject({ revokedAt: null }); + expect( + await prisma.session.findUniqueOrThrow({ + where: { id: secondGuardian.sessionId }, + }), + ).toMatchObject({ actingAsUserId: profile.id }); }); it("rolls managed Guardian acceptance back when the grant update fails", async () => { From 389f2a042bfafc9d8425d2cc75dd9cb0961b85e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 03:19:34 +0200 Subject: [PATCH 11/11] fix: complete managed Guardian invitation lifecycle --- .../managed-profiles/[id]/guardians/route.ts | 13 ++- src/lib/managed-profiles/lifecycle.ts | 2 +- src/lib/sharing/grants.ts | 55 +++++++++-- .../managed-profile-lifecycle.test.ts | 93 +++++++++++-------- 4 files changed, 112 insertions(+), 51 deletions(-) diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts index 94bc12433..80b8a13de 100644 --- a/src/app/api/managed-profiles/[id]/guardians/route.ts +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -24,7 +24,10 @@ const INVITE_LIMIT = 10; const INVITE_WINDOW_MS = 60 * 60 * 1000; const guardianInviteSchema = z - .object({ identifier: z.string().trim().min(1).max(320) }) + .object({ + identifier: z.string().trim().min(1).max(255), + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + }) .strict(); /** Invite another Guardian from a cookie-backed, freshly verified session. */ @@ -63,6 +66,9 @@ export const POST = apiHandler( profileId: id, guardianId: user.id, granteeId: invitee.id, + expiresAt: parsed.data.expiresAt + ? new Date(parsed.data.expiresAt) + : null, }); annotate({ action: { name: "managed_profile.guardian.invited" }, @@ -81,6 +87,11 @@ export const POST = apiHandler( }); } if (error instanceof ManagedProfileLifecycleError) { + if (error.code === "managed_grantee") { + return apiError("A managed profile cannot be a Guardian", 422, { + errorCode: "managed_profile.guardian.managed_invitee", + }); + } return apiError("Managed profile not found", 404, { errorCode: "managed_profile.not_found", }); diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 55c544049..8bb929660 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -12,7 +12,7 @@ export const activeGuardianWhere = (now: Date) => ({ }); export type ManagedProfileLifecycleErrorCode = - "not_found" | "not_managed" | "not_guardian"; + "not_found" | "not_managed" | "not_guardian" | "managed_grantee"; export class ManagedProfileLifecycleError extends Error { constructor(readonly code: ManagedProfileLifecycleErrorCode) { diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 0184f0c43..06fdc954a 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -52,6 +52,7 @@ * transition computed against the stale row. */ import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { isP2002 } from "@/lib/prisma-errors"; import { clearActingSessions } from "@/lib/sharing/acting-session"; import { @@ -469,6 +470,7 @@ export async function inviteManagedProfileGuardian(input: { profileId: string; guardianId: string; granteeId: string; + expiresAt?: Date | null; }): Promise { return prisma.$transaction(async (tx) => { return withManagedProfileLock(tx, input.profileId, async (profile) => { @@ -485,16 +487,30 @@ export async function inviteManagedProfileGuardian(input: { select: { id: true }, }); if (!guardian) throw new ManagedProfileLifecycleError("not_guardian"); - return inviteGrant( + const grantee = await tx.user.findUnique({ + where: { id: input.granteeId }, + select: { managedProfileAt: true }, + }); + if (grantee?.managedProfileAt) { + throw new ManagedProfileLifecycleError("managed_grantee"); + } + const grant = await inviteGrant( { grantorId: profile.id, granteeId: input.granteeId, access: "MANAGE", scope: null, - expiresAt: null, + expiresAt: input.expiresAt ?? null, }, tx, ); + await auditLog("managed_profile.guardian.invited", { + userId: profile.id, + actorUserId: input.guardianId, + details: { grantId: grant.id }, + client: tx, + }); + return grant; }); }); } @@ -524,28 +540,49 @@ export async function revokeManagedProfileGuardian(input: { where: { id: input.grantId, grantorId: profile.id, - ...activeGuardianWhere(new Date()), + access: "MANAGE", + revokedAt: null, + }, + select: { + id: true, + granteeId: true, + acceptedAt: true, + expiresAt: true, }, - select: { id: true, granteeId: true }, }); if (!target) throw new ManagedProfileLifecycleError("not_guardian"); if (target.granteeId === input.guardianId) { throw new ManagedProfileLifecycleError("not_guardian"); } - const activeGuardians = await tx.accountGrant.count({ - where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) }, - }); - if (activeGuardians <= 1) throw new LastManagedGuardianError(); + const now = new Date(); + const targetIsActive = + target.acceptedAt !== null && + (target.expiresAt === null || target.expiresAt > now); + if (targetIsActive) { + const activeGuardians = await tx.accountGrant.count({ + where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + }); + if (activeGuardians <= 1) throw new LastManagedGuardianError(); + } await tx.accountGrant.update({ where: { id: target.id }, - data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + // `revokedBy` records which grant party withdrew access. The managed + // profile is the grantor; the individual Guardian is durable audit + // attribution below rather than an overloaded enum value. + data: { revokedAt: now, revokedBy: "GRANTOR" }, }); await clearActingSessions( { grantorId: profile.id, granteeId: target.granteeId }, tx, ); + await auditLog("managed_profile.guardian.revoked", { + userId: profile.id, + actorUserId: input.guardianId, + details: { grantId: target.id }, + client: tx, + }); return tx.accountGrant.findUniqueOrThrow({ where: { id: target.id } }); }); }); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 85dbaad22..39c7219e2 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -144,7 +144,7 @@ afterEach(async () => { "DROP FUNCTION IF EXISTS fail_managed_profile_delete_audit()", ); await prisma.$executeRawUnsafe( - 'DROP TRIGGER IF EXISTS managed_guardian_audit_failure ON audit_logs', + "DROP TRIGGER IF EXISTS managed_guardian_audit_failure ON audit_logs", ); await prisma.$executeRawUnsafe( "DROP FUNCTION IF EXISTS fail_managed_guardian_audit()", @@ -291,10 +291,10 @@ describe("managed profile lifecycle (real Postgres)", () => { { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ - identifier: secondGuardian.user.username, - expiresAt: invitationExpiry.toISOString(), - }), + body: JSON.stringify({ + identifier: secondGuardian.user.username, + expiresAt: invitationExpiry.toISOString(), + }), }, ), { params: Promise.resolve({ id: profile.id }) }, @@ -353,7 +353,12 @@ describe("managed profile lifecycle (real Postgres)", () => { const audit = await getPrismaClient().auditLog.findMany({ where: { userId: profile.id, - action: { in: ["managed_profile.guardian.invited", "managed_profile.guardian.revoked"] }, + action: { + in: [ + "managed_profile.guardian.invited", + "managed_profile.guardian.revoked", + ], + }, }, orderBy: { createdAt: "asc" }, }); @@ -379,19 +384,20 @@ describe("managed profile lifecycle (real Postgres)", () => { locale: "en", timezone: "UTC", }); - const { POST: inviteGuardian } = await import( - "@/app/api/managed-profiles/[id]/guardians/route" - ); - const { DELETE: revokeGuardian } = await import( - "@/app/api/managed-profiles/[id]/guardians/[grantId]/route" - ); + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); const invite = async () => { const response = await inviteGuardian( - new NextRequest(`http://localhost/api/managed-profiles/${profile.id}/guardians`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ identifier: secondGuardian.user.username }), - }), + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: secondGuardian.user.username }), + }, + ), { params: Promise.resolve({ id: profile.id }) }, ); expect(response.status).toBe(201); @@ -435,21 +441,25 @@ describe("managed profile lifecycle (real Postgres)", () => { locale: "en", timezone: "UTC", }); - const { POST: inviteGuardian } = await import( - "@/app/api/managed-profiles/[id]/guardians/route" - ); + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); const response = await inviteGuardian( - new NextRequest(`http://localhost/api/managed-profiles/${profile.id}/guardians`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ identifier: managedInvitee.username }), - }), + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: managedInvitee.username }), + }, + ), { params: Promise.resolve({ id: profile.id }) }, ); expect(response.status).toBe(422); expect( - await getPrismaClient().accountGrant.count({ where: { grantorId: profile.id } }), + await getPrismaClient().accountGrant.count({ + where: { grantorId: profile.id }, + }), ).toBe(1); }); @@ -469,18 +479,20 @@ describe("managed profile lifecycle (real Postgres)", () => { "CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.invited' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$", ); await prisma.$executeRawUnsafe( - 'CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()', - ); - const { POST: inviteGuardian } = await import( - "@/app/api/managed-profiles/[id]/guardians/route" + "CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()", ); + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); const response = await inviteGuardian( - new NextRequest(`http://localhost/api/managed-profiles/${profile.id}/guardians`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ identifier: secondGuardian.user.username }), - }), + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: secondGuardian.user.username }), + }, + ), { params: Promise.resolve({ id: profile.id }) }, ); expect(response.status).toBe(500); @@ -502,11 +514,10 @@ describe("managed profile lifecycle (real Postgres)", () => { "CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.revoked' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$", ); await prisma.$executeRawUnsafe( - 'CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()', - ); - const { DELETE: revokeGuardian } = await import( - "@/app/api/managed-profiles/[id]/guardians/[grantId]/route" + "CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()", ); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); const response = await revokeGuardian( new NextRequest( @@ -517,7 +528,9 @@ describe("managed profile lifecycle (real Postgres)", () => { ); expect(response.status).toBe(500); expect( - await prisma.accountGrant.findUniqueOrThrow({ where: { id: secondGrant.id } }), + await prisma.accountGrant.findUniqueOrThrow({ + where: { id: secondGrant.id }, + }), ).toMatchObject({ revokedAt: null }); expect( await prisma.session.findUniqueOrThrow({