feat(auth): block protected account deletion in auth endpoints

This commit is contained in:
2026-02-10 18:47:52 +01:00
parent 29a6e38ff3
commit 0e2248b5c7
4 changed files with 114 additions and 2 deletions

View File

@@ -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

View File

@@ -1,5 +1,7 @@
import {
auth,
authRouteHandlers,
canDeleteUserAccount,
canUserSelfRegister,
ensureSupportUserBootstrap,
ensureUserUsername,
@@ -40,6 +42,51 @@ function buildJsonRequest(request: Request, body: Record<string, unknown>): 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<Response | null> {
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<Response> {
await ensureSupportUserBootstrap()
@@ -136,6 +183,13 @@ async function handleSignUpPost(request: Request): Promise<Response> {
export async function GET(request: Request): Promise<Response> {
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<Response> {
}
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.POST(request)
}
export async function PATCH(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.PATCH(request)
}
export async function PUT(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.PUT(request)
}
export async function DELETE(request: Request): Promise<Response> {
await ensureSupportUserBootstrap()
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
if (deletionGuardResponse) {
return deletionGuardResponse
}
return authRouteHandlers.DELETE(request)
}

View File

@@ -452,6 +452,34 @@ export async function enforceOwnerInvariant(): Promise<OwnerInvariantState> {
})
}
export async function canDeleteUserAccount(userId: string): Promise<boolean> {
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<boolean> {
const promoted = await db.$transaction(async (tx) => {
const existingOwner = await tx.user.findFirst({

View File

@@ -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.