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] 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({