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); }); });