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

So far, blog-demo already has:
Login system (Auth.js);
Current user context (
auth()/useSession());Complete CRUD for posts and comments (Parts 6, 7).
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:
All logged-in users are USER;
Some users can be designated as ADMIN;
Permission rules:
ADMIN can edit and delete any post;
USER can only edit and delete “their own posts”;
Unauthenticated users cannot write.
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:
No type constraints;
Typing a wrong string won’t immediately error.
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:
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:
pnpm prisma migrate dev --name add_user_role_enum
pnpm prisma generate
This way:
At the database level, a UserRole type is added;
In the Prisma Client, user.role becomes the union type
'USER' | 'ADMIN'.
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:
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:
session.user.id: the current user’s id;(session.user as any).role: the current user’s role ('USER' | 'ADMIN').
This means:
RBAC judgment logic can be fully written in the application layer (API Route / Server Action), without modifying Prisma Client behavior;
You can use “a unified function” for permission checks.
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.
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:
All permission rules are centrally maintained;
If we later extend to Viewer/Editor, we only need to modify this file;
In the API, we can express business intent in one line:
if (!canEditPost(...)).
4. Applying RBAC in Post Update/Delete APIs
Suppose we want to provide two operations for posts:
PATCH /api/posts/[id]: Update post (only edit title / content / published, etc.);DELETE /api/posts/[id]: Delete post.
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:
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:
Get
idandrolefromsession.user;First query the post’s
authorId, then callcanEditPostto check;Only allow
post.updateif the check passes.
4.2 DELETE /api/posts/[id]: Same Rule
Continue implementing DELETE in the same file:
// 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:
Not logged in: 401;
Logged-in user but not author, and not ADMIN: 403;
Author or ADMIN: allowed.
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:
// 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:
Different resource types (posts, teams, projects) have different permission sets;
Each resource instance can define its own “admins” and “members” (ReBAC / ABAC);
Permission rules change frequently, better to let non-technical staff configure them in the backend rather than modify code;
The Prisma official documentation and community have many examples showing how to use:
Client Extensions + external permission services (like Permit.io) for fine-grained authorization;
PostgreSQL’s Row-Level Security (RLS) for row-level control at the database level;
These are all “further” topics; once you are proficient with this set of RBAC, adding more will feel more natural.
Follow on Google
Add HeyBinyang as a preferred source on Google
If you'd like to keep finding my updates through Google, you can mark this site as a preferred source and make it easier to spot in relevant reading flows.
SHARE
Share
Share this article.