feat(rbac): enforce admin access checks and document permission model

This commit is contained in:
2026-02-10 12:16:36 +01:00
parent 4041a4ac4a
commit 947cb0a3d7
13 changed files with 458 additions and 8 deletions

View File

@ -1,10 +1,21 @@
import { hasPermission } from "@cms/content/rbac"
import { listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import Link from "next/link"
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access"
export const dynamic = "force-dynamic"
export default async function AdminHomePage() {
const role = await resolveRoleFromServerContext()
if (!role || !hasPermission(role, "news:read", "team")) {
redirect("/unauthorized?required=news:read&scope=team")
}
const canCreatePost = hasPermission(role, "news:write", "team")
const posts = await listPosts()
return (
@ -26,7 +37,7 @@ export default async function AdminHomePage() {
<section className="rounded-xl border border-neutral-200 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-medium">Posts</h2>
<Button>Create post</Button>
<Button disabled={!canCreatePost}>Create post</Button>
</div>
<div className="space-y-3">

View File

@ -1,6 +1,10 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { hasPermission } from "@cms/content/rbac"
import Link from "next/link"
import { redirect } from "next/navigation"
import { resolveRoleFromServerContext } from "@/lib/access"
export const dynamic = "force-dynamic"
@ -401,6 +405,12 @@ function filterButtonClass(active: boolean): string {
export default async function AdminTodoPage(props: {
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
}) {
const role = await resolveRoleFromServerContext()
if (!role || !hasPermission(role, "roadmap:read", "global")) {
redirect("/unauthorized?required=roadmap:read&scope=global")
}
const content = await getTodoMarkdown()
const sections = parseTodo(content)
const progress = getProgressCounts(sections)

View File

@ -0,0 +1,57 @@
import Link from "next/link"
type SearchParams = Promise<Record<string, string | string[] | undefined>>
function getSingleValue(input: string | string[] | undefined): string | undefined {
if (Array.isArray(input)) {
return input[0]
}
return input
}
export default async function UnauthorizedPage({ searchParams }: { searchParams: SearchParams }) {
const params = await searchParams
const required = getSingleValue(params.required)
const scope = getSingleValue(params.scope)
const reason = getSingleValue(params.reason)
return (
<main className="mx-auto flex min-h-screen w-full max-w-xl flex-col gap-6 px-6 py-20">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
<h1 className="text-4xl font-semibold tracking-tight">Access denied</h1>
<p className="text-neutral-600">
You do not have the required role/permission for this admin route.
</p>
</header>
<section className="rounded-xl border border-neutral-200 p-5">
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="text-neutral-500">Reason</dt>
<dd className="font-medium text-neutral-800">{reason ?? "insufficient-permission"}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-neutral-500">Required permission</dt>
<dd className="font-medium text-neutral-800">{required ?? "n/a"}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-neutral-500">Required scope</dt>
<dd className="font-medium text-neutral-800">{scope ?? "n/a"}</dd>
</div>
</dl>
</section>
<Link
href="/"
className="inline-flex w-fit rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
Back to dashboard
</Link>
</main>
)
}

View File

@ -0,0 +1,105 @@
import { hasPermission, normalizeRole, type PermissionScope, type Role } from "@cms/content/rbac"
import { cookies, headers } from "next/headers"
import type { NextRequest } from "next/server"
type RoutePermission = {
permission: Parameters<typeof hasPermission>[1]
scope: PermissionScope
}
type GuardRule = {
route: RegExp
requirement: RoutePermission | null
}
const guardRules: GuardRule[] = [
{
route: /^\/unauthorized(?:\/|$)/,
requirement: null,
},
{
route: /^\/todo(?:\/|$)/,
requirement: {
permission: "roadmap:read",
scope: "global",
},
},
{
route: /^\/(?:$|\?)/,
requirement: {
permission: "dashboard:read",
scope: "global",
},
},
]
function resolveDefaultRole(): Role | null {
if (process.env.NODE_ENV === "production") {
return null
}
return normalizeRole(process.env.CMS_DEV_ROLE ?? "admin")
}
function resolveRoleFromRawValue(raw: string | null | undefined): Role | null {
return normalizeRole(raw)
}
export function resolveRoleFromRequest(request: NextRequest): Role | null {
const roleFromCookie = request.cookies.get("cms_role")?.value
const roleFromHeader = request.headers.get("x-cms-role")
const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader)
if (resolved) {
return resolved
}
return resolveDefaultRole()
}
export async function resolveRoleFromServerContext(): Promise<Role | null> {
const cookieStore = await cookies()
const headerStore = await headers()
const roleFromCookie = cookieStore.get("cms_role")?.value
const roleFromHeader = headerStore.get("x-cms-role")
const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader)
if (resolved) {
return resolved
}
return resolveDefaultRole()
}
export function getRequiredPermission(pathname: string): RoutePermission {
for (const rule of guardRules) {
if (rule.route.test(pathname)) {
return (
rule.requirement ?? {
permission: "dashboard:read",
scope: "global",
}
)
}
}
return {
permission: "dashboard:read",
scope: "global",
}
}
export function canAccessRoute(role: Role, pathname: string): boolean {
const rule = guardRules.find((item) => item.route.test(pathname))
if (rule && rule.requirement === null) {
return true
}
const requirement = getRequiredPermission(pathname)
return hasPermission(role, requirement.permission, requirement.scope)
}

View File

@ -0,0 +1,37 @@
import { type NextRequest, NextResponse } from "next/server"
import { canAccessRoute, getRequiredPermission, resolveRoleFromRequest } from "@/lib/access"
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const role = resolveRoleFromRequest(request)
if (!role) {
const unauthorizedUrl = request.nextUrl.clone()
unauthorizedUrl.pathname = "/unauthorized"
unauthorizedUrl.searchParams.set("reason", "missing-role")
return NextResponse.redirect(unauthorizedUrl)
}
if (!canAccessRoute(role, pathname)) {
const unauthorizedUrl = request.nextUrl.clone()
unauthorizedUrl.pathname = "/unauthorized"
const required = getRequiredPermission(pathname)
unauthorizedUrl.searchParams.set("required", required.permission)
unauthorizedUrl.searchParams.set("scope", required.scope)
return NextResponse.redirect(unauthorizedUrl)
}
const response = NextResponse.next()
response.headers.set("x-cms-role", role)
return response
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}