当嵌入式 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:全栈框架
  • Better-Auth:用户身份验证和组织管理
  • Prisma:我们用来与数据库通信的 ORM
  • ZenStack:通过访问控制和自动 CRUD API 增强 Prisma
  • TanStack Query:数据获取/缓存库
  • 此堆栈的好处是所有内容都嵌入在 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

    生成的模式文件应包含以下模型:

  • 用户:已注册的用户
  • 会话:用户会话
  • 帐户:OAuth 帐户(未使用)
  • 验证:注册验证记录
  • 組織: 一組織
  • 成员:组织的成员(用户和组织之间的连接表)
  • 邀请:加入组织的邀请
  • 初始仪表板 UI 如下所示:

    Initial 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 演示项目。

    入门

  • 将“.env.example”复制到“.env”,并填入变量。
  • 安装依赖项 npm install
  • 准备数据库 npx zenstack generate npx prisma db push
  • 启动开发服务器 npm run dev
  • 在 GitHub 上查看

    结论

    身份验证和授权是大多数应用程序的两个基石。对于多租户应用程序来说,构建身份验证和授权尤其具有挑战性。这篇文章展示了如何通过结合 better-auth 和 ZenStack 来显著简化和精简工作。最终结果是具有极大灵活性和很少样板代码的安全应用程序。

    Better-auth 还支持为组织定义自定义权限。虽然本文未涉及,但通过一些调整,您应该能够利用它来定义访问策略。这样,您就可以使用 better-auth 的 API 管理权限,并让 ZenStack 在运行时强制执行这些权限。