使用 StackAuth 的“Teams”和 Next.js 构建多租户应用程序
构建一个功能齐全的多租户应用程序可能非常具有挑战性。除了拥有灵活的注册和登录系统外,您还需要实现其他几个基本部分:
这听起来工作量很大,事实也确实如此。如果您是资深 SaaS 开发人员,您可能已经做过多次了。
StackAuth 是一个开源身份验证和用户管理平台,旨在无缝集成到 Next.js 项目中。它结合了前端/后端 API 和预构建的 UI 组件,大大简化了将此类功能集成到应用程序中的过程。同样,其较新的“Teams”功能为创建多租户应用程序提供了一个绝佳的起点。在本文中,我们将探索如何利用它来构建一个非同寻常的应用程序,同时尽量保持代码的简洁和干净。
目标和堆栈
我们将构建的目标应用程序是 Todo List。其核心功能很简单:创建列表并在其中管理待办事项。但是,重点将放在多租户和访问控制方面:
用户可以创建团队并邀请其他人加入。他们可以管理成员并设置他们的角色。
用户可以选择一个团队作为当前上下文。
仅可访问当前团队内的数据。
我们将使用以下基本武器来构建应用程序:
您可以在文章末尾找到已完成项目的链接。
添加团队管理
我假设您已经创建了一个 Next.js 项目并完成了 StackAuth 设置指南中所述的步骤。验证基本注册/登录流程是否正常运行。此外,在 StackAuth 的管理控制台中,在“团队设置”部分中启用“客户端团队创建”和“自动团队创建”选项。

现在,我们可以将“SelectedTeamSwitcher”组件添加到布局中。
// src/app/layout.tsx
import { SelectedTeamSwitcher } from "@stackframe/stack";
...
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}通过这一行代码,您将拥有一组功能齐全的 UI 组件,用于管理团队并选择一个活跃的团队!

尽管 StackAuth 让在应用中添加“团队”功能变得轻而易举,但如何使用用户和团队信息来控制数据访问仍取决于您。我们将了解如何将其与 Prisma/ZenStack 连接以实现适当的授权。
设置数据库
我们的用户和团队数据存储在 StackAuth 端。我们需要将待办事项列表和项目存储在我们自己的数据库中。在本节中,我们将设置 Prisma 和 ZenStack 并创建数据库模式。
让我们开始安装必要的软件包:
npm install --save-dev prisma zenstack npm install @prisma/client @zenstackhq/runtime
然后我们可以创建数据库模式。请注意,我们正在创建一个 **schema.zmodel** 文件(作为“schema.prisma”的替代品)。ZModel 语言是 Prisma 模式语言的超集,允许您对数据模式和访问控制策略进行建模。在本节中,我们将仅关注数据建模部分。
// schema.zmodel
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator js {
provider = "prisma-client-js"
}
// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?
ownerId String
todos Todo[]
}
// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}然后,您可以生成常规 Prisma 模式文件并将模式推送到数据库:
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate" npx zenstack generate npx prisma db push
最后,创建一个“src/server/db.ts”文件来导出 Prisma 客户端:
// src/server/db.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();实施访问控制
如上所述,ZenStack 允许您在单个模式中对数据和访问控制进行建模。让我们看看如何使用它完全实现我们的授权要求。规则使用 `@@allow` 和 `@@deny` 属性定义。除非使用 `@@allow` 规则明确授予,否则默认情况下会拒绝访问。
尽管授权是与身份验证截然不同的概念,但它通常依赖于身份验证才能工作。例如,要确定当前用户是否有权访问列表,必须根据用户的 ID、当前团队和团队中的角色做出判断。要访问此类信息,我们首先声明一个类型来表达它:
// schema.zmodel
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id
// User's current team ID
currentTeamId String?
// User's role in the current team
currentTeamRole String?
@@auth
}然后就可以使用访问策略规则中特殊的`auth()`函数来访问当前用户的信息了。我们以`List`模型为例来演示如何定义规则。
// schema.zmodel
model List {
...
// deny anonymous access
@@deny('all', auth() == null)
// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)
// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')
// can be read by org members if not private
@@allow('read', !private)
// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}谜题的最后一部分是,您可能已经想知道,`auth()` 的值来自哪里?在运行时,ZenStack 提供了一个 `enhance()` API 来创建增强的 `PrismaClient`(轻量级包装器),可自动执行访问策略。在调用 `enhance()` 时,您会传入一个用户上下文(通常从身份验证提供程序中获取),该上下文为 `auth()` 提供值。
我们将在下一节详细了解其工作原理。
最后,UI
在深入创建 UI 之前,让我们首先创建一个助手来为当前用户、团队和角色获取增强的“PrismaClient”。
// src/server/db.ts
import { enhance } from "@zenstackhq/runtime";
import { stackServerApp } from "~/stack";
export async function getUserDb() {
const stackAuthUser = await stackServerApp.getUser();
const currentTeam = stackAuthUser?.selectedTeam;
// by default StackAuth's team members have "admin" or "member" role
const perm =
currentTeam && (await stackAuthUser.getPermission(currentTeam, "admin"));
const user = stackAuthUser
? {
userId: stackAuthUser.id,
currentTeamId: stackAuthUser.selectedTeam?.id,
currentTeamRole: perm ? "admin" : "member",
}
: undefined; // anonymous
return enhance(prisma, { user });
}让我们使用 React Server Components (RSC) 和 Server Actions 构建 UI。我们还将始终使用 `getUserDb()` 帮助程序来访问具有访问控制强制功能的数据库。
以下是为当前用户呈现待办事项列表的 RSC(省略样式):
// src/components/TodoList.tsx
// Component showing Todo list for the current user
export default async function TodoLists() {
const db = await getUserDb();
// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});
return (
{/* client component for creating a new List */}
{lists?.map((list) => (
- {list.title}
))}
);
}通过调用服务器操作来创建新列表的客户端组件:
// src/components/CreateList.tsx
"use client";
import { createList } from "~/app/actions";
export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
return (
);
}// src/app/actions.ts
'use server';
import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";
export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}为简洁起见,未显示管理 Todo 项的组件,但其思路类似。您可以在此处找到完整的代码。
结论
身份验证和授权是大多数应用程序的两个基石。对于多租户应用程序来说,构建这两个功能尤其具有挑战性。这篇文章展示了如何通过结合 StackAuth 的“团队”功能和 ZenStack 的访问控制功能来显著简化和精简工作。最终结果是具有极大灵活性和很少样板代码的安全应用程序。
StackAuth 还支持为团队定义自定义权限。虽然本文未涉及,但通过一些调整,您应该能够利用它来定义访问策略。这样,您就可以使用 StackAuth 的仪表板管理权限,并让 ZenStack 在运行时强制执行这些权限。