mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat: create managed profiles atomically
- Require a fresh cookie MFA proof before creation\n- Create the credential-less profile and initial Guardian grant together
This commit is contained in:
1 parent
51cbbae17f
commit
58434a2b44
5 files changed
+171
-11
No files matched your search
@@ -16,11 +16,10 @@
|
||||
--
|
||||
-- A timestamp rather than a boolean, deliberately. It says since when, which
|
||||
-- the activity trail needs to be readable across the transition, and clearing
|
||||
-- it is the emancipation act: the day the person takes the record over,
|
||||
-- credentials attach (`email` and `password_hash` are already nullable, so
|
||||
-- nothing here has to change for that) and this goes back to NULL. The grants
|
||||
-- survive untouched — the handover changes who holds the revoke button, not
|
||||
-- which rows exist.
|
||||
-- it leaves a future-compatible NULL state. This migration deliberately adds
|
||||
-- no transition route or credential attachment flow. If a later handover is
|
||||
-- designed, `email` and `password_hash` are already nullable and the grants
|
||||
-- can survive untouched while the product defines who holds the revoke button.
|
||||
--
|
||||
-- No index. The column is read by primary key, on a row the request already
|
||||
-- loaded, and the one enumeration that scans for marked rows (the reminder
|
||||
|
||||
@@ -62,11 +62,11 @@ model User {
|
||||
/// former and stay shut for the latter, and the gate is this column rather
|
||||
/// than the grant, so no argument about a grant level can reach them.
|
||||
///
|
||||
/// A timestamp rather than a boolean, for a reason the schema has to keep
|
||||
/// possible: it says since when, and clearing it is the emancipation act —
|
||||
/// the day the person takes their own record over, credentials attach and
|
||||
/// this goes back to NULL, with the grants and the whole activity trail
|
||||
/// spanning the transition untouched.
|
||||
/// A timestamp rather than a boolean: it says since when, and preserves a
|
||||
/// future-compatible null state without adding a transition surface here.
|
||||
/// This release does not expose a way to clear the marker or attach
|
||||
/// credentials; the grants and activity trail remain intact if a later,
|
||||
/// 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
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
requireFreshMfa,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
const managedProfileSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
dateOfBirth: z.iso.date().nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Create a record a Guardian administers. This deliberately uses the
|
||||
* cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less
|
||||
* person and persistent management relationship.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = managedProfileSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const { profile, creatorGrant } = await createManagedProfile({
|
||||
creatorId: user.id,
|
||||
displayName: parsed.data.displayName,
|
||||
dateOfBirth: parsed.data.dateOfBirth
|
||||
? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`)
|
||||
: null,
|
||||
locale: parsed.data.locale,
|
||||
timezone: parsed.data.timezone,
|
||||
});
|
||||
|
||||
await auditLog("managed_profile.created", {
|
||||
userId: user.id,
|
||||
details: { profileId: profile.id, grantId: creatorGrant.id },
|
||||
}).catch(() => {});
|
||||
annotate({
|
||||
action: { name: "managed_profile.create" },
|
||||
meta: { profile_id: profile.id },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
id: profile.id,
|
||||
displayName: profile.displayName,
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
locale: profile.locale,
|
||||
timezone: profile.timezone,
|
||||
recordKind: "managed" as const,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { AccountGrant, User } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USERNAME_ATTEMPTS = 4;
|
||||
|
||||
export interface CreateManagedProfileInput {
|
||||
creatorId: string;
|
||||
displayName: string;
|
||||
dateOfBirth: Date | null;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface CreatedManagedProfile {
|
||||
profile: User;
|
||||
creatorGrant: AccountGrant;
|
||||
}
|
||||
|
||||
export class ManagedProfileCreationError extends Error {
|
||||
constructor() {
|
||||
super("Unable to allocate a managed profile identity");
|
||||
this.name = "ManagedProfileCreationError";
|
||||
}
|
||||
}
|
||||
|
||||
function managedUsername(): string {
|
||||
return `managed-${randomBytes(18).toString("hex")}`;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the credential-less record and its first Guardian grant together.
|
||||
*
|
||||
* A profile is never useful before the creator can manage it, and a creator
|
||||
* grant without its record is invalid by definition. Keeping both writes in
|
||||
* one transaction makes a failed grant insert leave no login-less user behind.
|
||||
*/
|
||||
export async function createManagedProfile(
|
||||
input: CreateManagedProfileInput,
|
||||
): Promise<CreatedManagedProfile> {
|
||||
for (let attempt = 0; attempt < USERNAME_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const profile = await tx.user.create({
|
||||
data: {
|
||||
username: managedUsername(),
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
displayName: input.displayName,
|
||||
dateOfBirth: input.dateOfBirth,
|
||||
locale: input.locale,
|
||||
timezone: input.timezone,
|
||||
managedProfileAt: now,
|
||||
},
|
||||
});
|
||||
const creatorGrant = await tx.accountGrant.create({
|
||||
data: {
|
||||
grantorId: profile.id,
|
||||
granteeId: input.creatorId,
|
||||
access: "MANAGE",
|
||||
scopeJson: Prisma.DbNull,
|
||||
invitedAt: now,
|
||||
acceptedAt: now,
|
||||
expiresAt: null,
|
||||
},
|
||||
});
|
||||
return { profile, creatorGrant };
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error) && attempt + 1 < USERNAME_ATTEMPTS) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ManagedProfileCreationError();
|
||||
}
|
||||
@@ -131,7 +131,7 @@ describe("managed profile lifecycle (real Postgres)", () => {
|
||||
expect(profile.dateOfBirth).toBeNull();
|
||||
});
|
||||
|
||||
it("rolls the profile back when creating its Guardian grant fails", async () => {
|
||||
it("creation rolls the profile back when its Guardian grant fails", async () => {
|
||||
await signInWithFreshMfa();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.$executeRawUnsafe(
|
||||
|
||||
Reference in new issue
Block a user