From 411861419f160e3573a71ea67d57af7e0e91de7d Mon Sep 17 00:00:00 2001 From: Citali Date: Tue, 10 Feb 2026 17:50:16 +0100 Subject: [PATCH] feat(auth): bootstrap protected support and first owner users --- .env.example | 7 + TODO.md | 4 +- apps/admin/src/app/api/auth/[...all]/route.ts | 27 ++- apps/admin/src/lib/auth/server.ts | 178 ++++++++++++++++++ packages/content/src/rbac.test.ts | 4 + packages/content/src/rbac.ts | 4 +- .../migration.sql | 8 + packages/db/prisma/schema.prisma | 4 + 8 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 packages/db/prisma/migrations/20260210190000_system_user_guards/migration.sql diff --git a/.env.example b/.env.example index 68e2ecb..b353862 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,12 @@ BETTER_AUTH_URL="http://localhost:3001" CMS_ADMIN_ORIGIN="http://localhost:3001" CMS_WEB_ORIGIN="http://localhost:3000" CMS_ADMIN_REGISTRATION_ENABLED="true" +# Bootstrap system users (used only when creating missing users) +CMS_OWNER_EMAIL="owner@cms.local" +CMS_OWNER_PASSWORD="change-me-owner-password" +CMS_OWNER_NAME="Owner" +CMS_SUPPORT_EMAIL="support@cms.local" +CMS_SUPPORT_PASSWORD="change-me-support-password" +CMS_SUPPORT_NAME="Technical Support" # Optional dev bypass role for admin middleware. Leave empty to require auth login. # CMS_DEV_ROLE="admin" diff --git a/TODO.md b/TODO.md index 84873f2..851ba46 100644 --- a/TODO.md +++ b/TODO.md @@ -25,9 +25,9 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [P1] i18n runtime integration baseline for both apps (locale provider + message loading) - [ ] [P1] Locale persistence and switcher base component (cookie/header + UI) - [x] [P1] Integrate Better Auth core configuration and session wiring -- [ ] [P1] Bootstrap first-run owner account creation when users table is empty +- [x] [P1] Bootstrap first-run owner account creation when users table is empty - [ ] [P1] Enforce invariant: exactly one owner user must always exist -- [ ] [P1] Create hidden technical support user by default (non-demotable, non-deletable) +- [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable) - [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel) - [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) - [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) diff --git a/apps/admin/src/app/api/auth/[...all]/route.ts b/apps/admin/src/app/api/auth/[...all]/route.ts index 2abb2e7..8a530ef 100644 --- a/apps/admin/src/app/api/auth/[...all]/route.ts +++ b/apps/admin/src/app/api/auth/[...all]/route.ts @@ -1,5 +1,28 @@ -import { authRouteHandlers } from "@/lib/auth/server" +import { authRouteHandlers, ensureAuthBootstrap } from "@/lib/auth/server" export const runtime = "nodejs" -export const { GET, POST, PATCH, PUT, DELETE } = authRouteHandlers +export async function GET(request: Request): Promise { + await ensureAuthBootstrap() + return authRouteHandlers.GET(request) +} + +export async function POST(request: Request): Promise { + await ensureAuthBootstrap() + return authRouteHandlers.POST(request) +} + +export async function PATCH(request: Request): Promise { + await ensureAuthBootstrap() + return authRouteHandlers.PATCH(request) +} + +export async function PUT(request: Request): Promise { + await ensureAuthBootstrap() + return authRouteHandlers.PUT(request) +} + +export async function DELETE(request: Request): Promise { + await ensureAuthBootstrap() + return authRouteHandlers.DELETE(request) +} diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts index ad2cc02..62e49de 100644 --- a/apps/admin/src/lib/auth/server.ts +++ b/apps/admin/src/lib/auth/server.ts @@ -12,6 +12,12 @@ const isProduction = process.env.NODE_ENV === "production" const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001" const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" +const DEFAULT_OWNER_EMAIL = "owner@cms.local" +const DEFAULT_OWNER_PASSWORD = "change-me-owner-password" +const DEFAULT_OWNER_NAME = "Owner" +const DEFAULT_SUPPORT_EMAIL = "support@cms.local" +const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password" +const DEFAULT_SUPPORT_NAME = "Technical Support" function resolveAuthSecret(): string { const value = process.env.BETTER_AUTH_SECRET @@ -41,6 +47,26 @@ export function isAdminRegistrationEnabled(): boolean { return !isProduction } +function resolveBootstrapValue( + envKey: string, + fallback: string, + options: { + requiredInProduction?: boolean + } = {}, +): string { + const value = process.env[envKey] + + if (value) { + return value + } + + if (isProduction && options.requiredInProduction) { + throw new Error(`${envKey} is required in production`) + } + + return fallback +} + export const auth = betterAuth({ appName: "CMS Admin", baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin, @@ -67,6 +93,24 @@ export const auth = betterAuth({ defaultValue: false, input: false, }, + isSystem: { + type: "boolean", + required: true, + defaultValue: false, + input: false, + }, + isHidden: { + type: "boolean", + required: true, + defaultValue: false, + input: false, + }, + isProtected: { + type: "boolean", + required: true, + defaultValue: false, + input: false, + }, }, }, }) @@ -75,6 +119,140 @@ export const authRouteHandlers = toNextJsHandler(auth) export type AuthSession = typeof auth.$Infer.Session +let bootstrapPromise: Promise | null = null + +type BootstrapUserConfig = { + email: string + name: string + password: string + role: Role + isHidden: boolean +} + +async function ensureCredentialUser(config: BootstrapUserConfig): Promise { + const ctx = await auth.$context + const normalizedEmail = config.email.toLowerCase() + const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, { + includeAccounts: true, + }) + + if (existing?.user) { + await db.user.update({ + where: { id: existing.user.id }, + data: { + name: config.name, + role: config.role, + isBanned: false, + isSystem: true, + isHidden: config.isHidden, + isProtected: true, + }, + }) + + const hasCredentialAccount = existing.accounts.some( + (account) => account.providerId === "credential", + ) + + if (!hasCredentialAccount) { + const passwordHash = await ctx.password.hash(config.password) + + await ctx.internalAdapter.linkAccount({ + userId: existing.user.id, + providerId: "credential", + accountId: existing.user.id, + password: passwordHash, + }) + } + + return + } + + const passwordHash = await ctx.password.hash(config.password) + const createdUser = await ctx.internalAdapter.createUser({ + name: config.name, + email: normalizedEmail, + emailVerified: true, + role: config.role, + isBanned: false, + isSystem: true, + isHidden: config.isHidden, + isProtected: true, + }) + + await ctx.internalAdapter.linkAccount({ + userId: createdUser.id, + providerId: "credential", + accountId: createdUser.id, + password: passwordHash, + }) +} + +async function bootstrapSystemUsers(): Promise { + const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", DEFAULT_SUPPORT_EMAIL) + const supportPassword = resolveBootstrapValue("CMS_SUPPORT_PASSWORD", DEFAULT_SUPPORT_PASSWORD, { + requiredInProduction: true, + }) + const supportName = resolveBootstrapValue("CMS_SUPPORT_NAME", DEFAULT_SUPPORT_NAME) + + await ensureCredentialUser({ + email: supportEmail, + name: supportName, + password: supportPassword, + role: "support", + isHidden: true, + }) + + const ownerCount = await db.user.count({ + where: { role: "owner" }, + }) + + if (ownerCount > 0) { + return + } + + const nonSupportUserCount = await db.user.count({ + where: { + role: { + not: "support", + }, + }, + }) + + if (nonSupportUserCount > 0) { + return + } + + const ownerEmail = resolveBootstrapValue("CMS_OWNER_EMAIL", DEFAULT_OWNER_EMAIL) + const ownerPassword = resolveBootstrapValue("CMS_OWNER_PASSWORD", DEFAULT_OWNER_PASSWORD, { + requiredInProduction: true, + }) + const ownerName = resolveBootstrapValue("CMS_OWNER_NAME", DEFAULT_OWNER_NAME) + + await ensureCredentialUser({ + email: ownerEmail, + name: ownerName, + password: ownerPassword, + role: "owner", + isHidden: false, + }) +} + +export async function ensureAuthBootstrap(): Promise { + if (bootstrapPromise) { + await bootstrapPromise + return + } + + bootstrapPromise = bootstrapSystemUsers() + + try { + await bootstrapPromise + } catch (error) { + bootstrapPromise = null + throw error + } +} + export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null { const sessionUserRole = session?.user?.role diff --git a/packages/content/src/rbac.test.ts b/packages/content/src/rbac.test.ts index 3a79911..e534141 100644 --- a/packages/content/src/rbac.test.ts +++ b/packages/content/src/rbac.test.ts @@ -4,12 +4,16 @@ import { hasPermission, normalizeRole, permissionMatrix } from "./rbac" describe("rbac model", () => { it("normalizes valid roles", () => { + expect(normalizeRole("OWNER")).toBe("owner") + expect(normalizeRole("support")).toBe("support") expect(normalizeRole("ADMIN")).toBe("admin") expect(normalizeRole("manager")).toBe("manager") expect(normalizeRole("unknown")).toBeNull() }) it("grants admin full access", () => { + expect(hasPermission("owner", "users:manage_roles", "global")).toBe(true) + expect(hasPermission("support", "news:publish", "global")).toBe(true) expect(hasPermission("admin", "users:manage_roles", "global")).toBe(true) expect(hasPermission("admin", "news:publish", "global")).toBe(true) }) diff --git a/packages/content/src/rbac.ts b/packages/content/src/rbac.ts index cdb2935..3e1660d 100644 --- a/packages/content/src/rbac.ts +++ b/packages/content/src/rbac.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const roleSchema = z.enum(["admin", "editor", "manager"]) +export const roleSchema = z.enum(["owner", "support", "admin", "editor", "manager"]) export const permissionScopeSchema = z.enum(["own", "team", "global"]) export const permissionSchema = z.enum([ @@ -44,6 +44,8 @@ const allGlobalGrants: PermissionGrant[] = allPermissions.map((permission) => ({ })) export const permissionMatrix: Record = { + owner: allGlobalGrants, + support: allGlobalGrants, admin: allGlobalGrants, manager: [ { permission: "dashboard:read", scopes: ["global"] }, diff --git a/packages/db/prisma/migrations/20260210190000_system_user_guards/migration.sql b/packages/db/prisma/migrations/20260210190000_system_user_guards/migration.sql new file mode 100644 index 0000000..e55add0 --- /dev/null +++ b/packages/db/prisma/migrations/20260210190000_system_user_guards/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "user" +ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isHidden" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isProtected" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "user_role_idx" ON "user"("role"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 98c9e5e..2b0352e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -28,10 +28,14 @@ model User { updatedAt DateTime @updatedAt role String @default("editor") isBanned Boolean @default(false) + isSystem Boolean @default(false) + isHidden Boolean @default(false) + isProtected Boolean @default(false) sessions Session[] accounts Account[] @@unique([email]) + @@index([role]) @@map("user") }