mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user