diff --git a/prisma/migrations/0297_managed_profile/migration.sql b/prisma/migrations/0297_managed_profile/migration.sql index d7d309e46..83b0a252e 100644 --- a/prisma/migrations/0297_managed_profile/migration.sql +++ b/prisma/migrations/0297_managed_profile/migration.sql @@ -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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 651897559..7dbbcdd21 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts new file mode 100644 index 000000000..964a6e0bf --- /dev/null +++ b/src/app/api/managed-profiles/route.ts @@ -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, + ); +}); diff --git a/src/lib/managed-profiles/create.ts b/src/lib/managed-profiles/create.ts new file mode 100644 index 000000000..431e36373 --- /dev/null +++ b/src/lib/managed-profiles/create.ts @@ -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 { + 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(); +} diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 564412b04..ad3071d87 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -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(