使用 StackAuth 的“Teams”和 Next.js 构建多租户应用程序

构建一个功能齐全的多租户应用程序可能非常具有挑战性。除了拥有灵活的注册和登录系统外,您还需要实现其他几个基本部分:

  • 创建和管理租户
  • 用户邀请流程
  • 管理角色和权限
  • 在整个应用程序中强制数据隔离和访问控制
  • 这听起来工作量很大,事实也确实如此。如果您是资深 SaaS 开发人员,您可能已经做过多次了。

    StackAuth 是一个开源身份验证和用户管理平台,旨在无缝集成到 Next.js 项目中。它结合了前端/后端 API 和预构建的 UI 组件,大大简化了将此类功能集成到应用程序中的过程。同样,其较新的“Teams”功能为创建多租户应用程序提供了一个绝佳的起点。在本文中,我们将探索如何利用它来构建一个非同寻常的应用程序,同时尽量保持代码的简洁和干净。

    目标和堆栈

    我们将构建的目标应用程序是 Todo List。其核心功能很简单:创建列表并在其中管理待办事项。但是,重点将放在多租户和访问控制方面:

  • 团队管理
  • 用户可以创建团队并邀请其他人加入。他们可以管理成员并设置他们的角色。

  • 当前背景
  • 用户可以选择一个团队作为当前上下文。

  • 数据隔离
  • 仅可访问当前团队内的数据。

  • 基于角色的访问控制管理员成员对其团队内的所有数据拥有完全访问权限。普通成员对其拥有的待办事项列表拥有完全访问权限。普通成员可以查看其他成员的待办事项列表并管理其内容,只要列表不是私密的。
  • 我们将使用以下基本武器来构建应用程序:

  • Next.js:全栈框架
  • StackAuth:用户身份验证和团队管理
  • Prisma:我们用来与数据库通信的 ORM
  • ZenStack:Prisma 上方的授权层,用于处理数据隔离和访问控制
  • 您可以在文章末尾找到已完成项目的链接。

    添加团队管理

    我假设您已经创建了一个 Next.js 项目并完成了 StackAuth 设置指南中所述的步骤。验证基本注册/登录流程是否正常运行。此外,在 StackAuth 的管理控制台中,在“团队设置”部分中启用“客户端团队创建”和“自动团队创建”选项。

    Team Settings

    现在,我们可以将“SelectedTeamSwitcher”组件添加到布局中。

    // src/app/layout.tsx
    
    import { SelectedTeamSwitcher } from "@stackframe/stack";
    ...
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        
          
            
              
                
    {children}
    ); }

    通过这一行代码,您将拥有一组功能齐全的 UI 组件,用于管理团队并选择一个活跃的团队!

    Team Management

    尽管 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 在运行时强制执行这些权限。