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 {