arrow_back返回文章列表
技术

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

到目前为止,blog-demo 已经有:

但所有登录用户的“权限”是一样的:任何登录用户都能发文、删文、改别人文章——这显然不适合真实项目。

这一篇,我们就给 blog-demo 增加一个简单的 RBAC(基于角色的访问控制)规则:

我们不做多角色、多团队、多租户,专注把这一条链路写清楚。


一、在 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

这样一来:


二、确定“权限的来源”:从 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
  },
}

接下来,我们会不断用到这两个信息:

这意味着:


三、定义一个小的“权限检查工具”:在服务端专用

我们可以在 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

这个工具函数很简单,但好处是:


四、在文章更新/删除 API 中应用 RBAC

假设我们要为文章提供两个操作:

我们在 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 },
    )
  }
}

几个关键点:

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 控制了:


五、在界面上表现 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 的实践来看,它足以支撑很多中小型后台系统。但当你的需求演化成下面这些情况时,就需要考虑更“正规的”权限方案了:

Prisma 官方文档和社区有不少示例,展示了如何用:

这些都属于“更进一步”的话题,等你把当前这一套 RBAC 玩熟了,再往上加会更自然。