当嵌入式 AuthN 遇到嵌入式 AuthZ - 使用 Better-Auth 和 ZenStack 构建多租户应用程序
构建一个功能齐全的多租户应用程序可能非常具有挑战性。除了拥有灵活的注册和登录系统外,您还需要实现其他几个基本部分:
这听起来工作量很大,事实也确实如此。如果您是资深 SaaS 开发人员,您可能已经做过多次了。
Better-auth 是一个新兴的开源 TypeScript 身份验证框架,它提供了一套全面的功能和出色的可扩展性。除了支持广泛的身份提供者之外,其强大的插件系统还允许您添加新功能,为整个堆栈(数据模型、后端 API 和前端挂钩)提供扩展。一个很好的例子是 Organization 插件,它为实现具有访问控制的多租户应用奠定了基础。
better-auth 解决了确定用户身份和角色的问题,而 ZenStack 则从那里继续,并使用此类信息来控制用户可以对数据执行的操作。ZenStack 建立在 Prisma ORM 之上,并通过灵活的访问控制和自动 CRUD API 扩展了 Prisma 的功能。由于 better-auth 内置了与 Prisma 的集成,因此两者可以完美结合,用于构建安全的多租户应用程序。这篇文章将引导您完成创建应用程序的步骤。
目标和堆栈
我们将构建的目标应用程序是 Todo List。其核心功能很简单:创建列表并在其中管理待办事项。但是,重点将放在多租户和访问控制方面:
用户可以创建组织并邀请他人加入,管理成员并设置成员角色。
用户可以选择一个组织作为活跃组织。
仅可访问活跃组织内的数据。
我们将使用以下基本武器来构建应用程序:
此堆栈的好处是所有内容都嵌入在 Next.js 中运行。无需第三方云服务或自托管服务。您唯一需要的是一个 Next.js 托管商和一个数据库提供商。
您可以在帖子末尾找到已完成项目的链接。
基础设置
Better-auth 的 Next.js Demo 为我们提供了一个很好的起点,其中已经包含:
我们将对演示进行的一项重大更改是将数据库客户端从 Kysely 切换到 Prisma。
// lib/auth.ts
import { prismaAdapter } from 'better-auth/adapters/prisma';
export const auth = betterAuth({
appName: 'Better Auth Demo',
database: prismaAdapter(prisma, {
provider: 'sqlite',
}),
...
});然后,安装 Prisma 包并使用 better-auth CLI 生成模式:
npm install -D prisma npm install @prisma/client npx @better-auth/cli generate
生成的模式文件应包含以下模型:
初始仪表板 UI 如下所示:

设置ZenStack
在以下部分中,我们将使用 ZenStack 来实现访问控制要求。ZenStack 使用自己的 DSL(称为 ZModel)来定义数据模型和访问策略规则。ZModel 是 Prisma 模式语言的超集。ZenStack CLI 可以从 ZModel 文件生成 Prisma 模式,以便下游 Prisma 消费者(如 better-auth)可以继续无缝工作。
让我们用 ZenStack 初始化项目:
npx zenstack@latest init
该命令将安装必要的依赖项,并将“prisma/schema.prisma”文件复制到“/schema.zmodel”。接下来,我们将修改“schema.zmodel”并使用 ZenStack CLI 重新生成 Prisma 架构。
npx zenstack generate
准备数据模型
Better-auth 帮助我们生成了与身份验证相关的数据模型,让我们可以处理特定于应用程序的数据模型:Todo List 和 Todo。如前所述,我们应该更新“schema.zmodel”来定义它们:
// schema.zmodel
model TodoList {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
owner User @relation(fields: [ownerId], references: [id])
ownerId String
organization Organization? @relation(fields: [organizationId], references: [id])
organizationId String?
todos Todo[]
}
model Todo {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
done Boolean @default(false)
listId String
list TodoList @relation(fields: [listId], references: [id])
}然后重新生成 Prisma 模式并将更改推送到数据库:
npx zenstack generate npx prisma db push
最后,创建一个“/lib/db.ts”文件来导出 Prisma 客户端:
// lib/db.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();安装自动 CRUD API
ZenStack 提供了一个 Next.js 服务器适配器,可自动公开 Prisma 样式的 CRUD API。要安装它,请安装服务器适配器包:
npm install @zenstackhq/server
,然后创建一个“/app/api/[...path]/router.ts”文件:
// app/api/[...path]/router.ts"
import { prisma } from '@/lib/db';
import { NextRequestHandler } from '@zenstackhq/server/next';
async function getPrisma() {
return prisma;
}
const handler = NextRequestHandler({ getPrisma, useAppDir: true });
export {
handler as DELETE,
handler as GET,
handler as PATCH,
handler as POST,
handler as PUT,
};这将公开一组 CRUD 端点,如 `/api/model/TodoList/findMany`、`/api/model/TodoList/create` 等。您可以在此处找到更多详细信息。
虽然我们可以直接使用“fetch”调用这些 API,但更简单的方法是利用 ZenStack 的 TanStack Query 插件来生成客户端钩子。
npm install @zenstackhq/tanstack-query
// schema.zmodel
plugin hooks {
provider = "@zenstackhq/tanstack-query"
target = "react"
output = "./hooks/model"
}npx zenstack generate
然后,您可以在前端代码中享受类型安全的 Prisma 样式钩子。
import { useFindManyTodoList } from '@/hooks/model';
export function MyComponent() {
const { data: lists, isLoading } = useFindManyTodoList({ include: { owner: true }});
...
}实施访问控制
现在,我们可以通过生成的钩子和自动化 API 从前端来操作数据库。然而,这些 API 是开放的,没有任何保护,这显然不是我们想要的。
ZenStack 在 Prisma 之上增加的最大价值是访问控制,可以使用 `@@allow` 和 `@@deny` 属性直接在架构内部实现。在运行时,ZenStack 允许您围绕 `PrismaClient`(称为 **enhanced** PrismaClient)创建一个包装器,以自动执行这些策略规则。除非使用 `@@allow` 规则明确授予访问权限,并且未被任何 `@@deny` 规则拒绝,否则默认情况下会拒绝访问。当使用增强型客户端访问数据库时,在读取期间会过滤掉无法访问的记录,并且拒绝权限不足的突变。
在实际应用中,授权始终与身份验证相关:您将根据用户的身份和其他信息(如组织成员资格、角色等)确定用户的访问权限。在我们的上下文中,我们将使用 better-auth 来检索当前用户的身份、活跃组织和组织中的角色,并在创建增强型“PrismaClient”时将此信息用作“用户上下文”。由于自动 API 使用增强型客户端,因此它们也是安全的。
// app/api/model/[...path]/route.ts
async function getPrisma() {
const reqHeaders = await headers();
const sessionResult = await auth.api.getSession({
headers: reqHeaders,
});
if (!sessionResult) {
// anonymous user, create enhanced client without user context
return enhance(prisma);
}
let organizationId: string | undefined = undefined;
let organizationRole: string | undefined = undefined;
const { session } = sessionResult;
if (session.activeOrganizationId) {
// if there's an active orgId, get the role of the user in the org
organizationId = session.activeOrganizationId;
const org = await auth.api.getFullOrganization({ headers: reqHeaders });
if (org?.members) {
const myMember = org.members.find(
(m) => m.userId === session.userId
);
organizationRole = myMember?.role;
}
}
// create enhanced client with user context
const userContext = {
userId: session.userId,
organizationId,
organizationRole,
};
return enhance(prisma, { user: userContext });
}用户上下文将通过特殊的 `auth()` 函数在 ZModel 策略规则中访问。为了使其工作,我们将使用一种类型来定义 `auth()` 的形状:
// schema.zmodel
type Auth {
userId String @id
organizationId String?
organizationRole String?
@@auth
}现在,我们准备编写策略规则。您可以在此处找到有关访问策略的更多信息。
1. 租户隔离
model TodoList {
...
// deny anonymous users
@@deny('all', auth() == null)
// deny access to lists that don't belong to the user's active organization
@@deny('all', auth().organizationId != organizationId)
}2. 用户只能为自己创建列表
model TodoList {
...
// users can create lists for themselves
@@allow('create', auth().userId == ownerId)
}3. 所有者和管理员拥有完全访问权限
默认情况下,better-auth 的组织成员可以拥有“所有者”、“管理员”或“成员”角色。
model TodoList {
...
// full access to: list owner, org owner, and org admins
@@allow('all',
auth().userId == ownerId ||
auth().organizationRole == 'owner' ||
auth().organizationRole == 'admin')
}4. 组织成员可读
model TodoList {
...
// if the list belongs to an org, it's readable to all members
@@allow('read', organizationId != null)
}5. 所有者和组织不能更改
您可以使用 `@allow` 和 `@deny` 属性(注意单个 `@` 符号)来定义字段级规则。
model TodoList {
...
ownerId String @allow('update', false)
organizationId String? @allow('update', false)
}6. 如果用户可以读取 Todo 的父级 TodoList,则该用户可以完全访问 Todo
我们已经成功保护了 `TodoList` 模型,而 `Todo` 模型的规则尚未定义。幸运的是,ZenStack 允许您在策略规则中引用关系。`check()` 助手允许您直接将权限检查委托给关系(此处为 `Todo` -> `TodoList`)。
model Todo {
...
// `check()` delegates permission check to a relation
@@allow('all', check(list, 'read'))
}最后,待办事项列表 UI
在保护好 CRUD API 并生成前端钩子后,实现用于管理“TodoList”的 UI 变得非常简单。我在这里只展示了部分实现。
// app/dashboard/todo-lists-card.tsx
export default function TodoListsCard() {
// Note that you don't need to filter for the current user and the active organization
// because the ZModel rules have taken care of it
const { data: todoLists } = useFindManyTodoList({
orderBy: { createdAt: 'desc' },
});
const { mutateAsync: del, isPending: isDeleting } = useDeleteTodoList();
async function onDelete(id: string) {
await del({ where: { id } });
}
return (
Todo List
{todoLists?.map((list) => (
{list.name}
{list.createdAt.toLocaleString()}
))}
);
}您可以在下面找到完整的代码:
ymc9 / better-auth-zenstack-多租户
使用 better-auth 和 ZenStack 构建的多租户 Todo 应用程序
这是博客文章《当嵌入式 AuthN 遇到嵌入式 AuthZ - 使用 Better-Auth 和 ZenStack 构建多租户应用程序》的配套项目。
该项目基于 better-auth 的 Next.js 演示项目。
入门
结论
身份验证和授权是大多数应用程序的两个基石。对于多租户应用程序来说,构建身份验证和授权尤其具有挑战性。这篇文章展示了如何通过结合 better-auth 和 ZenStack 来显著简化和精简工作。最终结果是具有极大灵活性和很少样板代码的安全应用程序。
Better-auth 还支持为组织定义自定义权限。虽然本文未涉及,但通过一些调整,您应该能够利用它来定义访问策略。这样,您就可以使用 better-auth 的 API 管理权限,并让 ZenStack 在运行时强制执行这些权限。