feat(auth): bootstrap protected support and first owner users
This commit is contained in:
@@ -4,5 +4,12 @@ BETTER_AUTH_URL="http://localhost:3001"
|
|||||||
CMS_ADMIN_ORIGIN="http://localhost:3001"
|
CMS_ADMIN_ORIGIN="http://localhost:3001"
|
||||||
CMS_WEB_ORIGIN="http://localhost:3000"
|
CMS_WEB_ORIGIN="http://localhost:3000"
|
||||||
CMS_ADMIN_REGISTRATION_ENABLED="true"
|
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.
|
# Optional dev bypass role for admin middleware. Leave empty to require auth login.
|
||||||
# CMS_DEV_ROLE="admin"
|
# CMS_DEV_ROLE="admin"
|
||||||
|
|||||||
4
TODO.md
4
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] i18n runtime integration baseline for both apps (locale provider + message loading)
|
||||||
- [ ] [P1] Locale persistence and switcher base component (cookie/header + UI)
|
- [ ] [P1] Locale persistence and switcher base component (cookie/header + UI)
|
||||||
- [x] [P1] Integrate Better Auth core configuration and session wiring
|
- [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] 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] Admin registration policy control (allow/deny self-registration for admin panel)
|
||||||
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
||||||
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
import { authRouteHandlers } from "@/lib/auth/server"
|
import { authRouteHandlers, ensureAuthBootstrap } from "@/lib/auth/server"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
export const { GET, POST, PATCH, PUT, DELETE } = authRouteHandlers
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
await ensureAuthBootstrap()
|
||||||
|
return authRouteHandlers.GET(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
await ensureAuthBootstrap()
|
||||||
|
return authRouteHandlers.POST(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request): Promise<Response> {
|
||||||
|
await ensureAuthBootstrap()
|
||||||
|
return authRouteHandlers.PATCH(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request): Promise<Response> {
|
||||||
|
await ensureAuthBootstrap()
|
||||||
|
return authRouteHandlers.PUT(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request): Promise<Response> {
|
||||||
|
await ensureAuthBootstrap()
|
||||||
|
return authRouteHandlers.DELETE(request)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ const isProduction = process.env.NODE_ENV === "production"
|
|||||||
|
|
||||||
const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001"
|
const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001"
|
||||||
const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
|
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 {
|
function resolveAuthSecret(): string {
|
||||||
const value = process.env.BETTER_AUTH_SECRET
|
const value = process.env.BETTER_AUTH_SECRET
|
||||||
@@ -41,6 +47,26 @@ export function isAdminRegistrationEnabled(): boolean {
|
|||||||
return !isProduction
|
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({
|
export const auth = betterAuth({
|
||||||
appName: "CMS Admin",
|
appName: "CMS Admin",
|
||||||
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
|
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
|
||||||
@@ -67,6 +93,24 @@ export const auth = betterAuth({
|
|||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
input: 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
|
export type AuthSession = typeof auth.$Infer.Session
|
||||||
|
|
||||||
|
let bootstrapPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
type BootstrapUserConfig = {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
role: Role
|
||||||
|
isHidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 {
|
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
|
||||||
const sessionUserRole = session?.user?.role
|
const sessionUserRole = session?.user?.role
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import { hasPermission, normalizeRole, permissionMatrix } from "./rbac"
|
|||||||
|
|
||||||
describe("rbac model", () => {
|
describe("rbac model", () => {
|
||||||
it("normalizes valid roles", () => {
|
it("normalizes valid roles", () => {
|
||||||
|
expect(normalizeRole("OWNER")).toBe("owner")
|
||||||
|
expect(normalizeRole("support")).toBe("support")
|
||||||
expect(normalizeRole("ADMIN")).toBe("admin")
|
expect(normalizeRole("ADMIN")).toBe("admin")
|
||||||
expect(normalizeRole("manager")).toBe("manager")
|
expect(normalizeRole("manager")).toBe("manager")
|
||||||
expect(normalizeRole("unknown")).toBeNull()
|
expect(normalizeRole("unknown")).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("grants admin full access", () => {
|
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", "users:manage_roles", "global")).toBe(true)
|
||||||
expect(hasPermission("admin", "news:publish", "global")).toBe(true)
|
expect(hasPermission("admin", "news:publish", "global")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod"
|
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 permissionScopeSchema = z.enum(["own", "team", "global"])
|
||||||
|
|
||||||
export const permissionSchema = z.enum([
|
export const permissionSchema = z.enum([
|
||||||
@@ -44,6 +44,8 @@ const allGlobalGrants: PermissionGrant[] = allPermissions.map((permission) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
export const permissionMatrix: Record<Role, PermissionGrant[]> = {
|
export const permissionMatrix: Record<Role, PermissionGrant[]> = {
|
||||||
|
owner: allGlobalGrants,
|
||||||
|
support: allGlobalGrants,
|
||||||
admin: allGlobalGrants,
|
admin: allGlobalGrants,
|
||||||
manager: [
|
manager: [
|
||||||
{ permission: "dashboard:read", scopes: ["global"] },
|
{ permission: "dashboard:read", scopes: ["global"] },
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -28,10 +28,14 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
role String @default("editor")
|
role String @default("editor")
|
||||||
isBanned Boolean @default(false)
|
isBanned Boolean @default(false)
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
isHidden Boolean @default(false)
|
||||||
|
isProtected Boolean @default(false)
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
|
@@index([role])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user