From 0e2248b5c7f72684e4db6d4ab8f306b10f50ac66 Mon Sep 17 00:00:00 2001 From: Citali Date: Tue, 10 Feb 2026 18:47:52 +0100 Subject: [PATCH] feat(auth): block protected account deletion in auth endpoints --- TODO.md | 3 +- apps/admin/src/app/api/auth/[...all]/route.ts | 82 +++++++++++++++++++ apps/admin/src/lib/auth/server.ts | 28 +++++++ docs/product-engineering/auth-baseline.md | 3 +- 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index b644724..e56a3e9 100644 --- a/TODO.md +++ b/TODO.md @@ -115,7 +115,7 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability) - [ ] [P1] Users management (invite, roles, status) - [ ] [P1] Disable/ban user function and enforcement in auth/session checks -- [ ] [P1] Owner/support protection rules in user management actions (cannot delete/demote) +- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote) - [ ] [P1] Commissions management (request intake, owner, due date, notes) - [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done) - [ ] [P1] Header banner management (message, CTA, active window) @@ -191,6 +191,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] Linux Playwright runtime depends on host packages; browser setup may require `playwright install --with-deps`. - [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`. - [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers. +- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented. ## How We Use This File diff --git a/apps/admin/src/app/api/auth/[...all]/route.ts b/apps/admin/src/app/api/auth/[...all]/route.ts index 4063db7..f16e674 100644 --- a/apps/admin/src/app/api/auth/[...all]/route.ts +++ b/apps/admin/src/app/api/auth/[...all]/route.ts @@ -1,5 +1,7 @@ import { + auth, authRouteHandlers, + canDeleteUserAccount, canUserSelfRegister, ensureSupportUserBootstrap, ensureUserUsername, @@ -40,6 +42,51 @@ function buildJsonRequest(request: Request, body: Record): Requ }) } +function isDeleteUserAuthPath(pathname: string): boolean { + const actionPrefix = "/api/auth/" + const actionIndex = pathname.indexOf(actionPrefix) + + if (actionIndex === -1) { + return false + } + + const actionPath = pathname.slice(actionIndex + actionPrefix.length) + return actionPath === "delete-user" || actionPath.startsWith("delete-user/") +} + +async function guardProtectedAccountDeletion(request: Request): Promise { + const pathname = new URL(request.url).pathname + + if (!isDeleteUserAuthPath(pathname)) { + return null + } + + const session = await auth.api + .getSession({ + headers: request.headers, + }) + .catch(() => null) + + const userId = session?.user?.id + + if (!userId) { + return null + } + + const allowed = await canDeleteUserAccount(userId) + + if (allowed) { + return null + } + + return jsonResponse( + { + message: "This account is protected and cannot be deleted.", + }, + 403, + ) +} + async function handleSignInPost(request: Request): Promise { await ensureSupportUserBootstrap() @@ -136,6 +183,13 @@ async function handleSignUpPost(request: Request): Promise { export async function GET(request: Request): Promise { await ensureSupportUserBootstrap() + + const deletionGuardResponse = await guardProtectedAccountDeletion(request) + + if (deletionGuardResponse) { + return deletionGuardResponse + } + return authRouteHandlers.GET(request) } @@ -151,20 +205,48 @@ export async function POST(request: Request): Promise { } await ensureSupportUserBootstrap() + + const deletionGuardResponse = await guardProtectedAccountDeletion(request) + + if (deletionGuardResponse) { + return deletionGuardResponse + } + return authRouteHandlers.POST(request) } export async function PATCH(request: Request): Promise { await ensureSupportUserBootstrap() + + const deletionGuardResponse = await guardProtectedAccountDeletion(request) + + if (deletionGuardResponse) { + return deletionGuardResponse + } + return authRouteHandlers.PATCH(request) } export async function PUT(request: Request): Promise { await ensureSupportUserBootstrap() + + const deletionGuardResponse = await guardProtectedAccountDeletion(request) + + if (deletionGuardResponse) { + return deletionGuardResponse + } + return authRouteHandlers.PUT(request) } export async function DELETE(request: Request): Promise { await ensureSupportUserBootstrap() + + const deletionGuardResponse = await guardProtectedAccountDeletion(request) + + if (deletionGuardResponse) { + return deletionGuardResponse + } + return authRouteHandlers.DELETE(request) } diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts index 3981f2c..e12fb11 100644 --- a/apps/admin/src/lib/auth/server.ts +++ b/apps/admin/src/lib/auth/server.ts @@ -452,6 +452,34 @@ export async function enforceOwnerInvariant(): Promise { }) } +export async function canDeleteUserAccount(userId: string): Promise { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { role: true, isProtected: true }, + }) + + if (!user) { + return false + } + + // Protected/system users (support + canonical owner) are never deletable + // through self-service endpoints. + if (user.isProtected) { + return false + } + + if (user.role !== "owner") { + return true + } + + // Defensive fallback for drifted data; normal flow should already keep one owner. + const ownerCount = await db.user.count({ + where: { role: "owner" }, + }) + + return ownerCount > 1 +} + export async function promoteFirstRegisteredUserToOwner(userId: string): Promise { const promoted = await db.$transaction(async (tx) => { const existingOwner = await tx.user.findFirst({ diff --git a/docs/product-engineering/auth-baseline.md b/docs/product-engineering/auth-baseline.md index 0c539a4..d604b2b 100644 --- a/docs/product-engineering/auth-baseline.md +++ b/docs/product-engineering/auth-baseline.md @@ -13,6 +13,7 @@ Implemented in MVP0: - Prisma auth models (`user`, `session`, `account`, `verification`) - First registration creates owner; subsequent registrations are disabled - Owner invariant reconciliation is enforced in auth bootstrap and owner promotion flow +- Protected accounts (support + canonical owner) are blocked from delete-account auth endpoints ## Environment @@ -39,5 +40,5 @@ Optional: - Support user bootstrap is available via `bun run auth:seed:support`. - Root `bun run db:seed` runs DB seed and support-user seed. - `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is temporary until admin settings UI manages this policy. -- Owner invariant checks for future user-management mutations remain tracked in `TODO.md`. +- Owner/support checks for future admin user-management mutations remain tracked in `TODO.md`. - Email verification and forgot/reset password pipelines are tracked for MVP2.