02数据库5 min

为什么我用过 RLS 的两次,最后都把它移除了

行级安全看起来是默认就该有的正确实践。但在有 Drizzle、server actions 和自建认证的情况下,权限逻辑已经住在别的地方,同时维护两套很疼。

作者 Salva Castells

封面图: Paulo Humberto, CC BY-SA 3.0.
本文目录

我的 profile schema 里曾有这样一条策略,用 Drizzle 给 Neon 的 helper 写的:

crudPolicy({
  role: authenticatedRole,
  read: authUid(table.userId),
  modify: false,
})

三行。它说的是:一个已认证用户只能看到自己的行,并且不能修改它们。这很难反驳。一年半之后,我在同一个提交里删掉了这三行,以及 feedback 表上的那几行。几个月后,我在另一个继承了同一份 schema 的项目里又删了一次。

这不是一时兴起。而是因为策略和代码说的是同一件事,而当两个地方说同一件事时,总有一个最后会撒谎。

RLS 的承诺,以及我为什么买账

这个承诺很好:权限住在数据库里,所以谁来查都无所谓。如果我明天写个维护脚本,或者开一个新 endpoint,或者有人漏写了一个 where,那一行也出不来。这是真正的纵深防御,不是装饰。

我围着它搭了一整套。两个连接:

export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
  const authToken = (await user?.getAuthJson())?.accessToken;
  const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
  const sql = neon(url, { authToken });
  return drizzle(sql);
};

一个"已认证"连接,带着用户的 JWT,遵守策略;另一个 admin 连接用普通的 DATABASE_URL,绕过策略。我甚至一度按用户 id 缓存了一张 RLS 连接的映射表,直到我意识到自己在缓存绑定了会过期的 token 的连接,于是改回每次新建一个。

它在 Drizzle 和 server actions 面前开始别扭的地方

问题不是 RLS 会失灵。而是在一个用 Next 和 server actions 的应用里,它几乎从来不是做决定的那一层。

每个 server action 一开始就在解析会话。你在碰数据库之前就已经拿到了用户。然后你写下这个:

const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));

这个 where 和策略是重复的。但你不能把它删掉,因为删掉之后代码就在撒谎:它看起来像是取出全部 feedback,却依赖着另一个文件里一条看不见的策略。于是你在代码里和数据库里都留着过滤,并且始终不确定真正生效的是哪一个。

第二件事是日常里的摩擦:

  • 两个连接。 每写一个新函数,开头就要问一次:走 RLS 还是走 admin?而所有管理操作、支付 webhook、定时任务和迁移都走 admin。也就是说:走那条什么都不检查的路。
  • 报错什么都不说。 策略挡住你的时候,它不会说"你缺一个 claim";它返回零行。行明明在,select 却返回空数组——调试这个比看起来贵得多。
  • token 跑到了你不希望它去的地方。 那个已认证的 URL 最后落在一个 NEXT_PUBLIC_ 前缀的变量里,这本身就逼你反复思考自己暴露了什么。
  • 策略住在 snapshot 里。 改一条策略就要生成迁移并应用它。改一处代码里的检查就是改一行。

事实上,有一阵子我整个绕过了 Drizzle,把 feedback 和 profile 改成走 PostgREST,就是为了真正地依靠 RLS。那次代价看得很清楚:我失去了类型,不得不手写 snake_case 的列名(user_id、created_at),而且每次调用都以同样的仪式收尾。

const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];

结尾一个 as Feedback[]。也就是相信数据库返回的就是我说的东西。两周后它就回到 Drizzle 了。

第二个项目里同样的结局

当我基于同一套脚手架开始下一个项目时,策略是自带的。它们活到我认真动 feedback 表为止。删掉它们的那个提交同时还做了另一件事:把 feedback.description 改名成 feedback.content。这是个很小的细节,但它讲完了整个故事:我当时正在给数据模型塑形,而策略是挡着我移动它的那个东西。在那个提交里,drizzle-orm/neon 的 import 和四处 crudPolicy 调用一起消失了。表上只留下了真正每天都在用的那一样东西:

(table) => [index("idx_feedback_user_id").on(table.userId)]

一个 user_id 上的索引。这个 Postgres 每个请求都会用到。

我的权限逻辑今天住在哪里

在中间件和 server action 层里。当我离开托管的认证、自己写会话中间件之后,"谁能看到什么"的决定点就收拢到了一个地方:解析会话、检查角色,此后所有查询都带着已经拿在手里的 userId 来写。只有一个连接,admin 的那个,没有并行的特权通道。

纸面上这更不安全。一个漏了 where 的查询会返回别人的行。我用两件事补偿:这些查询全都住在一个 actions 目录里,而不是散落在整个应用里;以及函数签名强制你把用户传进来。

什么时候我会重新用 RLS

当数据库有不止一个客户端时。如果除了我的应用之外还有一个公开的 PostgREST、一个直连的客户端,或者拥有只读权限的分析人员,那么策略就是唯一一个能对所有人生效的地方。这时维护两份真相的代价是划算的。

还有硬多租户的场景,那里的故障不是"你看到了别人的一条 feedback",而是"你看到了另一家公司的数据"。那种情况下,即使代码已经过滤了,我也要下面这张网。

而对于一个单客户端应用,有 server actions、并且在第一次查询之前就已解析出用户,RLS 只是你已经写下的规则的第二份副本。而写了两遍的规则不是安全性翻倍:是变得两倍容易失去同步。