构建一个功能完善的多租户应用程序可能非常有挑战性。除了要有灵活的注册和登录系统之外,你还需要实现几个其他关键的功能模块。
- 租户的创建与管理
- 邀请用户流程
- 管理角色和权限
- 在整个应用程序中实现数据隔离和访问控制
这确实听起来像很多工作。如果你是经验丰富的SaaS开发者,你可能已经多次做过这样的工作了。
StackAuth 是一个开源的身份验证和用户管理平台,旨在无缝集成到 Next.js 项目中。它结合了前端和后端 API 以及预构建的 UI 组件,大大简化了功能集成过程。同样,它的新的“团队”功能为创建多租户应用提供了一个很好的起点。本文将探讨如何利用它来构建一个非平凡的应用程序,同时尽量保持代码简洁清晰。
目标与技术栈我们将要构建的应用程序是一个待办事项列表。它的核心功能很简单:创建列表并在列表中管理待办事项。不过,重点将放在多租户支持和访问控制上。
- 团队管理
用户可以创建团队,邀请别人加入,并管理成员,分配角色。
当前情境
用户可以选一个团队作为当前的背景。
- 数据隔离
只能访问当前团队的数据。
-
基于角色的访问控制
- 管理员可以访问他们团队内的所有数据。
- 普通用户可以完全访问他们自己创建的待办事项列表。
- 普通用户可以查看并管理其他成员的待办事项列表,只要这些待办事项列表不是设置为私密的。
我们用来构建App的必备工具是
- Next.js: 全栈框架 (Next.js)
- StackAuth: 用户认证及团队管理
- Prisma: 我们用来与数据库通信的 ORM (Prisma)
- ZenStack: 位于 Prisma 之上的授权层,处理数据隔离和访问控制
您可以在帖子最后找到完成项目的链接。
团队管理的添加你已经创建了一个 Next.js 项目吧,并且按照 StackAuth 的设置指南完成了所有步骤,。确认基本的注册和登录流程是否正常运行。另外,在 StackAuth 的管理控制台里,启用“客户端团队创建”和“自动团队创建”的选项,在“团队设置”中的选项。
现在,我们可以把“SelectedTeamSwitcher”组件加到布局中了。
// src/app/layout.tsx
import { SelectedTeamSwitcher } from "@stackframe/stack";
...
// 导出默认的 RootLayout 函数,它接收一个包含 children 属性的对象作为参数
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
// 使用 StackProvider 组件包裹整个布局,并传递应用程序实例作为属性
<StackProvider app={stackServerApp}>
// StackTheme 组件用于设置主题
<StackTheme>
// header 区域包含 SelectedTeamSwitcher 组件,用于切换团队
<header>
<SelectedTeamSwitcher />
</header>
// main 区域包含传入的 children 组件
<main>{children}</main>
</StackTheme>
</StackProvider>
</body>
</html>
);
}
全屏模式 退出全屏
有了这句话,你将获得一整套用于管理团队并选择一个团队的完整UI组件!
(团队管理)
尽管 StackAuth 让添加“团队”功能变得轻松实现,但如何利用用户和团队信息来控制数据访问则取决于你。接下来我们将看看如何将其与 Prisma/ZenStack 结合以实现授权。
搭建数据库环境我们的用户和团队数据存储在 StackAuth 方面。我们需要将待办事项列表和项目存储在我们自己的数据库中。在此部分,我们将配置 Prisma 和 ZenStack 并建立数据库架构。
我们先来安装所需的软件包。
// 安装 Prisma 和 Zenstack 开发依赖
npm install --save-dev prisma zenstack
// 安装 Prisma 客户端和 Zenstack 运行时
npm install @prisma/client @zenstackhq/runtime
点击全屏模式 / 点击退出全屏
然后我们可以创建数据库架构。请注意,我们将创建一个 schema.zmodel 文件(替代原来的 "schema.prisma" 文件)。ZModel 语言 不仅允许你定义数据模式,还允许你定义访问控制策略。在本节中,我们将仅关注数据建模方面。
// schema.zmodel
数据源定义 db {
提供程序 = "postgresql"
url = env("DATABASE_URL")
}
生成器定义 js {
提供程序 = "prisma-client-js"
}
// 待办事项列表
模型定义 List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?
ownerId String
todos Todo[]
}
// 待办事项
模型定义 Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
切换到全屏 退出全屏
你可以生成一个普通的 Prisma 模式文件,并将该模式推送到数据库里。
# `zenstack generate` 命令会创建 "prisma/schema.prisma" 文件并运行 "prisma generate"
zenstack generate
npx prisma db push # 执行数据库迁移命令
全屏 退出全屏
最后,在项目中,创建一个 "src/server/db.ts" 文件以导出 Prisma 客户端实例。
// src/server/db.ts
// 引入 PrismaClient 类
import { PrismaClient } from "@prisma/client";
// 导出一个新的 PrismaClient 实例
export const prisma = new PrismaClient();
全屏模式,退出全屏
实施访问控制如前所述,ZenStack 允许你在单一模式中建模数据和访问控制。让我们看看如何完全通过它来满足我们的授权需求。规则通过 @@allow
和 @@deny
属性来定义。默认情况下,访问被拒绝,除非明确地通过 @@allow
规则授予访问权限。
尽管授权和认证是两个不同的概念,授权通常依赖于认证来运行。例如,要确定当前用户是否能访问某个列表,需要根据用户的ID、当前团队和在团队中的角色来判断。为了表示这些信息,让我们先定义一个类型来表示它们。
// schema.zmodel
// auth 的定义
type Auth {
// 当前用户 ID
userId String @id
// 当前团队 ID
currentTeamId String?
// 当前团队角色
currentTeamRole String?
@@auth
}
点击进入全屏 点击退出全屏
然后你可以使用访问策略规则中的特殊 auth()
函数来访问当前用户的信息。我们以 List
模型为例来说明这些规则是如何设定的。
// schema.zmodel
model 列表模型 {
...
// 拒绝未认证访问
@@deny('all', auth() == null)
// 租户隔离:如果用户当前组织与目标组织不匹配,则拒绝访问
@@deny('all', auth().currentOrgId != orgId)
// 所有者或管理员具有全部访问权限
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')
// 如果非私有,组织成员可以读取
@@allow('read', !private)
// 创建时,必须将所有者设置为当前用户
@@allow('create', ownerId == auth().userId)
}
切换到全屏模式并随时退出全屏
最后一个谜题是,正如你可能已经猜到的,auth()
的值是从哪里来的?在运行时,ZenStack 提供了一个 enhance()
API,用于生成一个增强版的 PrismaClient
(一个轻量级的包装),该包装会自动实施访问策略。当你调用 enhance()
时,需要传递一个用户上下文(通常是从认证服务提供商获取的),这个上下文提供了 auth()
的值来源。
我们将在下一节详细看看它是怎么工作的。
最后,用户界面在开始构建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;
// 默认情况下,StackAuth 的团队成员具有 "admin" 或 "member" 角色的身份
const perm =
currentTeam && (await stackAuthUser.getPermission(currentTeam, "admin"));
const user = stackAuthUser
? {
userId: stackAuthUser.id,
currentTeamId: stackAuthUser.selectedTeam?.id,
currentTeamRole: perm ? "admin" : "member",
}
: undefined; // 根据上下文,此处可以省略注释
return enhance(prisma, { user });
}
点击这里切换到全屏模式)或退出全屏模式)
让我们使用React Server Components(RSC)和Server Actions来构建用户界面。我们还将始终使用getUserDb()(获取用户数据库的帮助函数)
来访问受控访问的数据库。
这里有一个RSC,它会为当前用户渲染待办事项(样式部分已省略)。
// src/components/TodoList.tsx
// 显示当前用户待办事项列表的组件
export default async function TodoLists() {
const db = await getUserDb();
// 增强的 PrismaClient 自动过滤出无法访问的列表
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});
return (
<div>
<div>
{/* 客户端组件,用于创建新列表 */}
<CreateList />
<ul>
{lists?.map((list) => (<Link href={`/lists/${list.id}`} key={list.id}><li>{list.title}</li></Link>))}
</ul>
</div>
</div>
);
}
点击进入全屏播放,退出全屏
客户端组件通过调用服务器上的动作创建新列表。
// src/components/CreateList.tsx
"use client";
import { createList } from "~/app/actions";
export default function CreateList() {
function onCreate() {
const title = prompt("请输入你的列表标题");
if (title) {
createList(title);
}
}
return (
<button onClick={onCreate}>
新建列表
</button>
);
}
全屏查看;退出全屏
// src/app/actions.ts
'use server';
import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";
export async function 创建清单(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
全屏模式;退出全屏模式
管理Todo项的组件为了简洁起见省略了展示,但是其思路是相似的。你可以在这里找到完整的代码实现here。
结论部分认证和授权是大多数应用程序的两大基石。对于多租户应用来说,构建起来可能特别有挑战性。本文展示了如何通过结合StackAuth的“团队”功能和ZenStack的权限控制能力,大大简化了这一过程。最终结果是一个既安全又灵活,且几乎不需要样板代码的应用程序。
StackAuth 还支持为团队设定自定义权限。虽然这里没有详细介绍,但经过一些调整,你可以利用它来设定访问策略。这样一来,你就可以在 StackAuth 的仪表板上管理这些权限,并让 ZenStack 在运行时强制实施这些权限。
共同學習,寫下你的評論
評論加載中...
作者其他優質文章