发布于 2026-01-06 1 阅读
0

Supabase RLS 替代方案

Supabase RLS 替代方案

BaaS简史

在Web和移动应用开发的早期,从零开始构建后端既费力又容易出错。开发人员必须管理服务器、数据库和基础设施,并在编写应用程序核心业务逻辑的同时确保可扩展性。随后,后端即服务(BaaS)应运而生,有望将开发人员从这些负担中解放出来。

Firebase:先驱者

Firebase是最早获得广泛应用的BaaS平台之一。它被谷歌收购后,在2016年的谷歌I/O大会上,宣布扩展其服务,成为面向移动开发者的统一BaaS平台。Firebase凭借其易用性和与谷歌生态系统的无缝集成,迅速赢得了开发者的青睐。

然而,随着项目复杂性的增加,对供应商锁定和数据控制的担忧也日益加剧。其僵化的数据模型和可扩展性问题促使开发人员寻求更灵活、更强大的替代方案。

Supabase:开源软件的有力竞争者

为了应对这些局限性,Supabase 于2020 年应运而生,定位为“开源的 Firebase 替代方案”。它基于 PostgreSQL 构建,在保持开源的同时,提供了更灵活、更强大的数据库解决方案。不出所料,它很快成为数据库即服务 (BaaS) 领域的新标杆。

访问控制层 (ACL) 是核心

虽然 BaaS 承诺提供前端与数据库连接的简单抽象,但它实际上违背了另一个传统的承诺:

你永远不必将数据库直接暴露给前端。

幕后功臣是访问控制列表(ACL),更准确地说是授权机制。它就像守门人一样,守护着你的数据库,防止恶意攻击者入侵。它确保用户只能读取或写入他们有权访问的数据。

Firebase:安全规则

Firebase 引入了一个简单的 DSL 来强制执行访问控制。

service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{postId} {
      allow read: if true;
      allow write: if request.auth != null && request.auth.uid == resource.data.authorId;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Supabase:RLS(行级安全性)

由于 Supabase 是基于 Postgres 构建的,因此它可以利用 PostgreSQL 强大的 RLS 来处理访问控制。

-- owner has full access to her own posts
CREATE POLICY post_owner_policy ON post
    USING (owner = current_user);
Enter fullscreen mode Exit fullscreen mode

由于 RLS 是用 SQL 编写的,因此它允许更细粒度的访问控制。开发人员可以定义策略,根据用户的角色或其他属性来确定用户可以访问哪些数据行。理论上,它可以表达您使用的任何授权模式,例如基于角色的访问控制 (RBAC)、基于属性的访问控制 (ABAC)、基于角色的访问控制 (PBAC) 等。

多租户 SaaS 示例

多租户是 SaaS 应用中常用的经典模式。一个应用可以托管多个组织,用户可以加入不同的组织,并根据权限访问资源。我们以待办事项应用为例进行说明。

数据库模型

数据库模型

  • 和是通过关系表User建立Space的多对多关系SpaceUser
  • ATodo属于 a User,并且 aList
  • AList属于User,并且 aSpace

列表的 RLS 规则

让我们一起来看看模型的访问控制要求List和相应的 RLS 规则。

  • 创造

    • 所有者必须设置为当前用户。
    • 用户必须在该空间内。
    create policy "list_create"
    on "public"."List"
    to public
    with check (
      ((auth.uid() = ("ownerId")::uuid) AND (EXISTS ( SELECT 1
       FROM "SpaceUser"
      WHERE (("SpaceUser"."spaceId" = "List"."spaceId") AND (("SpaceUser"."userId")::uuid = auth.uid())))))
    );
    
    • 所有者可以阅读。
    • 如果不是私密的,空间成员可以阅读。
    create policy "list_read"
    on "public"."List"
    to authenticated
    using (
       ((auth.uid() = ("ownerId")::uuid) OR ((NOT private) AND (EXISTS (SELECT 1
       FROM "SpaceUser"
      WHERE (("SpaceUser"."spaceId" = "List"."spaceId") AND (("SpaceUser"."userId")::uuid = auth.uid()))))))
    );
    
  • 更新

    • 只有所有者才能更新
    • 所有者必须在当前列表中。
    • 它不允许更改所有者
    create policy "list_update"
    on "public"."List"
    to authenticated
    using (
      ((auth.uid() = ("ownerId")::uuid) AND (EXISTS ( SELECT 1
       FROM "SpaceUser"
      WHERE (("SpaceUser"."spaceId" = "List"."spaceId") AND (("SpaceUser"."userId")::uuid = auth.uid())))))
    with check (
      (auth.uid() = ("ownerId")::uuid)
    );
    
  • 删除

    • 所有者可以删除
    create policy "list.delete"
    on "public"."List"
    to authenticated
    using (
      (auth.uid() = ("ownerId")::uuid)
    );
    

Supabase 使用PostgREST提供 RESTful API 。但是,如果没有行级安全 (RLS),您的数据库将暴露给前端。通过上面创建的 RLS 策略,可以安全地将 API 公开,因为每个用户只能访问策略允许的数据。例如,如果您尝试List使用下面的 API 获取所有项目,您只会收到读取策略允许您读取的项目:

curl '{SUPABASE_PROJECT_URL}/rest/v1/List?select=*' \
-H "apikey: SUPABASE_ANON_KEY" \
-H "Authorization: Bearer USER_JWT_TOKEN"
Enter fullscreen mode Exit fullscreen mode

一切看起来都很完美,尤其如果您熟悉 SQL 的话。那么我为什么还需要其他方案呢?

完美梗

Supabase RLS 的问题

1.与应用程序逻辑分离

作为一名现代开发者,您一定深知一键启动带来的掌控感。而前提是所有工作都必须在代码库内完成。这不仅仅是为了方便,更是为了维护单一数据源,确保代码一致性,并简化您的工作流程。

然而,对于行级别安全性 (RLS),您必须直接在数据库中定义授权,而不是在源代码中。当然,您可以将其作为 SQL 文件存储在代码库中,但您需要依赖 SQL 迁移来确保一致性。我认为这与如今很少有人使用数据库存储过程的原因相同,尽管它们有很多优点。

更糟糕的是,您还需要在应用程序代码中复制策略过滤器。例如,如果您使用的是 Supabase JS SDK,则必须使用以下两个查询才能获得结果:

// First, get the user's spaceIds
const { data: userSpaces } = await supabase.from('SpaceUser').select('spaceId').eq('userId', userId);

// Extract spaceIds from the result
const userSpaceIds = userSpaces.map((space) => space.spaceId);

// Now, query the List table
const { data, error } = await supabase
    .from('List')
    .select('*')
    .or(`ownerId.eq.${userId},and(private.eq.false,spaceId.in.(${userSpaceIds.join(',')}))`);
Enter fullscreen mode Exit fullscreen mode

为什么?否则,根据 Supabase 的官方基准测试,您的查询性能可能会降低20 倍。😲

为每个查询添加筛选条件 | Supabase 文档

2. 诊断能力差

如果您是 SQL 专家,可以忽略这一点。

对于许多开发者来说,编写 SQL 本身并不是良好的开发体验 (DX),因此大多数开发者都采用了 ORM。此外,你必须在没有智能感知 (Intellisense) 辅助的 UI 框中编写 SQL,而且点击保存按钮后经常会遇到一些莫名其妙的错误:

supabase 错误

更具挑战性的部分是测试和调试。说实话,我对这方面的最佳实践一无所知;如果您有任何建议,请在评论区分享。

3. 可扩展性

你可能觉得前面提到的 RLS 配置List清晰明了,但这仅适用于单个表。如果表的访问控制策略与表Todo相同(List这种情况很常见),那么你需要为表创建什么样的策略呢?答案是,你必须复制Todo的所有策略。这显然不符合 DRY(Don't Repeat Yourself,不要重复自己)原则。listTodo

如果你觉得这没什么大不了,想象一下,如果你足够幸运,能够将你的待办事项 SaaS 发展成为一个团队协作平台,管理各种实体,如仪表板、任务、错误、项目等。

4. 可维护性

假设我们收到一个功能请求,如果团队成员可以看到待办事项列表,他将拥有该列表下所有待办事项的完全访问权限,即使是那些不属于他的待办事项。

你知道需要做哪些更改吗?你需要删除所有从List上述位置复制的策略,然后在下方创建一个新策略:

create policy "Todo"
on "public"."Todo"
to authenticated
using (
  ((EXISTS ( SELECT 1
   FROM "List"
  WHERE (("List".id = "Todo"."listId") AND (("List"."ownerId")::uuid = auth.uid())))) OR (EXISTS ( SELECT 1
   FROM (("List"
     JOIN "Space" ON (("List"."spaceId" = "Space".id)))
     JOIN "SpaceUser" ON (("SpaceUser"."spaceId" = "Space".id)))
  WHERE (("List".id = "Todo"."listId") AND (("SpaceUser"."userId")::uuid = auth.uid()) AND (NOT "List".private)))))
)
Enter fullscreen mode Exit fullscreen mode

你们觉得你们自己能写出这样的政策吗?实际上,我让 ChatGPT 自己写的。即使对你们来说没问题,也请想想其他团队成员第一次看到这份政策时的感受。

为了提升性能,别忘了在你的应用程序代码中也做同样的修改哦。😂

替代方案

还记得 2016 年 Google I/O 大会上 Firebase 宣布扩展至 BaaS 领域吗?同年,另一个名字很有意思的产品也加入了这一行列:Graphcool。

或许你没听说过它,因为它在 2020 年停止服务了,而 Supabase 也是在同一年发布的(真是巧合!)。它为什么停止服务了呢?请查看其主页上的详细说明:

https://www.graph.cool/

TLDR:团队认为 BaaS 对于开发人员构建下一代 Web 应用程序来说限制太多,因此他们转向了Prisma ORM

它通过提供类型安全的查询构建器、无缝迁移和直观的数据建模语言来简化数据库交互。虽然 Prisma ORM 相比 BaaS 提供了更大的灵活性,但作为 ORM,它有意省略了访问控制层。因此,您需要在应用层重新实现授权逻辑。

是否有可能在保持自定义后端灵活性的同时,恢复像 BaaS 那样无需编写授权代码的便利性?

这就是为什么我们基于 Prisma ORM构建了ZenStack ,添加了缺失的授权层并自动生成类型安全的 API/钩子。它既能提供与使用 BaaS 相同的便利,又能保持代码库中所有内容的灵活性。

咱们别废话了,直接看代码。下面是与待办事项应用对应的 ZenStack schema 定义,你需要为它们编写相应的List代码Todo

abstract model BaseEntity {
    id        String   @id @default(uuid())
    space     Space    @relation(fields: [spaceId], references: [id], onDelete: Cascade)
    spaceId   String
    owner     User     @relation(fields: [ownerId], references: [id], onDelete: Cascade)
    ownerId   String   @default(auth().id)

    // can be read by owner or space members 
    @@allow('read', owner == auth() || (space.members?[user == auth()]))

    // when create, owner must be set to current user, and user must be in the space
    @@allow('create', owner == auth() && space.members?[user == auth()])

    // when create, owner must be set to current user, and user must be in the space
    // update is not allowed to change owner
    @@allow('update', owner == auth() && space.members?[user == auth()] && future().owner == owner)

    // can be deleted by owner
    @@allow('delete', owner == auth())
}

/**
 * Model for a Todo list
 */
model List extends BaseEntity {
    title   String  @length(1, 100)
    private Boolean @default(false)
    todos   Todo[]

    // can't be read by others if it's private
    @@deny('read', private == true && owner != auth())
}

/**
 * Model for a single Todo
 */
model Todo {
    id          String    @id @default(uuid())
    owner       User      @relation(fields: [ownerId], references: [id], onDelete: Cascade)
    ownerId     String    @default(auth().id)
    list        List      @relation(fields: [listId], references: [id], onDelete: Cascade)
    listId      String
    title       String    @length(1, 100)
    completedAt DateTime?

    // same as its parent list
    @@allow('all', check(list))
}
Enter fullscreen mode Exit fullscreen mode

请记住,完成此架构后,虽然您仍然需要服务器进行部署,但几乎无需编写任何后端代码。ZenStack 会检查架构并将 CRUD API 安装到您选择的框架中。由于模型中定义了访问策略,这些 API 完全安全,可以直接向公众开放。您还可以生成 OpenAPI(Swagger)规范。

事实上,你甚至无需了解 API,因为 ZenStack 会生成完全类型化的客户端钩子,这些钩子会调用生成的 API。你可以直接使用这些钩子,它们内置了自动失效和乐观更新支持。

tanstack-hook

那么 RLS 存在哪些问题呢?让我们逐一分析。

1. 单一信息来源

现在,访问策略与数据库模型一同定义。模式成为后端唯一的数据源,从而有助于更轻松地理解整个系统。

此外,无论从前端还是后端调用,您都不需要使用任何关于授权规则的过滤器,ZenStack 运行时会自动将授权规则注入到查询中。您需要编写的应用程序代码简洁明了:

   // frontend query:hook is auto generated by ZenStack
   const { data: lists } = useFindManyList();    
   ...

   // server props  
   export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res, params }) => {
    const db = await getEnhancedPrisma({ req, res });

    const lists = await db.list.findMany();
    return {
        props: { lists },
    };
};
Enter fullscreen mode Exit fullscreen mode

还记得上面提到的 RLS 情况需要编写的复杂查询吗?

2. 良好的DX

ZenStack 带有VSCode 扩展。它具备 IntelliSense 的所有基本功能,例如自动完成、内联错误报告、跳转到定义、查找引用和自动格式化。

照常工作。

zmodel-autocomplete
如果你使用了 GitHub Copilot,那么你很可能不需要自己编写策略:

zmodel-copilot

为了进行测试和调试,您可以通过设置一个简单的标志来启用 ZenStack 的调试日志记录。然后,您可以从控制台日志中看到 ZenStack 实际向数据库调用的所有 Prisma 查询:

prisma:info [policy] `findMany` list:
{
  where: {
    AND: [
      { NOT: { OR: [] } },
      {
        OR: [
          { owner: { is: { id: 1 } } },
          {
            AND: [
              {
                space: {
                  members: {
                    some: { user: { is: { id: 1 } } }
                  }
                }
              },
              { NOT: { private: true } }
            ]
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

由于所有代码实际上都在您的服务器上运行,因此您可以在生成的代码中设置断点,逐步调试它。

ZenStack 还提供了一个 REPL 命令行界面,用于交互式查询执行。您可以快速切换不同的用户上下文,并查看访问策略如何影响结果。

3. 可扩展性

你有没有注意到,Todo策略规则中只有一行?

    // same as its parent list
    @@allow('all', check(list))
Enter fullscreen mode Exit fullscreen mode

List对于 RLS 而言,无需重复制定策略。

此外,如果您需要添加一个新的顶级实体,例如Bug,您只需在架构中添加以下模型:

model Bug extends BaseEntity {
    title    String @length(1, 100)
    priority Int
}
Enter fullscreen mode Exit fullscreen mode

那么它的策略规则呢?得益于模型继承,它可以从抽象基模型继承所有策略BaseEntity

如您所见,ZenStack 提供了多种功能来保持模式的 DRY(Don't Repeat Yourself,避免重复),并实现更好的可扩展性。

4. 可维护性

实现上述需求变更请求只需添加一个参数。

// full access if the parent list is readable
// @@allow('all', check(list))
   @@allow('all', check(list, 'read'))
Enter fullscreen mode Exit fullscreen mode

没有什么比代码更能说明问题了。

以下是这款 SaaS 待办事项应用的完整可运行项目:

https://github.com/zenstackhq/sample-todo-nextjs

权衡的艺术

作为开发者,我们比其他人更清楚,任何事物都有两面性。尽管我上面提到了 RLS 的种种问题,但它的另一面也展现出了自身的优势。例如:

  • 虽然该策略与应用程序是分开的,但您无需重新部署应用程序即可使新策略生效。
  • 虽然 RLS 的 SQL 策略可能不如 ZenStack 的策略直观,但它更灵活,并且拥有更好的生态系统。

软件工程是一门权衡取舍的艺术。它需要在时间和空间、稳定性和灵活性、性能和代码复杂度等诸多方面之间取得平衡。ZenStack 提供的只是另一种选择;如何权衡这些取舍并将其发展成一门艺术,则取决于您自己。

有人已经完成了制作中的美术设计 😉

我们使用 ZenStack 推出了 MermaidChart 的团队功能。相比编写 RLS 策略或应用层检查(这些方法肯定会在一段时间后出现漏洞),ZenStack 更加简洁,也更容易维护。——

Sidharth MermaidChart

文章来源:https://dev.to/zenstack/supabase-rls-alternative-n3p