mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
55291ad32d
commit
ffe3225639
8 files changed
+299
-59
No files matched your search
@@ -69,9 +69,9 @@ model User {
|
||||
/// separately designed handover ever needs that state.
|
||||
///
|
||||
/// Consumers land in this release: the guardian resolver in
|
||||
/// `src/lib/api-handler.ts` reads it on every guardian request, the
|
||||
/// notification dispatcher fans reminders out to the guardians' channels on
|
||||
/// it, and the creation flow is what writes it.
|
||||
/// `src/lib/api-handler.ts` reads it on every guardian request, and the
|
||||
/// creation flow is what writes it. A later notification plan may add
|
||||
/// delivery fanout after it defines consent and preference semantics.
|
||||
managedProfileAt DateTime? @map("managed_profile_at")
|
||||
|
||||
// Profile
|
||||
|
||||
@@ -42,6 +42,16 @@ describe("managed-profile contract", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not claim notification fanout before its consent plan exists", async () => {
|
||||
const source = await readFile(
|
||||
path.join(root, "prisma/schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toContain("notification dispatcher fans reminders");
|
||||
expect(source).toContain("later notification plan");
|
||||
});
|
||||
|
||||
it("uses the same record lock for every Guardian-reducing transition", async () => {
|
||||
const lifecycle = await readFile(
|
||||
path.join(root, "src/lib/managed-profiles/lifecycle.ts"),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type Transaction = Prisma.TransactionClient;
|
||||
|
||||
const ACTIVE_GUARDIAN = (now: Date) => ({
|
||||
export const activeGuardianWhere = (now: Date) => ({
|
||||
access: "MANAGE" as const,
|
||||
acceptedAt: { not: null },
|
||||
revokedAt: null,
|
||||
@@ -65,7 +66,7 @@ export async function reduceManagedProfileGuardian<T>(
|
||||
if (!profile?.managedProfileAt) return reduction();
|
||||
|
||||
const activeGuardians = await tx.accountGrant.count({
|
||||
where: { grantorId: profile.id, ...ACTIVE_GUARDIAN(new Date()) },
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) },
|
||||
});
|
||||
if (activeGuardians <= 1) throw new LastManagedGuardianError();
|
||||
|
||||
@@ -87,7 +88,7 @@ export async function deleteGuardianAccountWithLifecycle<T>(
|
||||
const grants = await tx.accountGrant.findMany({
|
||||
where: {
|
||||
granteeId: guardianId,
|
||||
...ACTIVE_GUARDIAN(now),
|
||||
...activeGuardianWhere(now),
|
||||
grantor: { managedProfileAt: { not: null } },
|
||||
},
|
||||
select: { grantorId: true },
|
||||
@@ -103,14 +104,14 @@ export async function deleteGuardianAccountWithLifecycle<T>(
|
||||
where: {
|
||||
grantorId,
|
||||
granteeId: guardianId,
|
||||
...ACTIVE_GUARDIAN(new Date()),
|
||||
...activeGuardianWhere(new Date()),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!stillActive) return;
|
||||
|
||||
const activeGuardians = await tx.accountGrant.count({
|
||||
where: { grantorId, ...ACTIVE_GUARDIAN(new Date()) },
|
||||
where: { grantorId, ...activeGuardianWhere(new Date()) },
|
||||
});
|
||||
if (activeGuardians <= 1) throw new LastManagedGuardianError();
|
||||
});
|
||||
@@ -135,12 +136,17 @@ export async function deleteManagedProfile(input: {
|
||||
where: {
|
||||
grantorId: profile.id,
|
||||
granteeId: input.guardianId,
|
||||
...ACTIVE_GUARDIAN(new Date()),
|
||||
...activeGuardianWhere(new Date()),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guardian) throw new ManagedProfileLifecycleError("not_guardian");
|
||||
|
||||
await auditLog("managed_profile.deleted", {
|
||||
userId: input.guardianId,
|
||||
details: { profileId: profile.id },
|
||||
client: tx,
|
||||
});
|
||||
await tx.user.delete({ where: { id: profile.id } });
|
||||
});
|
||||
});
|
||||
|
||||
+125
-36
@@ -54,7 +54,13 @@
|
||||
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 {
|
||||
activeGuardianWhere,
|
||||
LastManagedGuardianError,
|
||||
ManagedProfileLifecycleError,
|
||||
reduceManagedProfileGuardian,
|
||||
withManagedProfileLock,
|
||||
} 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";
|
||||
@@ -415,47 +421,130 @@ export interface AcceptGrantInput {
|
||||
*/
|
||||
export async function acceptGrant(
|
||||
input: AcceptGrantInput,
|
||||
db: GrantDb = prisma,
|
||||
): Promise<AccountGrant> {
|
||||
const now = new Date();
|
||||
const { count } = await db.accountGrant.updateMany({
|
||||
where: {
|
||||
id: input.grantId,
|
||||
granteeId: input.granteeId,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
data: { acceptedAt: now },
|
||||
});
|
||||
return prisma.$transaction((tx) => acceptGrantInTransaction(input, tx));
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
throw await refusalFor(input.grantId, { granteeId: input.granteeId }, db);
|
||||
}
|
||||
|
||||
const accepted = await db.accountGrant.findUniqueOrThrow({
|
||||
async function acceptGrantInTransaction(
|
||||
input: AcceptGrantInput,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<AccountGrant> {
|
||||
const candidate = await tx.accountGrant.findUnique({
|
||||
where: { id: input.grantId },
|
||||
include: { grantor: { select: { managedProfileAt: true } } },
|
||||
select: { grantorId: true, access: true },
|
||||
});
|
||||
|
||||
// The expiry belongs to the invitation, not to a Guardian relationship. A
|
||||
// managed profile still lets its creator set an expiry while the invitation
|
||||
// waits for the other adult's consent, but once accepted the MANAGE grant is
|
||||
// standing until a Guardian explicitly ends it. Ordinary adult-record grants
|
||||
// retain their requested expiry unchanged.
|
||||
if (accepted.access === "MANAGE" && accepted.grantor.managedProfileAt) {
|
||||
await db.accountGrant.updateMany({
|
||||
where: {
|
||||
id: accepted.id,
|
||||
granteeId: input.granteeId,
|
||||
acceptedAt: { not: null },
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { expiresAt: null },
|
||||
});
|
||||
if (!candidate) {
|
||||
throw await refusalFor(input.grantId, { granteeId: input.granteeId }, tx);
|
||||
}
|
||||
|
||||
return db.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } });
|
||||
const accept = async (managedProfile: boolean) => {
|
||||
const now = new Date();
|
||||
const { count } = await tx.accountGrant.updateMany({
|
||||
where: {
|
||||
id: input.grantId,
|
||||
granteeId: input.granteeId,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
data: {
|
||||
acceptedAt: now,
|
||||
...(managedProfile ? { expiresAt: null } : {}),
|
||||
},
|
||||
});
|
||||
if (count === 0) {
|
||||
throw await refusalFor(input.grantId, { granteeId: input.granteeId }, tx);
|
||||
}
|
||||
return tx.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } });
|
||||
};
|
||||
|
||||
if (candidate.access !== "MANAGE") return accept(false);
|
||||
|
||||
return withManagedProfileLock(tx, candidate.grantorId, async (profile) =>
|
||||
accept(Boolean(profile?.managedProfileAt)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function inviteManagedProfileGuardian(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
granteeId: string;
|
||||
}): Promise<AccountGrant> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return 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,
|
||||
...activeGuardianWhere(new Date()),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guardian) throw new ManagedProfileLifecycleError("not_guardian");
|
||||
return inviteGrant(
|
||||
{
|
||||
grantorId: profile.id,
|
||||
granteeId: input.granteeId,
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
expiresAt: null,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeManagedProfileGuardian(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
grantId: string;
|
||||
}): Promise<AccountGrant> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return withManagedProfileLock(tx, input.profileId, async (profile) => {
|
||||
if (!profile) throw new ManagedProfileLifecycleError("not_found");
|
||||
if (!profile.managedProfileAt) {
|
||||
throw new ManagedProfileLifecycleError("not_managed");
|
||||
}
|
||||
const requester = await tx.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: profile.id,
|
||||
granteeId: input.guardianId,
|
||||
...activeGuardianWhere(new Date()),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!requester) throw new ManagedProfileLifecycleError("not_guardian");
|
||||
|
||||
const target = await tx.accountGrant.findFirst({
|
||||
where: {
|
||||
id: input.grantId,
|
||||
grantorId: profile.id,
|
||||
...activeGuardianWhere(new Date()),
|
||||
},
|
||||
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();
|
||||
|
||||
await tx.accountGrant.update({
|
||||
where: { id: target.id },
|
||||
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
|
||||
});
|
||||
return tx.accountGrant.findUniqueOrThrow({ where: { id: target.id } });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface RevokeGrantInput {
|
||||
|
||||
Reference in new issue
Block a user