第 8 篇:在 blog-demo 中加上 RBAC(让 Prisma 知道“谁能做什么”)

到目前为止,blog-demo 已经有:
登录系统(Auth.js);
当前用户上下文(
auth()/useSession());文章与评论的完整 CRUD(第 6、7 篇)。
但所有登录用户的“权限”是一样的:任何登录用户都能发文、删文、改别人文章——这显然不适合真实项目。
这一篇,我们就给 blog-demo 增加一个简单的 RBAC(基于角色的访问控制)规则:
所有登录用户都是 USER;
可以指定某些用户为 ADMIN;
权限规则:
ADMIN 可以编辑和删除任何文章;
USER 只能编辑和删除“自己写的文章”;
未登录用户不能写入。
我们不做多角色、多团队、多租户,专注把这一条链路写清楚。
一、在 Prisma 里添加角色字段:enum 比字符串更安全
上一篇我们用的是 role: String @default("USER"),写法简单,但缺点是:
没有类型约束;
写错字符串不会立刻报错。
更好的方式是用 enum UserRole,官方在 RBAC 示例里也是这么推荐的。
1.1 修改 schema:UserRole + User.role
在 blog-demo 的 schema(prisma/blog.schema.prisma)里,增加一个枚举,并把 User.role 改为枚举类型:
enum UserRole {
USER
ADMIN
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
username String? @unique
password String?
role UserRole @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile Profile?
posts Post[]
comments Comment[]
}
然后执行迁移:
pnpm prisma migrate dev --name add_user_role_enum
pnpm prisma generate
这样一来:
数据库层面会多出一个
UserRole类型;Prisma Client 里的
user.role会成为'USER' | 'ADMIN'联合类型。
二、确定“权限的来源”:从 session 里读 role
在第 7 篇里,我们在 Auth.js 的 jwt / session 回调中已经把 user.role 写入了 token:
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as any).role
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
;(session.user as any).role = token.role
}
return session
},
}
接下来,我们会不断用到这两个信息:
session.user.id:当前用户的 id;(session.user as any).role:当前用户角色('USER' | 'ADMIN')。
这意味着:
RBAC 判断逻辑可以完全写在应用层(API Route / Server Action),不需要改 Prisma Client 的行为;
你可以用“一个统一的函数”来做权限判断。
三、定义一个小的“权限检查工具”:在服务端专用
我们可以在 lib/rbac.ts 中写一个简单的辅助函数,避免在每个 API 里重复写 if/else。
lib/rbac.ts:
import type { UserRole } from '../generated/blog'
// 约定角色字符串与枚举一致
type Role = UserRole | 'USER' | 'ADMIN'
// 判断是否为管理员
export function isAdmin(role: Role | undefined | null): boolean {
return role === 'ADMIN'
}
// 判断当前用户是否可以编辑这篇文章:
// - ADMIN 可以编辑任何文章
// - USER 只能编辑自己的文章
export function canEditPost(params: {
userId: number
userRole: Role
postAuthorId: number
}): boolean {
if (isAdmin(params.userRole)) return true
return params.userId === params.postAuthorId
}
// 删除文章同理,这里先复用同一规则
export const canDeletePost = canEditPost
这个工具函数很简单,但好处是:
所有权限规则集中维护;
如果以后扩展 Viewer/Editor,只改这里即可;
API 里可以用一行表达业务意图:
if (!canEditPost(...))。
四、在文章更新/删除 API 中应用 RBAC
假设我们要为文章提供两个操作:
PATCH /api/posts/[id]:更新文章(只能编辑 title / content / published 等);DELETE /api/posts/[id]:删除文章。
我们在 App Router 的 API Route 里加上这两个 handler,并在里面使用 auth() 和 canEditPost / canDeletePost。
4.1 /api/posts/[id] 的 PATCH:只有作者或管理员可以改
app/api/posts/[id]/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { blogPrisma } from '@/lib/prisma-blog'
import { auth } from '@/lib/auth'
import { canEditPost } from '@/lib/rbac'
type RouteParams = {
params: { id: string }
}
// PATCH /api/posts/[id]:编辑文章
export async function PATCH(req: NextRequest, { params }: RouteParams) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: '未登录用户不能编辑文章' },
{ status: 401 },
)
}
const userId = Number(session.user.id)
const role = (session.user as any).role ?? 'USER'
const postId = Number(params.id)
if (!Number.isFinite(postId)) {
return NextResponse.json({ error: '无效的文章 ID' }, { status: 400 })
}
const body = await req.json()
const { title, content, published } = body
// 1. 查出文章作者
const post = await blogPrisma.post.findUnique({
where: { id: postId },
select: { id: true, authorId: true },
})
if (!post) {
return NextResponse.json({ error: '文章不存在' }, { status: 404 })
}
// 2. 权限判断
if (
!canEditPost({
userId,
userRole: role,
postAuthorId: post.authorId,
})
) {
return NextResponse.json(
{ error: '没有权限编辑这篇文章' },
{ status: 403 },
)
}
// 3. 执行更新
const updated = await blogPrisma.post.update({
where: { id: postId },
data: {
...(title !== undefined && { title }),
...(content !== undefined && { content }),
...(typeof published === 'boolean' && { published }),
},
})
return NextResponse.json(updated)
} catch (e: any) {
console.error(e)
return NextResponse.json(
{ error: e.message || 'Failed to update post' },
{ status: 500 },
)
}
}
几个关键点:
从
session.user拿id和role;先查出文章的
authorId,再调用canEditPost检查;只有检查通过才允许执行
post.update。
4.2 /api/posts/[id] 的 DELETE:同样规则
继续在同一个文件中实现 DELETE:
// DELETE /api/posts/[id]:删除文章
export async function DELETE(_req: NextRequest, { params }: RouteParams) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: '未登录用户不能删除文章' },
{ status: 401 },
)
}
const userId = Number(session.user.id)
const role = (session.user as any).role ?? 'USER'
const postId = Number(params.id)
if (!Number.isFinite(postId)) {
return NextResponse.json({ error: '无效的文章 ID' }, { status: 400 })
}
// 1. 查出文章作者
const post = await blogPrisma.post.findUnique({
where: { id: postId },
select: { id: true, authorId: true },
})
if (!post) {
return NextResponse.json({ error: '文章不存在' }, { status: 404 })
}
// 2. 权限判断
if (
!canDeletePost({
userId,
userRole: role,
postAuthorId: post.authorId,
})
) {
return NextResponse.json(
{ error: '没有权限删除这篇文章' },
{ status: 403 },
)
}
// 3. 删除文章(及其相关记录,视你是否配置了级联)
await blogPrisma.post.delete({
where: { id: postId },
})
return NextResponse.json({ ok: true })
} catch (e: any) {
console.error(e)
return NextResponse.json(
{ error: e.message || 'Failed to delete post' },
{ status: 500 },
)
}
}
现在,blog-demo 的“编辑/删除文章”链路就完全受 RBAC 控制了:
未登录:401;
登录用户但不是作者,且不是 ADMIN:403;
作者或 ADMIN:允许操作。
五、在界面上表现 RBAC:只为有权限的人显示按钮
为了避免“点了才发现没权限”的体验,我们可以在 UI 上也做一点权限判断。
比如在文章详情页 /blog/[id],只有当前用户是作者或管理员时,才显示“编辑/删除”按钮。
在 Server Component 里也可以使用 auth()(Auth.js 支持),我们可以这么写:
// app/blog/[id]/page.tsx
import { blogPrisma } from '@/lib/prisma-blog'
import { auth } from '@/lib/auth'
import { canEditPost } from '@/lib/rbac'
import CommentForm from './comment-form'
type PageParams = { params: { id: string } }
export default async function PostDetailPage({ params }: PageParams) {
const postId = Number(params.id)
const [session, post] = await Promise.all([
auth(),
blogPrisma.post.findUnique({
where: { id: postId },
include: {
author: { select: { id: true, name: true } },
categories: { include: { category: true } },
comments: {
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
},
},
}),
])
if (!post) {
return <main className="p-8">文章不存在</main>
}
const userId = session?.user?.id ? Number(session.user.id) : null
const role = (session?.user as any)?.role ?? 'USER'
const canEdit =
userId != null &&
canEditPost({
userId,
userRole: role,
postAuthorId: post.author.id,
})
const categories = post.categories.map((pc) => pc.category)
return (
<main className="mx-auto max-w-3xl px-4 py-8 space-y-4">
<article className="space-y-2">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{post.title}</h1>
<div className="text-xs text-gray-500 space-x-2">
<span>作者:{post.author.name}</span>
<span>发布于:{post.createdAt.toLocaleString()}</span>
<span>
分类:
{categories.length === 0
? '无'
: categories.map((c) => c.name).join(' / ')}
</span>
</div>
</div>
{canEdit && (
<div className="flex gap-2">
{/* 这里可以接入前端编辑/删除逻辑 */}
<button className="rounded border px-2 py-1 text-xs">
编辑
</button>
<form action={`/api/posts/${post.id}`} method="POST">
{/* 真正的删除建议用前端 fetch 调用 DELETE */}
</form>
</div>
)}
</header>
<p className="mt-4 whitespace-pre-wrap text-sm leading-relaxed">
{post.content}
</p>
</article>
<section className="space-y-3 border-t pt-4">
<h2 className="text-lg font-semibold">
评论({post.comments.length})
</h2>
<CommentForm postId={post.id} />
<ul className="space-y-2">
{post.comments.map((c) => (
<li key={c.id} className="rounded border px-3 py-2">
<div className="text-xs text-gray-500 mb-1">
{c.author.name} · {c.createdAt.toLocaleString()}
</div>
<p className="text-sm">{c.content}</p>
</li>
))}
</ul>
</section>
</main>
)
}
现在,编辑/删除按钮只会出现在“有权限的人”眼前。
再加上 API 里的权限检查,就实现了前后端双重保护。
六、一点延伸:RBAC 能做到哪一步,什么时候要更复杂的方案?
本篇这个 RBAC 实现非常基础:只有两个角色,规则写死在应用代码里。
从 Prisma/Next.js 的实践来看,它足以支撑很多中小型后台系统。但当你的需求演化成下面这些情况时,就需要考虑更“正规的”权限方案了:
不同资源种类(文章、团队、项目)有不同的权限集合;
每个资源实例可以定义自己的“管理员”“成员”(ReBAC / ABAC);
权限规则频繁调整,最好让非技术人员在后台配置,而不是改代码;
Prisma 官方文档和社区有不少示例,展示了如何用:
Client Extensions + 外部权限服务(如 Permit.io)做细粒度授权;
PostgreSQL 的 Row-Level Security(RLS)从数据库层面做行级控制;
这些都属于“更进一步”的话题,等你把当前这一套 RBAC 玩熟了,再往上加会更自然。