RBAC 权限与多租户系统
认证解决"你是谁",授权解决"你能做什么"。大多数 SaaS 应用需要角色权限控制(RBAC)和多租户隔离——不同团队的数据必须完全隔离,团队内不同角色看到不同内容。本章从数据模型设计到权限检查再到数据库级隔离,搭建完整的授权体系。
1. 权限模型设计
1.1 RBAC 基础概念
RBAC(Role-Based Access Control,基于角色的访问控制):
- 用户(User):系统的使用者
- 角色(Role):一组权限的集合(如 admin、editor、viewer)
- 权限(Permission):具体的操作能力(如
posts:create、users:delete)
用户 → 角色 → 权限
张三 → admin → [posts:create, posts:delete, users:manage, billing:view]
李四 → editor → [posts:create, posts:edit, posts:delete]
王五 → viewer → [posts:read]
1.2 数据模型
// lib/db/schema.ts
import { pgTable, text, timestamp, pgEnum, primaryKey, boolean } from 'drizzle-orm/pg-core'
// 角色枚举
export const roleEnum = pgEnum('role', ['owner', 'admin', 'member', 'viewer'])
// 组织(租户)
export const organizations = pgTable('organizations', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
slug: text('slug').notNull().unique(), // URL 友好的标识符
plan: text('plan').notNull().default('free'), // free | pro | enterprise
createdAt: timestamp('created_at').defaultNow().notNull(),
})
// 组织成员关系
export const members = pgTable('members', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
orgId: text('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
role: roleEnum('role').notNull().default('member'),
joinedAt: timestamp('joined_at').defaultNow().notNull(),
}, (table) => [
primaryKey({ columns: [table.userId, table.orgId] }),
])
// 邀请
export const invitations = pgTable('invitations', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
orgId: text('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
role: roleEnum('role').notNull().default('member'),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
acceptedAt: timestamp('accepted_at'),
})
// 业务数据——关联到组织
export const projects = pgTable('projects', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
orgId: text('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
createdBy: text('created_by').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
})1.3 权限定义
// lib/permissions.ts
// 所有可用权限
export const PERMISSIONS = {
// 项目
'projects:create': '创建项目',
'projects:read': '查看项目',
'projects:update': '编辑项目',
'projects:delete': '删除项目',
// 成员管理
'members:read': '查看成员',
'members:invite': '邀请成员',
'members:remove': '移除成员',
'members:update-role': '修改角色',
// 组织设置
'org:settings': '组织设置',
'org:billing': '账单管理',
'org:delete': '删除组织',
} as const
export type Permission = keyof typeof PERMISSIONS
// 角色 → 权限映射
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
owner: Object.keys(PERMISSIONS) as Permission[], // owner 拥有全部权限
admin: [
'projects:create', 'projects:read', 'projects:update', 'projects:delete',
'members:read', 'members:invite', 'members:remove', 'members:update-role',
'org:settings',
],
member: [
'projects:create', 'projects:read', 'projects:update',
'members:read',
],
viewer: [
'projects:read',
'members:read',
],
}
// 检查角色是否有特定权限
export function hasPermission(role: string, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false
}2. 权限检查
2.1 服务端权限检查工具
// lib/auth-utils.ts
import { auth } from '@/auth'
import { db } from '@/lib/db'
import { members } from '@/lib/db/schema'
import { hasPermission, type Permission } from '@/lib/permissions'
import { and, eq } from 'drizzle-orm'
import { redirect } from 'next/navigation'
// 获取当前用户在指定组织中的成员信息
export async function getMembership(orgId: string) {
const session = await auth()
if (!session?.user?.id) return null
return db.query.members.findFirst({
where: and(
eq(members.userId, session.user.id),
eq(members.orgId, orgId),
),
})
}
// 要求特定权限——没有权限则抛错
export async function requirePermission(orgId: string, permission: Permission) {
const membership = await getMembership(orgId)
if (!membership) {
throw new Error('Not a member of this organization')
}
if (!hasPermission(membership.role, permission)) {
throw new Error(`Missing permission: ${permission}`)
}
return membership
}
// 页面级保护——没有权限则重定向
export async function requireAccess(orgId: string, permission: Permission) {
const session = await auth()
if (!session?.user) redirect('/login')
const membership = await getMembership(orgId)
if (!membership || !hasPermission(membership.role, permission)) {
redirect('/dashboard')
}
return { session, membership }
}2.2 Server Component 中使用
// app/[orgSlug]/settings/page.tsx
import { requireAccess } from '@/lib/auth-utils'
export default async function OrgSettingsPage({
params,
}: {
params: Promise<{ orgSlug: string }>
}) {
const { orgSlug } = await params
const org = await getOrgBySlug(orgSlug)
// 需要 org:settings 权限,没有则重定向
const { session, membership } = await requireAccess(org.id, 'org:settings')
return (
<div>
<h1>组织设置</h1>
<OrgSettingsForm org={org} />
{/* 只有 owner 能看到危险操作区域 */}
{membership.role === 'owner' && (
<DangerZone orgId={org.id} />
)}
</div>
)
}2.3 Server Action 中使用
'use server'
import { requirePermission } from '@/lib/auth-utils'
export async function createProject(orgId: string, data: FormData) {
// 检查权限
const membership = await requirePermission(orgId, 'projects:create')
const project = await db.insert(projects).values({
orgId,
name: data.get('name') as string,
createdBy: membership.userId,
}).returning()
revalidatePath(`/${orgId}/projects`)
return project[0]
}
export async function deleteProject(orgId: string, projectId: string) {
await requirePermission(orgId, 'projects:delete')
await db.delete(projects).where(
and(
eq(projects.id, projectId),
eq(projects.orgId, orgId), // 确保项目属于当前组织
),
)
revalidatePath(`/${orgId}/projects`)
}2.4 客户端权限控制
'use client'
import { type Permission, ROLE_PERMISSIONS } from '@/lib/permissions'
// 权限上下文
interface AuthContext {
role: string
orgId: string
}
// 权限检查组件
export function Can({
permission,
role,
children,
fallback = null,
}: {
permission: Permission
role: string
children: React.ReactNode
fallback?: React.ReactNode
}) {
const allowed = ROLE_PERMISSIONS[role]?.includes(permission) ?? false
return allowed ? children : fallback
}// 使用
<Can permission="members:invite" role={membership.role}>
<InviteMemberButton orgId={org.id} />
</Can>
<Can permission="projects:delete" role={membership.role} fallback={<span className="text-gray-400">无权限</span>}>
<DeleteProjectButton projectId={project.id} />
</Can>重要:客户端权限检查只是 UI 优化(隐藏不可用的按钮)。真正的安全检查必须在服务端——Server Action 和 API Route 中。
3. Row Level Security (RLS)
3.1 什么是 RLS
RLS 是数据库级别的访问控制——在 SQL 层面自动过滤数据,确保用户只能看到自己有权限的行。即使应用代码有 bug 忘了加 WHERE orgId = ?,RLS 也会兜底。
这是纵深防御的一层——应用层检查 + 数据库层检查,两层都要有。
3.2 PostgreSQL RLS 配置
-- 启用 RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 创建策略:只能看到自己组织的项目
CREATE POLICY "org_isolation" ON projects
FOR ALL
USING (
org_id IN (
SELECT org_id FROM members
WHERE user_id = current_setting('app.current_user_id')::text
)
);
-- 查看策略
CREATE POLICY "org_select" ON projects
FOR SELECT
USING (
org_id IN (
SELECT org_id FROM members
WHERE user_id = current_setting('app.current_user_id')::text
)
);
-- 插入策略:只能在自己的组织中创建
CREATE POLICY "org_insert" ON projects
FOR INSERT
WITH CHECK (
org_id IN (
SELECT org_id FROM members
WHERE user_id = current_setting('app.current_user_id')::text
)
);3.3 在 Next.js 中使用 RLS
// lib/db.ts — 在每次查询前设置当前用户 ID
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export function getDbForUser(userId: string) {
return {
async query<T>(fn: (db: ReturnType<typeof drizzle>) => Promise<T>): Promise<T> {
const client = await pool.connect()
try {
// 设置当前用户 ID(RLS 策略会用到)
await client.query(`SET app.current_user_id = '${userId}'`)
const db = drizzle(client)
return await fn(db)
} finally {
// 重置设置并释放连接
await client.query('RESET app.current_user_id')
client.release()
}
},
}
}// 使用
export default async function ProjectsPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
const userDb = getDbForUser(session.user.id)
// RLS 自动过滤——只返回用户有权限的项目
const projects = await userDb.query((db) =>
db.query.projects.findMany()
)
return <ProjectList projects={projects} />
}3.4 应用层替代方案
如果不用 RLS(比如使用不支持 RLS 的数据库),在应用层实现隔离:
// lib/queries.ts — 所有查询都必须带 orgId 过滤
export async function getProjects(orgId: string) {
// 强制关联 orgId — 不可能忘记
return db.query.projects.findMany({
where: eq(projects.orgId, orgId),
orderBy: desc(projects.createdAt),
})
}
export async function getProject(orgId: string, projectId: string) {
return db.query.projects.findFirst({
where: and(
eq(projects.id, projectId),
eq(projects.orgId, orgId), // 双重检查
),
})
}4. 多租户架构
4.1 租户隔离策略
| 策略 | 隔离级别 | 复杂度 | 适用场景 |
|---|---|---|---|
共享数据库 + org_id 列 | 行级 | 低 | 大多数 SaaS |
| Schema 隔离(每租户一个 schema) | Schema 级 | 中 | 中型 SaaS |
| 独立数据库(每租户一个库) | 数据库级 | 高 | 企业级/合规要求 |
推荐方案:大多数项目用"共享数据库 + org_id 列 + RLS"就够了。
4.2 基于 URL 的租户识别
https://app.example.com/acme/projects ← 路径模式
https://acme.example.com/projects ← 子域名模式
路径模式(推荐):
// app/[orgSlug]/layout.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function OrgLayout({
params,
children,
}: {
params: Promise<{ orgSlug: string }>
children: React.ReactNode
}) {
const { orgSlug } = await params
const session = await auth()
if (!session?.user) redirect('/login')
// 查找组织
const org = await db.query.organizations.findFirst({
where: eq(organizations.slug, orgSlug),
})
if (!org) redirect('/dashboard')
// 检查用户是否是成员
const membership = await db.query.members.findFirst({
where: and(
eq(members.userId, session.user.id),
eq(members.orgId, org.id),
),
})
if (!membership) redirect('/dashboard')
return (
<OrgContext.Provider value={{ org, membership, session }}>
<OrgSidebar org={org} role={membership.role} />
<main>{children}</main>
</OrgContext.Provider>
)
}4.3 组织切换
// components/org-switcher.tsx
import { auth } from '@/auth'
export async function OrgSwitcher() {
const session = await auth()
if (!session?.user?.id) return null
// 获取用户的所有组织
const userOrgs = await db.query.members.findMany({
where: eq(members.userId, session.user.id),
with: {
organization: true,
},
})
return (
<select onChange={(e) => window.location.href = `/${e.target.value}`}>
{userOrgs.map(({ organization: org }) => (
<option key={org.id} value={org.slug}>
{org.name}
</option>
))}
</select>
)
}5. 团队管理
5.1 邀请成员
'use server'
import { requirePermission } from '@/lib/auth-utils'
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function inviteMember(orgId: string, data: FormData) {
await requirePermission(orgId, 'members:invite')
const email = data.get('email') as string
const role = data.get('role') as string
// 检查是否已是成员
const existingUser = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (existingUser) {
const existingMembership = await db.query.members.findFirst({
where: and(
eq(members.userId, existingUser.id),
eq(members.orgId, orgId),
),
})
if (existingMembership) {
return { error: '该用户已是团队成员' }
}
}
// 创建邀请
const token = crypto.randomUUID()
await db.insert(invitations).values({
orgId,
email,
role,
token,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 天有效
})
// 发送邀请邮件
const org = await db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
})
await resend.emails.send({
from: '[email protected]',
to: email,
subject: `你被邀请加入 ${org?.name}`,
html: `
<p>你被邀请加入 <strong>${org?.name}</strong> 团队。</p>
<a href="${process.env.NEXT_PUBLIC_APP_URL}/invite/${token}">接受邀请</a>
<p>此邀请 7 天内有效。</p>
`,
})
return { success: true }
}5.2 接受邀请
// app/invite/[token]/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function AcceptInvitePage({
params,
}: {
params: Promise<{ token: string }>
}) {
const { token } = await params
const session = await auth()
if (!session?.user) redirect(`/login?callbackUrl=/invite/${token}`)
const invitation = await db.query.invitations.findFirst({
where: and(
eq(invitations.token, token),
isNull(invitations.acceptedAt),
),
with: { organization: true },
})
if (!invitation || invitation.expiresAt < new Date()) {
return <div>邀请已过期或无效</div>
}
// 验证邮箱匹配
if (invitation.email !== session.user.email) {
return <div>此邀请不是发给你的</div>
}
return (
<div className="flex flex-col items-center gap-4 p-8">
<h1>加入 {invitation.organization.name}</h1>
<p>你被邀请以 <strong>{invitation.role}</strong> 角色加入此团队。</p>
<form
action={async () => {
'use server'
// 添加为成员
await db.insert(members).values({
userId: session.user.id,
orgId: invitation.orgId,
role: invitation.role,
})
// 标记邀请已接受
await db.update(invitations)
.set({ acceptedAt: new Date() })
.where(eq(invitations.id, invitation.id))
redirect(`/${invitation.organization.slug}`)
}}
>
<button
type="submit"
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
接受邀请
</button>
</form>
</div>
)
}5.3 角色变更与成员移除
'use server'
export async function updateMemberRole(orgId: string, targetUserId: string, newRole: string) {
const membership = await requirePermission(orgId, 'members:update-role')
// 不能修改 owner 的角色
const target = await db.query.members.findFirst({
where: and(eq(members.userId, targetUserId), eq(members.orgId, orgId)),
})
if (target?.role === 'owner') {
throw new Error('Cannot change owner role')
}
// 只有 owner 能设置 admin
if (newRole === 'admin' && membership.role !== 'owner') {
throw new Error('Only owner can assign admin role')
}
await db.update(members)
.set({ role: newRole })
.where(and(eq(members.userId, targetUserId), eq(members.orgId, orgId)))
revalidatePath(`/${orgId}/settings/members`)
}
export async function removeMember(orgId: string, targetUserId: string) {
const membership = await requirePermission(orgId, 'members:remove')
// 不能移除 owner
const target = await db.query.members.findFirst({
where: and(eq(members.userId, targetUserId), eq(members.orgId, orgId)),
})
if (target?.role === 'owner') {
throw new Error('Cannot remove owner')
}
// admin 不能移除其他 admin(只有 owner 可以)
if (target?.role === 'admin' && membership.role !== 'owner') {
throw new Error('Only owner can remove admin')
}
await db.delete(members).where(
and(eq(members.userId, targetUserId), eq(members.orgId, orgId)),
)
revalidatePath(`/${orgId}/settings/members`)
}本章小结
- RBAC 模型:用户 → 角色 → 权限,角色-权限映射在代码中定义,灵活可控
- 权限检查:服务端必须检查(
requirePermission),客户端只做 UI 隐藏(<Can>组件) - RLS:数据库级别的行过滤,作为应用层权限检查的兜底——纵深防御
- 多租户:共享数据库 +
org_id列是最简单高效的方案,配合 URL 路径模式(/[orgSlug]/...) - 团队管理:邀请(Token + 邮件)、接受邀请(邮箱验证)、角色变更(层级权限控制)
- 关键原则:所有权限检查在服务端,所有数据查询带 orgId 过滤,Owner 权限不可被修改