fix: complete managed Guardian invitation lifecycle

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:19:34 +02:00
1 parent 493437207e
commit 389f2a042b
4 files changed
+112 -51

No files matched your search

@@ -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",
});
+1 -1
View File
@@ -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) {
+46 -9
View File
@@ -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<AccountGrant> {
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 } });
});
});
@@ -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({