1 分钟

面向 SaaS 的 PostgreSQL 行级安全:有效的策略

面向 SaaS 的 PostgreSQL 行级安全(RLS)有助于在数据库层强制实施租户隔离。了解何时使用、如何编写策略以及应避免的陷阱。

面向 SaaS 的 PostgreSQL 行级安全:有效的策略

RLS 在 SaaS 应用中要解决的真实问题

在 SaaS 应用里,最危险的安全漏洞通常会在你扩展后出现。你一开始使用一个简单规则,比如“用户只能看到其租户的数据”,然后很快上线一个新端点、增加一个报表查询,或者引入一个静悄悄绕过检查的连接(join)。

仅靠应用层的授权在压力下会崩溃,因为规则散落在各处。某个控制器检查 tenant_id,另一个检查成员关系,一个后台作业忘了做检查,而“管理员导出”路径则被标记为“临时”并持续数月。即便是谨慎的团队也会遗漏某处。

PostgreSQL 的行级安全(RLS)解决了一个特定问题:让数据库来强制哪个请求能看到哪些行。思路很简单:每个 SELECTUPDATEDELETE 都会自动通过策略过滤,就像每个请求都会通过认证中间件一样。

“行”这个维度很关键。RLS 并不能保护一切:

  • 它本身不会隐藏特定列(这需要视图或列权限)。
  • 它不会使不安全的函数变得安全(函数如果以提升的权限运行,仍可能泄露数据)。
  • 它不会替代业务规则校验(例如“只有所有者可以更改计费设置”)。

举个具体例子:你新增一个列出项目并与发票连接用于仪表盘的端点。仅靠应用层授权,很容易对 projects 做了租户过滤但忘了对 invoices 也做,或者连接了会跨租户的键。有了 RLS,两个表都可以强制租户隔离,这样查询会安全失败而不是泄露数据。

这不是没有代价的。你会写更少的重复授权代码并降低泄露点,但你也要承担新的工作:需要谨慎设计策略、尽早测试,并接受某个策略可能会阻止你本以为能运行的查询。

RLS 在什么时候简化授权(什么时候增加痛点)

在应用只有少数端点之前,RLS 可能感觉像额外工作。如果你的租户边界严格且存在大量查询路径(列表页、搜索、导出、管理员工具),把规则放到数据库里就不必记得到处添加相同的过滤条件。

当规则枯燥且普适时,RLS 非常合适:比如“用户只能看到其租户的行”或“用户只能看到其有成员关系的项目”。在这些场景下,策略能减少出错,因为每个 SELECTUPDATEDELETE 都会通过相同的门,即便后来添加了新的查询也一样。

对于以读取为主且过滤逻辑保持一致的应用也很有帮助。如果你的 API 有 15 种不同方式加载发票(按状态、按日期、按客户、按搜索),RLS 让你不必在每个查询上重实现租户过滤,而能专注于功能实现。

当规则不是基于行时,RLS 会带来痛点。例如按字段的规则像“你能看到工资但看不到奖金”或“除非你属于 HR,否则掩码此列”常会演变成尴尬的 SQL 和难以维护的例外情况。

它也不太适合真正需要广泛访问的重度报表场景。团队常为“就这个任务”创建绕过角色,而错误也正是在这些地方堆积的。

在决定采用前,考虑是否希望数据库成为最终的把关者。如果答案是肯定的,就要为此做好纪律准备:测试数据库行为(不仅仅是 API 响应)、把迁移当作安全变更、避免仓促的绕过、决定后台作业如何认证,并保持策略简洁且可复用。

如果你使用能生成后端的工具,它能加速交付,但并不能替代清晰的角色定义、测试以及简单的租户模型。(例如 Koder.ai 使用 Go 和 PostgreSQL 生成后端,但你仍应有意设计 RLS,而不是“以后再加”。)

让 RLS 策略易于管理的数据模型基础

当你的 schema 已经明确说明谁拥有什么时,RLS 最容易使用。如果你从一个模糊的模型开始并试图“在策略里修补”,通常会得到慢查询和令人困惑的错误。

把租户键放在应在的位置

选择一个租户键(例如 org_id)并一致使用。大多数租户拥有的表都应该有它,即便它们也引用了另一个已含该键的表。这样可以避免在策略里做连接并保持 USING 检查简单。

一个实用规则:如果一行在客户取消后应该消失,它很可能需要 org_id

明确建模成员关系

RLS 策略通常回答一个问题:“这个用户是该 org 的成员吗,他能做什么?”从临时字段推断这个信息很困难。

把核心表保持小而明确:

  • users(每个人一行)
  • orgs(每个租户一行)
  • org_membershipsuser_id, org_id, role, status
  • 可选:project_memberships 用于按项目访问

有了这些,策略可以通过一次带索引的查找来校验成员关系。

将共享参考数据与租户拥有的数据分离

不是所有东西都需要 org_id。像国家、产品分类或计划类型这样的参考表通常在所有租户间共享。把它们对大多数角色设为只读,不要把它们绑定到某一 org 上。

租户拥有的数据(项目、发票、工单)应避免通过共享表拉入租户特定细节。让共享表保持最小且稳定。

外键、级联与索引

外键在 RLS 下仍然有效,但如果删除的角色不能“看到”被依赖的行,删除操作可能让你吃惊。仔细规划级联并测试真实的删除流程。

对策略过滤的列做索引,尤其是 org_id 和成员键。像 WHERE org_id = ... 这样的策略在表达到数百万行时不应退化为全表扫描。

不带恐惧感地理解 RLS 的工作方式

RLS 是按表的开关。一旦启用,PostgreSQL 就不再信任你的应用代码会记住租户过滤。每个 SELECTUPDATEDELETE 都会被策略过滤,每个 INSERTUPDATE 都会由策略校验。

最大的思维转变是:启用 RLS 后,原本会返回数据的查询可能开始返回零行而没有报错——那是 PostgreSQL 在做访问控制。

策略实际做什么

策略是附着在表上的小规则。它们使用两类检查:

  • USING 是读取过滤。如果一行不匹配 USING,在 SELECT 时它是不可见的,并且不能作为 UPDATEDELETE 的目标。
  • WITH CHECK 是写入门槛。它决定 INSERTUPDATE 时允许的新或被修改的行。

一个常见的 SaaS 模式:USING 确保你只能看到自己租户的行;WITH CHECK 确保你不能通过猜测 tenant_id 将行插入到别人的租户中。

宽松(PERMISSIVE) vs 严格(RESTRICTIVE),一句话说明

以后你添加更多策略时,这一点会很重要:

  • PERMISSIVE(默认):只要任一策略允许,该行就被允许。
  • RESTRICTIVE:该行仅在所有严格策略都允许时才被允许(在宽松行为之上)。

如果你计划叠加像租户匹配、角色检查、项目成员关系这样的规则,使用 restrictive 能让意图更清晰,但如果忘记了某个条件也更容易把自己锁出去。

Postgres 如何知道谁在调用

RLS 需要一个可靠的“谁在调用”的值。常见选项:

  • 每次请求设置会话变量(例如 app.user_idapp.tenant_id)。
  • 由 API 层把 JWT 声明映射到会话设置。
  • 角色切换(每次请求 SET ROLE ...),这可以工作但增加运维开销。

选择一种方法并在所有地方应用。跨服务混用身份来源是走向混乱错误的快速通道。

给策略命名以便后期调试

使用可预测的命名约定,使 schema 导出和日志保持可读。例如:{table}__{action}__{rule},像 projects__select__tenant_match

逐步操作:在一张表上添加 RLS 并验证它有效

从 Postgres 优先的后端开始
获得一个干净的 Go API 和可用 RLS 锁定的 PostgreSQL schema。

如果你是 RLS 新手,从一张表和一个小型证明开始。目标不是覆盖所有表,而是让数据库在应用出现漏洞时拒绝跨租户访问。

一个练习用的小表

假设一个简单的 projects 表。首先以不会破坏写入的方式添加 tenant_id

ALTER TABLE projects ADD COLUMN tenant_id uuid;

-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;

ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;

接下来,把所有权与访问分开。一个常见模式是:一个角色拥有表(app_owner),另一个角色由 API 使用(app_user)。API 角色不应该是表的所有者,否则它可以绕过策略。

ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;

现在决定请求如何告诉 Postgres 它正在为哪个租户服务。一种简单方法是请求作用域的设置。你的应用在打开事务后立即设置它。

-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);

启用 RLS 并从读访问开始。

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);

通过尝试两个不同的租户并检查行数变化来证明它工作。

添加写入规则(WITH CHECK)

读取策略不会保护写入。添加 WITH CHECK,这样插入和更新就不能把行偷渡到错误的租户。

CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);

一个快速验证行为(包括失败)的方式是保留一个小的 SQL 脚本,每次迁移后可重跑:

  • BEGIN; SET LOCAL ROLE app_user;
  • SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;
  • INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad');(应失败)
  • UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...;(应失败)
  • ROLLBACK;

如果你能每次运行该脚本并得到相同结果,就拥有了在扩展 RLS 到其他表之前的可靠基线。

大多数 SaaS 应用会重用的策略模式

多数团队在厌倦在每个查询重复相同授权检查后采用 RLS。好消息是,你需要的策略形态通常是一致的。

所有者行 vs 成员行

有些表天然由单个用户拥有(笔记、API token)。其他表属于租户且访问依赖于成员关系。把它们当作不同模式处理。

对于仅所有者可访问的数据,策略通常检查 created_by = app_user_id()。对于租户数据,策略通常检查用户在 org 中是否有成员行。

把身份逻辑集中到小的 SQL 辅助函数中并重用,可以保持策略可读:

-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
  select current_setting('app.user_id', true)::uuid
$$;

create function app_is_admin() returns boolean
language sql stable as $$
  select current_setting('app.is_admin', true) = 'true'
$$;

将读规则与写规则分离

读通常比写更宽。比如任何 org 成员都能 SELECT 项目,但只有编辑者能 UPDATE,只有所有者能 DELETE

明确地为每种行为写策略:一个针对 SELECT(成员关系),一个针对 INSERT/UPDATE 并带 WITH CHECK(角色),以及一个针对 DELETE(通常比更新更严格)。

在不禁用 RLS 的情况下实现管理员覆盖

避免“为管理员关闭 RLS”。相反,在策略内部添加一个跳出路线,比如 app_is_admin(),这样不会意外把对共享服务角色的全面访问权授予出去。

软删除与状态标记

如果你使用 deleted_atstatus,在 SELECT 策略中把它们考虑进去(deleted_at is null)。否则某人可以通过翻转应用以为是最终状态的标志来“复活”行。

UPSERT:保持 WITH CHECK 友好

INSERT ... ON CONFLICT DO UPDATE 必须满足写入后的 WITH CHECK。如果你的策略要求 created_by = app_user_id(),确保 upsert 在插入时设置 created_by,并在更新时不覆盖它。

如果你生成后端代码,把这些模式做成内部模板很值得,这样新表从一开始就有安全默认,而不是一张空白表。

常见 RLS 踩坑会让调试变得痛苦

RLS 很好,但一处小细节可能让 Postgres 看起来在“随机”隐藏或显示数据。下面这些错误最浪费时间。

导致静默租户泄露的陷阱

第一个陷阱是忘了在插入和更新时添加 WITH CHECKUSING 控制你能看见什么,不控制你被允许创建什么。没有 WITH CHECK,应用漏洞可以把行写入错误租户,而你可能发现不了,因为同一用户无法读回该行。

另一个常见泄露是“泄漏连接”。你正确地过滤了 projects,然后 join 到未做同样保护的 invoicesnotesfiles。修复方法严格但直接:每个可能泄露租户数据的表都需要自己的策略,视图不应仅依赖一张表是安全的。

常见失败模式早期就会显现:

  • 存在读取策略,但写入策略缺少 WITH CHECK
  • 策略条件使用了连接到未受保护的表的检查。
  • 在视图里强制了访问,却让底层表保持开放。
  • 你依赖“应用总是设置 tenant_id”,某个后台作业忘了。
  • 用超级用户测试,因此从未看到真实行为。

让行为变得令人困惑的陷阱

策略引用同一表(直接或通过视图)可能产生递归惊讶。策略可能通过查询一个再次读取受保护表的视图来检查成员关系,导致错误、慢查询或策略永远不匹配。

角色设置也是混淆来源。表的所有者和高权限角色可以绕过 RLS,因此你的测试通过,而真实用户失败(或相反)。总是用应用使用的低权限角色来测试。

SECURITY DEFINER 函数也要谨慎。它们以函数所有者的权限运行,所以像 current_tenant_id() 这样的辅助函数可能没问题,但如果一个“方便”函数去读取数据,可能会在设计不当时跨租户读取。确保在 security definer 函数内设置安全的 search_path,否则函数可能拾取同名的其他对象,而你的策略逻辑会根据会话状态悄然引用错误的对象。

调试 RLS:实用方法查看发生了什么

无惧试验
在执行高风险迁移前保存快照,让策略变更更安全。

RLS 的错误通常是缺少上下文,而不是“SQL 错误”。策略在纸面上可能正确,但因为会话角色与你预期不同,或请求没有设置策略依赖的租户和用户值而失败。

可靠复现生产报表的方法是本地镜像相同的会话设置并运行完全相同的查询。通常意味着:

  • SET ROLE app_user;(或真实的 API 角色)
  • SELECT set_config('app.tenant_id', 't_123', true);SELECT set_config('app.user_id', 'u_456', true);
  • 运行应用发出的相同 SQL(包括参数)
  • 确认 Postgres 看到的值:SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);

当你不确定哪个策略被应用时,检查系统目录比猜测要可靠。pg_policies 显示每个策略、命令和 USINGWITH CHECK 表达式。配合 pg_class 可确认表上是否启用了 RLS 并且没有被绕过。

性能问题可能看起来像授权问题。一个在成员表上做连接或调用函数的策略可能在表增长后正确但变慢。对复现的查询使用 EXPLAIN (ANALYZE, BUFFERS),查找顺序扫描、意外的嵌套循环或迟应用的过滤器。缺少 (tenant_id, user_id) 以及成员表上的索引是常见原因。

同时在应用层每次请求记录三个值很有帮助:租户 ID、用户 ID 和用于请求的数据库角色。当这些与预期不匹配时,RLS 会表现“错误”,因为输入不对。

在测试里保留一些种子租户并把失败明确化。一个小测试套件通常包含:“租户 A 无法读取租户 B”、“无成员用户不能看到项目”、“所有者能更新、查看者不能”、“除非 tenant_id 与上下文匹配否则插入被阻止”以及“管理员覆盖仅在预期情形下生效”。

上线前 RLS 的快速检查清单

把 RLS 当作安全带,而不是功能开关。小的遗漏会导致“所有人都能看到所有人的数据”或“所有查询都返回零行”。

数据模型与策略形态

确保表设计与策略规则匹配你的租户模型。

  • 每个租户拥有的表都应有明确的租户键(通常为 tenant_id)。如果没有,写下原因(例如全局参考表)。
  • 在每个租户拥有的表上启用 RLS,而不仅仅是“主要”表。如果某些路径绝不能被绕过,考虑在这些表上使用 FORCE ROW LEVEL SECURITY
  • 将读写规则分离。读取使用 USING。写入必须有 WITH CHECK,以便插入和更新不能把行转移到另一个租户。
  • 保持策略谓词对索引友好。如果策略按 tenant_id 过滤或通过成员表做连接,为其添加匹配索引。

一个简单的理智场景:租户 A 的用户能读取自己的发票、只能为租户 A 插入发票、不能将发票更新为另一个租户的 tenant_id

角色、性能与测试

RLS 的强度取决于你的应用角色设置。

  • 确认应用从未以 superuser、表所有者或任何具有 bypassrls 的角色连接。
  • 用接近生产的数据量运行若干真实查询并检查查询计划。
  • 添加自动化的负向测试以证明跨租户访问失败。

示例:基于成员关系访问的多租户项目应用

掌控你的安全逻辑
保留 RLS 辅助函数、测试和迁移的源码导出,掌控全部安全逻辑。

设想一个 B2B 应用,公司(org)拥有项目,项目有任务。用户可以属于多个 org,并且用户可能只属于某些项目。数据库在这种场景下适合使用 RLS,因为即使某个 API 端点忘记了过滤,数据库也能强制租户隔离。

一个简单模型是:orgsusersorg_memberships (org_id, user_id, role)projects (id, org_id)project_memberships (project_id, user_id)tasks (id, project_id, org_id, ...)tasks 上的 org_id 是刻意为之。它让策略简单并在连接时减少惊讶。

一个经典泄露是当 tasks 只有 project_id,你的策略通过 join 到 projects 检查访问。有一次错误(对 projects 的宽松策略、丢失条件的 join,或改变上下文的视图)就能暴露其他 org 的 tasks。

更安全的迁移路径能避免破坏生产流量:

  • 先上线 schema 变更(在 tasks 上添加 org_id,添加成员表)。
  • projects.org_id 回填 tasks.org_id,然后加 NOT NULL
  • 添加策略,但在你在预发布环境测试时先保持 RLS 关闭。
  • 启用 RLS,然后强制它,再移除旧的应用层过滤。

支持访问通常最好用一个狭窄的紧急突破角色来处理,而不是禁用 RLS。把它与常规支持帐号分开,并且在使用时显式记录。

记录规则以防策略漂移:哪些会话变量必须设置(user_id, org_id),哪些表必须带 org_id,什么是“成员”,以及一些在错误 org 下运行应返回 0 行的 SQL 示例。

下一步:安全地推广 RLS 并保持可维护性

当你把 RLS 当作产品变更来对待时,它最容易长期维护。分小块上线、用测试证明行为,并清楚记录每条策略存在的原因。

一个常见且可行的推广计划:

  • 从一张明确属于某租户的表开始(例如 projects)并把它锁定。
  • 添加覆盖允许与阻止读写的测试(owner、member、outsider)。
  • 按批次扩展(一次一个功能领域),这样你能在一个会话里调试。
  • 在推广期间监控权限错误,并在低风险时段部署。

第一张表稳定后,让策略变更变得慎重。给迁移增加策略审查步骤,并附上一条简短的意图说明(谁应访问什么与为什么)以及对应的测试更新。这能防止“再加一个 OR”类型的策略慢慢变成漏洞。

如果你推进得很快,像 Koder.ai (koder.ai) 这样的工具可帮助你通过聊天生成 Go + PostgreSQL 的起点,然后你可以用相同的纪律在其上叠加 RLS 策略和测试。

最后,在推广期间保留安全措施:在策略迁移前拍快照、反复演练回滚直到无聊,并保留一条不会禁用整系统 RLS 的小型紧急突破路径。

常见问题

What security problem does RLS actually solve in a SaaS app?

RLS 让 PostgreSQL 强制决定对某次请求哪些行可见或可写,从而使租户隔离不再依赖每个端点记住正确的 WHERE tenant_id = ... 过滤条件。主要收益是减少在应用增长、查询增多时出现的“漏掉一次检查”的漏洞。

When is RLS worth the extra complexity?

当访问规则一致且基于行(例如租户隔离或基于成员资格的访问),并且存在许多查询路径(搜索、导出、管理员界面、后台作业)时,RLS 就值得采用。它通常不适合大部分规则都是按字段、充满例外或以跨租户广泛报表为主的场景。

What does RLS NOT protect me from?

RLS 用于行可见性和基本写入约束,但不能替代所有安全措施。列隐私通常需要视图或列权限;复杂的业务规则(例如计费所有权或审批流程)仍然该在应用逻辑或精心设计的数据库约束中实现。

What’s the safest way to start using RLS if I’m new to it?

为 API 创建一个低权限角色(不是表的所有者),启用 RLS,然后添加 SELECT 策略和带 WITH CHECKINSERT/UPDATE 策略。使用请求范围的会话变量(如 app.current_tenant),并验证切换该变量会改变可见和可写的行。

How should my app tell Postgres which tenant and user is making the request?

常见做法是在事务开始时设置会话变量,例如 app.tenant_idapp.user_id。关键是保持一致:每个代码路径(web 请求、作业、脚本)都必须设置策略期望的相同值,否则你会遇到令人困惑的“返回零行”情况。

What’s the difference between USING and WITH CHECK in an RLS policy?

USING 控制哪些已有行对 SELECT 可见并能被 UPDATEDELETE 定位。WITH CHECK 控制 INSERTUPDATE 时允许的新行或被修改后的行,从而防止写入到其他租户的情况。

Why do people keep saying “don’t forget WITH CHECK”?

如果你只添加了 USING,有缺陷的端点仍然可以向错误的租户插入或更新行,而同一用户可能无法读回该行以发现问题。因此总是要为写入配对对应的 WITH CHECK,以防止在源头生成错误数据。

How should I structure my schema so RLS policies stay simple and fast?

通过将租户键(例如 org_id)直接放在租户拥有的表上,避免在策略中做连接推断。添加显式的成员关系表(org_memberships,可选 project_memberships),使策略能通过一次索引查找判断访问,而不是复杂的推断。

How do I debug “RLS is hiding my data” without guessing?

先在本地复现与生产相同的会话上下文:设置相同的角色和会话设置,然后运行精确的 SQL 查询。接着确认 RLS 已启用,并检查 pg_policies 以查看应用了哪些 USINGWITH CHECK 表达式,因为 RLS 常常是输入上下文不对而不是“SQL 错误”。

If I generate my backend (for example with Koder.ai), do I still need to design RLS carefully?

是,但把生成的代码当作起点,而不是完整的安全体系。如果你用 Koder.ai 生成 Go + PostgreSQL 后端,仍需定义租户模型、一致地设置会话身份,并有意识地添加策略和测试,避免新表在没有正确保护的情况下上线。

Related posts