mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix: complete managed Guardian invitation lifecycle
This commit is contained in:
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",
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user