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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 02:50:30 +02:00
1 parent 5059bc9b69
commit 523ab3220b
8 files changed
+318 -31

No files matched your search

@@ -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"),
@@ -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;
}
},
+6
View File
@@ -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;
}
},
@@ -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 });
},
);
+28 -11
View File
@@ -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" },
+167
View File
@@ -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<T>(
tx: Transaction,
profileId: string,
operation: (
profile: { id: string; managedProfileAt: Date | null } | null,
) => Promise<T>,
): Promise<T> {
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<T>(
tx: Transaction,
profileId: string,
reduction: () => Promise<T>,
): Promise<T> {
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<T>(
guardianId: string,
deleteAccount: (tx: Transaction) => Promise<T>,
): Promise<T> {
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<void> {
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<void> {
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 },
});
});
});
}
+45 -12
View File
@@ -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<AccountGrant> {
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<AccountGrant> {
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<EndedGrant> {
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<EndedGrant> {
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<AccountGrant> {
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);
}
/**
@@ -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);
});
});