第 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 玩熟了,再往上加會更自然。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。