Tech3 views

Part 8: Adding RBAC to blog-demo — Teaching Prisma Who Can Do What

So far, blog-demo already has:

But all logged-in users have the same “permissions”: any logged-in user can create, delete, and edit others’ posts—this is clearly not suitable for a real project.

In this post, we will add a simple RBAC (Role-Based Access Control) rule to blog-demo:

We won’t do multi-role, multi-team, or multi-tenant; we focus on making this single chain clear.


1. Adding a Role Field in Prisma: Enum is Safer than String

In the previous post, we used role: String @default("USER"), which is simple but has the downside:

A better approach is to use enum UserRole, which the official RBAC example also recommends.

1.1 Modify Schema: UserRole + User.role

In the blog-demo schema (prisma/blog.schema.prisma), add an enum and change User.role to the enum type:

text
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[]
}

Then execute the migration:

text
pnpm prisma migrate dev --name add_user_role_enum
pnpm prisma generate

This way:


2. Determining the Source of Permissions: Read role from session

In Part 7, we already wrote user.role into the token in the jwt / session callbacks of Auth.js:

text
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
  },
}

Next, we will frequently use these two pieces of information:

This means:


3. Defining a Small Permission Check Utility: Server-Side Only

We can write a simple helper function in lib/rbac.ts to avoid repeating if/else in every API.

text
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

This utility function is simple, but the benefits are:


4. Applying RBAC in Post Update/Delete APIs

Suppose we want to provide two operations for posts:

We will add these two handlers in the App Router API Route, and use auth() and canEditPost / canDeletePost inside them.

4.1 PATCH /api/posts/[id]: Only Author or Admin Can Edit

app/api/posts/[id]/route.ts:

text
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 },
    )
  }
}

Key points:

4.2 DELETE /api/posts/[id]: Same Rule

Continue implementing DELETE in the same file:

text
// 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 },
    )
  }
}

Now, the “edit/delete post” chain in blog-demo is fully controlled by RBAC:


5. Displaying RBAC in the UI: Show Buttons Only for Authorized Users

To avoid the experience of “clicking and then finding out you don’t have permission”, we can add some permission checks in the UI as well.

For example, on the post detail page /blog/[id], only show the “edit/delete” buttons when the current user is the author or an admin.

In the Server Component, we can also use auth() (Auth.js supports it), and we can write it like this:

text
// 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>
  )
}

Now, the edit/delete buttons will only appear in front of “authorized people”.
Along with the permission checks in the API, this achieves double protection on both frontend and backend.


6. A Little Extension: How Far Can RBAC Go, and When Do You Need a More Complex Solution?

The RBAC implementation in this post is very basic: only two roles, rules hardcoded in the application code.

From the practice of Prisma/Next.js, it is sufficient to support many small to medium backend systems. But when your needs evolve into the following situations, you need to consider more “formal” permission solutions:

The Prisma official documentation and community have many examples showing how to use:

These are all “further” topics; once you are proficient with this set of RBAC, adding more will feel more natural.

SHARE

Share

Share this article.