fix: complete managed Guardian lifecycle

Use locked Guardian membership and transactional lifecycle audits.\n\nKeep acceptance and marker changes atomic.
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:05:19 +02:00
1 parent 55291ad32d
commit ffe3225639
8 files changed
+299 -59

No files matched your search

@@ -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;
}
},
);
@@ -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;
}
},
);
@@ -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 },
+9 -9
View File
@@ -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 } });