RBAC 权限与多租户系统

认证解决"你是谁",授权解决"你能做什么"。大多数 SaaS 应用需要角色权限控制(RBAC)和多租户隔离——不同团队的数据必须完全隔离,团队内不同角色看到不同内容。本章从数据模型设计到权限检查再到数据库级隔离,搭建完整的授权体系。

1. 权限模型设计

1.1 RBAC 基础概念

RBAC(Role-Based Access Control,基于角色的访问控制):

  • 用户(User):系统的使用者
  • 角色(Role):一组权限的集合(如 admin、editor、viewer)
  • 权限(Permission):具体的操作能力(如 posts:createusers: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 隐藏(&lt;Can&gt; 组件)
  • RLS:数据库级别的行过滤,作为应用层权限检查的兜底——纵深防御
  • 多租户:共享数据库 + org_id 列是最简单高效的方案,配合 URL 路径模式(/[orgSlug]/...
  • 团队管理:邀请(Token + 邮件)、接受邀请(邮箱验证)、角色变更(层级权限控制)
  • 关键原则:所有权限检查在服务端,所有数据查询带 orgId 过滤,Owner 权限不可被修改