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;
}
}
}
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);
由于 RLS 是用 SQL 编写的,因此它允许更细粒度的访问控制。开发人员可以定义策略,根据用户的角色或其他属性来确定用户可以访问哪些数据行。理论上,它可以表达您使用的任何授权模式,例如基于角色的访问控制 (RBAC)、基于属性的访问控制 (ABAC)、基于角色的访问控制 (PBAC) 等。
多租户 SaaS 示例
多租户是 SaaS 应用中常用的经典模式。一个应用可以托管多个组织,用户可以加入不同的组织,并根据权限访问资源。我们以待办事项应用为例进行说明。
数据库模型
- 和是通过关系表
User建立Space的多对多关系SpaceUser。 - A
Todo属于 aUser,并且 aList - A
List属于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"
一切看起来都很完美,尤其如果您熟悉 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(',')}))`);
为什么?否则,根据 Supabase 的官方基准测试,您的查询性能可能会降低20 倍。😲
2. 诊断能力差
如果您是 SQL 专家,可以忽略这一点。
对于许多开发者来说,编写 SQL 本身并不是良好的开发体验 (DX),因此大多数开发者都采用了 ORM。此外,你必须在没有智能感知 (Intellisense) 辅助的 UI 框中编写 SQL,而且点击保存按钮后经常会遇到一些莫名其妙的错误:
更具挑战性的部分是测试和调试。说实话,我对这方面的最佳实践一无所知;如果您有任何建议,请在评论区分享。
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)))))
)
你们觉得你们自己能写出这样的政策吗?实际上,我让 ChatGPT 自己写的。即使对你们来说没问题,也请想想其他团队成员第一次看到这份政策时的感受。
为了提升性能,别忘了在你的应用程序代码中也做同样的修改哦。😂
替代方案
还记得 2016 年 Google I/O 大会上 Firebase 宣布扩展至 BaaS 领域吗?同年,另一个名字很有意思的产品也加入了这一行列:Graphcool。
或许你没听说过它,因为它在 2020 年停止服务了,而 Supabase 也是在同一年发布的(真是巧合!)。它为什么停止服务了呢?请查看其主页上的详细说明:
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))
}
请记住,完成此架构后,虽然您仍然需要服务器进行部署,但几乎无需编写任何后端代码。ZenStack 会检查架构并将 CRUD API 安装到您选择的框架中。由于模型中定义了访问策略,这些 API 完全安全,可以直接向公众开放。您还可以生成 OpenAPI(Swagger)规范。
事实上,你甚至无需了解 API,因为 ZenStack 会生成完全类型化的客户端钩子,这些钩子会调用生成的 API。你可以直接使用这些钩子,它们内置了自动失效和乐观更新支持。
那么 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 },
};
};
还记得上面提到的 RLS 情况需要编写的复杂查询吗?
2. 良好的DX
ZenStack 带有VSCode 扩展。它具备 IntelliSense 的所有基本功能,例如自动完成、内联错误报告、跳转到定义、查找引用和自动格式化。
照常工作。

如果你使用了 GitHub 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 } }
]
}
]
}
]
}
}
由于所有代码实际上都在您的服务器上运行,因此您可以在生成的代码中设置断点,逐步调试它。
ZenStack 还提供了一个 REPL 命令行界面,用于交互式查询执行。您可以快速切换不同的用户上下文,并查看访问策略如何影响结果。
3. 可扩展性
你有没有注意到,Todo策略规则中只有一行?
// same as its parent list
@@allow('all', check(list))
List对于 RLS 而言,无需重复制定策略。
此外,如果您需要添加一个新的顶级实体,例如Bug,您只需在架构中添加以下模型:
model Bug extends BaseEntity {
title String @length(1, 100)
priority Int
}
那么它的策略规则呢?得益于模型继承,它可以从抽象基模型继承所有策略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'))
没有什么比代码更能说明问题了。
以下是这款 SaaS 待办事项应用的完整可运行项目:
https://github.com/zenstackhq/sample-todo-nextjs
权衡的艺术
作为开发者,我们比其他人更清楚,任何事物都有两面性。尽管我上面提到了 RLS 的种种问题,但它的另一面也展现出了自身的优势。例如:
- 虽然该策略与应用程序是分开的,但您无需重新部署应用程序即可使新策略生效。
- 虽然 RLS 的 SQL 策略可能不如 ZenStack 的策略直观,但它更灵活,并且拥有更好的生态系统。
软件工程是一门权衡取舍的艺术。它需要在时间和空间、稳定性和灵活性、性能和代码复杂度等诸多方面之间取得平衡。ZenStack 提供的只是另一种选择;如何权衡这些取舍并将其发展成一门艺术,则取决于您自己。
有人已经完成了制作中的美术设计 😉
文章来源:https://dev.to/zenstack/supabase-rls-alternative-n3p我们使用 ZenStack 推出了 MermaidChart 的团队功能。相比编写 RLS 策略或应用层检查(这些方法肯定会在一段时间后出现漏洞),ZenStack 更加简洁,也更容易维护。——
Sidharth MermaidChart



