技術3 閱讀

第 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 改為列舉型別:

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

然後執行遷移:

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

這樣一來:


二、確定「權限的來源」:從 session 裡讀取 role

在第 7 篇裡,我們在 Auth.js 的 jwt / session 回呼中已經把 user.role 寫入了 token:

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

接下來,我們會不斷用到這兩個資訊:

這意味著:


三、定義一個簡單的「權限檢查工具」:在伺服器端專用

我們可以在 lib/rbac.ts 中寫一個簡單的輔助函數,避免在每個 API 裡重複寫 if/else。

lib/rbac.ts

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

這個工具函數很簡單,但好處是:


四、在文章更新/刪除 API 中應用 RBAC

假設我們要為文章提供兩個操作:

我們在 App Router 的 API Route 裡加上這兩個 handler,並在裡面使用 auth()canEditPost / canDeletePost

4.1 /api/posts/[id] 的 PATCH:只有作者或管理員可以改

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

幾個關鍵點:

4.2 /api/posts/[id] 的 DELETE:同樣規則

繼續在同一個檔案中實作 DELETE:

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

現在,blog-demo 的「編輯/刪除文章」鏈路就完全受 RBAC 控制了:


五、在介面上呈現 RBAC:只為有權限的人顯示按鈕

為了避免「點了才發現沒權限」的體驗,我們可以在 UI 上也做一點權限判斷。

例如在文章詳細頁 /blog/[id],只有當前使用者是作者或管理員時,才顯示「編輯/刪除」按鈕。

在 Server Component 裡也可以使用 auth()(Auth.js 支援),我們可以這樣寫:

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

現在,編輯/刪除按鈕只會出現在「有權限的人」眼前。
再加上 API 裡的權限檢查,就實作了前後端雙重保護。


六、一點延伸:RBAC 能做到哪一步,何時要更複雜的方案?

本篇這個 RBAC 實作非常基礎:只有兩個角色,規則寫死在應用程式碼裡。

從 Prisma/Next.js 的實踐來看,它足以支撐很多中小型後台系統。但當你的需求演進成以下這些情況時,就需要考慮更「正規的」權限方案了:

Prisma 官方文件和社群有不少範例,展示了如何使用:

這些都屬於「更進一步」的話題,當你把當前這套 RBAC 玩熟了,再往上加會更自然。

SHARE

分享

分享這篇文章。