2025年3月03日·阅读约1分钟

用于多租户应用的 PostgreSQL 行级安全模式

通过实用模式学习 PostgreSQL 行级安全,实现租户隔离和角色规则,让访问在数据库中强制执行,而不仅依赖应用层。

用于多租户应用的 PostgreSQL 行级安全模式

为什么在业务应用中把访问放到数据库很重要

业务应用通常有这样的规则:“用户只能看到他们公司的记录”和“只有经理可以批准退款”。很多团队把这些规则放在 UI 或 API 层实现,并认为这就足够了。问题是,每多一条访问数据库的路径,就多一次数据泄露的机会:内部管理工具、后台作业、分析查询、被遗忘的端点,或是跳过某个检查的 bug。

租户隔离意味着一个客户(租户)永远不能读取或修改另一个客户的数据,即使是无意的。基于角色的访问意味着同一租户内的不同人仍有不同权限,比如客服、经理或财务。这些规则很容易描述,但当规则散布在多个地方时,很难保持完全一致。

PostgreSQL 的行级安全(RLS)是一个数据库特性,让数据库来决定一个请求可以看到或修改哪些行。与其寄希望于应用中的每个查询都记得加上正确的 WHERE 子句,不如让数据库自动应用策略。

RLS 并不是万能的盾牌。它不会帮你设计模式、替代认证,也不能保护那些已经拥有强权限(比如超级用户)的账号。它也不会防止某些逻辑错误,例如“某人可以更新一行但不能选择它”,除非你对读写都写了策略。

你能得到的是一个强有力的安全网:

  • 对所有访问数据库的代码路径使用同一套规则
  • 减少新功能上线时的“哎呀”时刻
  • 更清晰的审计,因为访问规则可以在 SQL 里看到
  • 如果 API 出现 bug,防线更牢靠

这需要一些初始配置。你需要一种一致的方式把“这个用户是谁”和“属于哪个租户”传进数据库,并且需要在应用增长时维护策略。回报很大,尤其是对 SaaS 和内部工具来说,客户敏感数据在此承担重大风险时尤其如此。

用非行话解释行级安全基础

行级安全(RLS)会自动过滤查询可以看到或修改的行。与其指望每个界面、API 端点或报表去“记住”规则,不如让数据库为你执行这些规则。

在 PostgreSQL 中,你编写的策略会在每次 SELECTINSERTUPDATEDELETE 时被检查。如果策略规定“该用户只能看到租户 A 的行”,那么一个被遗忘的管理页面、新的查询或匆忙修复的热更仍会受到相同的保护。

RLS 与 GRANT/REVOKE 不同。GRANT 决定一个角色是否可以访问表(或特定列),而 RLS 决定该表中哪些行是允许的。实践中你通常会同时使用两者:用 GRANT 限制谁能访问表,用 RLS 限制他们能访问哪些行。

它在复杂现实中也能奏效。视图一般会遵守 RLS,因为底层表的访问仍会触发策略。连接和子查询也会被过滤,因此用户不能通过“连接”拿到别人的数据。策略无论哪个客户端运行查询都生效:应用代码、SQL 控制台、后台作业或报表工具。

当你有强烈的租户隔离需求、多个查询同一份数据的途径或多个共享表的角色时(SaaS 和内部工具中常见),RLS 很合适。对于只有一个受信任后端的小型应用,或数据不敏感且永远只通过单一受控服务访问的情况,RLS 可能过于复杂。一旦你有超过一个入口点(管理工具、导出、BI、脚本),RLS 往往就是值得的投资。

先绘出租户、角色与数据归属关系

在写第一条策略之前,先弄清谁拥有什么。PostgreSQL 行级安全在你的数据模型已经反映租户、角色和归属时效果最佳。

先从租户开始。在大多数 SaaS 应用中,最简单的规则是:每个包含客户数据的共享表都有一个 tenant_id。这包括“显而易见”的表,如发票,也包括常被忽视的表,如附件、评论、审计日志和后台任务。

接着,列出实际使用的角色。保持集合小且可理解:owner、manager、agent、read-only。这些是你后来会映射到策略检查的业务角色(它们不是数据库角色)。

然后决定记录如何归属。有些表由单个用户拥有(例如私有笔记),有些表是团队拥有(例如共享收件箱)。在没有计划的情况下混合这两种方式,会导致策略难以阅读且容易被绕过。

一种简单的记录规则方式是对每个表回答同样的问题:

  • 租户边界是什么(哪个列强制它)?
  • 谁可以读取行(按角色和按归属)?
  • 谁可以创建和更新行(在什么条件下)?
  • 谁可以删除行(通常最严格)?
  • 哪些例外是允许的(支持人员、自动化、导出)?

示例:"Invoices"(发票)可能允许经理查看该租户的所有发票,允许客服查看分配给其负责客户的发票,允许只读用户查看但不能编辑。事先决定哪些规则必须严格(租户隔离、删除)以及哪些可以灵活(比如经理的额外可见性)。如果你在像 AppMaster 这样的无代码工具中构建,这份映射还能帮助 UI 期望与数据库规则保持一致。

多租户表的设计模式

当表的结构可预测时,多租户 RLS 的效果最好。如果每个表以不同的方式存储租户,策略会变成一个拼图。统一的表形让 PostgreSQL 行级安全更易读、易测且能长期保持正确。

先选一个租户标识并在各处使用。UUID 很常见,因为难以猜测且在许多系统中易于生成。整数对内部应用也很好。Slug(像 "acme")对人友好,但可能会变更,所以把它当展示字段而不是核心键。

对于按租户划分的数据,在每个属于租户的表中添加 tenant_id 列,并尽可能将其设为 NOT NULL。如果某行可以没有租户,通常说明有问题:你可能在同一张表中混合了全局和租户数据,这会让 RLS 策略更难写且更脆弱。

索引很简单但重要。大多数 SaaS 应用的查询先按租户过滤,再按状态或日期等业务字段过滤。一个好的默认索引是 tenant_id,对高流量表可以建立复合索引,例如 (tenant_id, created_at)(tenant_id, status),基于常见的过滤模式。

提前决定哪些表是全局的,哪些是租户范围的。常见的全局表包括国家、货币代码或方案定义。租户范围的表包括客户、发票、工单以及租户拥有的任何东西。

要保持策略可维护,保持规则窄而明确:

  • 租户范围表:tenant_id NOT NULL、启用 RLS、策略始终检查 tenant_id
  • 全局参考表:无 tenant_id、无租户策略、对大多数角色只读。
  • 受控共享表:为不同概念使用不同表(避免在一张表中混合全局行和租户行)。

如果你使用像 AppMaster 这样的工具,这种一致性在数据模型中也会有回报。一旦 tenant_id 成为标准字段,你可以在各个模块间重用相同模式而不会有意外。

逐步示例:创建你的第一个租户策略

标准化你的租户数据模型
在 Data Designer 中只建模一次 tenant_id,并在每个表和模块中重用它。
开始构建

用 PostgreSQL 行级安全取得的第一个小胜利是让单表仅在当前租户内可读。目的很简单:即使有人在 API 中忘记了 WHERE 子句,数据库也会拒绝返回其他租户的行。

从包含 tenant_id 列的表开始:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

启用 RLS 后,默认行为常常让人惊讶:如果一个受 RLS 约束的角色没有匹配的策略,SELECT 会返回零行(写操作会失败)。这在起步阶段正是你想要的行为。

现在添加一个最小的读取策略。下面的示例假设你的应用在登录后设置了像 app.tenant_id 这样的会话变量:

CREATE POLICY invoices_tenant_read
ON invoices
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::uuid);

接下来,添加写入规则。在 RLS 中,USING 控制你可以触及哪些已有行,WITH CHECK 控制你被允许写入的新值。

CREATE POLICY invoices_tenant_insert
ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY invoices_tenant_update
ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY invoices_tenant_delete
ON invoices
FOR DELETE
USING (tenant_id = current_setting('app.tenant_id')::uuid);

策略默认是 PERMISSIVE,意味着任一策略允许访问即可。如果你希望规则必须全部通过(例如再加一层“仅活动账户可操作”),可以选择 RESTRICTIVE

把策略写得小且以角色为中心。不要写一条包含很多 OR 的巨型规则,而是为不同受众创建独立策略(例如 invoices_tenant_read_app_userinvoices_tenant_read_support_agent)。这样更容易测试、审阅,也更安全可改。

安全地传递租户和用户上下文

要让 PostgreSQL 行级安全生效,数据库需要知道“谁在调用”和“他们属于哪个租户”。RLS 策略只能在查询时将行与数据库可读取的值做比较,所以你必须把这些上下文传入会话。

一个常见模式是在认证后设置会话变量,然后让策略通过 current_setting() 读取它们。应用验证身份(例如校验 JWT),然后把 tenant 和 user 的 ID 写入数据库连接会话。

-- 在每次请求(或每个事务)执行一次
SELECT set_config('app.tenant_id', '3f2a0c3e-9c7b-4d3f-9c5c-3c5e9c5d1a11', true);
SELECT set_config('app.user_id',   '8d9c6b1a-6b6d-4e32-9c0d-2bfe6f6c1111', true);
SELECT set_config('app.role',      'support_agent', true);

-- 在策略中
-- tenant_id 列是 UUID
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

第三个参数 true 使设置在当前事务本地有效。如果你使用连接池,这很重要:连接可能被另一个请求重用,所以你不希望“昨天”的租户上下文残留在连接中。

从 JWT 声明填充上下文

如果你的 API 使用 JWT,请把声明视为输入而非真理。先验证令牌签名和过期,然后只复制需要的字段(tenant_id、user_id、role)到会话设置中。避免让客户端直接通过头或查询参数发送这些值。

缺失或无效的上下文:默认拒绝

设计策略时让缺失的设置导致零行返回。

使用 current_setting('app.tenant_id', true) 使缺少的值返回 NULL。按正确类型 cast(例如 ::uuid)以便格式无效时能快速失败。如果无法设置租户/用户上下文,应失败请求,而不是猜测默认值。

这样即使查询绕过了 UI 或添加了新端点,访问控制也能保持一致。

保持可维护的实用角色模式

把业务规则放在一个地方
把角色、所有权和审批转为后端逻辑,而无需到处重写授权检查。
创建项目

让 PostgreSQL 行级安全策略保持可读的最简单方式是将身份与权限分离。一个稳健的基线是有一张 users 表和一张 memberships 表,把用户连接到租户和角色(或多个角色)。然后你的策略可以回答一个问题:“当前用户是否对这行拥有合适的 membership?”

把角色名与真实动作绑定,而不是职位名称更好。比如用 invoice_viewerinvoice_approver 通常比用 “manager” 更不容易过时,因为策略可以用更直白的语言写出权限。

下面是一些随着应用增长仍保持简单的角色模式:

  • 仅所有者:行有 created_by_user_id(或 owner_user_id),访问检查精确匹配该字段。
  • 团队范围:行有 team_id,策略检查用户是否为同一租户内该团队的成员。
  • 仅已批准:仅当 status = 'approved' 时允许读取,写入限制在审批者。
  • 混合规则:先严格,再添加小范围例外(例如“支持可以读取,但仅限租户内”)。

跨租户管理员是许多团队出问题的地方。明确处理它们,不要把它们当作隐藏的“超级用户”捷径。创建一个单独的概念,比如 platform_admin(全局),并在策略中要求明确检查。更好的是,默认把跨租户访问设为只读,并让写入需要更高门槛。

文档比你想象的更重要。在每个策略上方写一段简短注释,说明意图而不是 SQL。“审批者可以更改状态。查看者只能读取已批准的发票。”六个月后,这段注释会让策略修改更安全。

如果你在无代码工具如 AppMaster 中构建,这些模式同样适用。你的 UI 和 API 可以快速迭代,但因为数据库规则依赖 membership 和清晰的角色含义,后端保持稳定。

示例场景:一个简单的 SaaS(发票与支持)

让 UI 与访问保持一致
创建网页和原生移动应用,使 UI 访问与后端权限保持一致。
构建应用

想象一个为多家公司服务的小型 SaaS。每家公司是一个租户。应用有发票(涉及金钱)和支持工单(日常服务)。用户可以是 agent、manager 或 support。

数据模型(简化):每条发票和工单都有 tenant_id。工单还有 assignee_user_id。应用在登录后将当前租户和用户写入数据库会话。

下面是 PostgreSQL 行级安全如何降低日常风险的举例。

来自租户 A 的用户打开发票界面并尝试猜测租户 B 的发票 ID(或 UI 不小心发送了它)。查询仍会执行,但数据库返回零行,因为策略要求 invoice.tenant_id = current_tenant_id。不会出现“访问被拒”的信息泄露,只是空结果。

在同一租户内,角色进一步缩小访问范围。经理可以查看该租户的所有发票和工单。客服只能看到分配给他们的工单,也许还有他们自己的草稿。很多团队在 API 中常在这一步出错,尤其是当过滤条件是可选时。

支持是特殊情况。他们可能需要查看发票以帮助客户,但不应该能更改敏感字段,如 amountbank_accounttax_id。一个实用模式是:

  • 允许 support 角色对发票执行 SELECT(仍受租户约束)。
  • 只通过“安全”路径允许 UPDATE(例如,一个暴露可编辑列的视图,或拒绝更改受保护字段的严格更新策略)。

再看“意外的 API bug”场景:一个端点在重构后忘记了应用租户过滤。没有 RLS,它可能泄露跨租户发票。有了 RLS,数据库会拒绝返回会话租户之外的行,所以这个 bug 只是导致界面空白,而不是数据泄露。

即便你在 AppMaster 上构建这类 SaaS,仍然要在数据库里保留这些规则。UI 检查有帮助,但当某处出错时,数据库规则才是最后的保障。

常见错误以及如何避免

PostgreSQL 行级安全很强大,但小失误会悄悄地把“安全”变成“令人惊讶”。大部分问题在新增表、角色变更或有人用错误的数据库用户测试时出现。

一个常见失误是忘记对新表启用 RLS。你可能为核心表写了细致的策略,但后来添加了“notes”或“attachments”表并以开放访问发布。养成习惯:新表意味着启用 RLS,并至少添加一条策略。

另一个常见陷阱是操作间策略不匹配。允许 INSERT 但阻止 SELECT 的策略会让人感觉“数据消失”——刚创建就看不到。反过来也很麻烦:用户能读到他们不能创建的行,于是在 UI 端做旁门左道。按流程思考:"先创建再查看"、"更新后重新打开"、"删除后列出"。

当心 SECURITY DEFINER 函数。它们以函数所有者的权限运行,可能会绕过 RLS。如果使用它们,保持函数小、验证输入,并避免使用动态 SQL,除非确实需要。

也不要只依赖应用端过滤而让数据库访问保持开放。即便是构建良好的 API,最终也会出现新的端点、后台任务和管理脚本。如果数据库角色可以读取所有数据,总有一天会被利用。

为尽早发现问题,保持检查实用且可重复:

  • 使用与你的生产应用相同的数据库角色进行测试,而不是你的管理员账户。
  • 为每张表添加一个否定测试:来自别的租户的用户必须看到零行。
  • 确认每张表支持你期望的操作:SELECTINSERTUPDATEDELETE
  • 审查 SECURITY DEFINER 的使用并记录理由。
  • 在代码审查和迁移中包含“是否启用 RLS?”这一项。

示例:如果支持人员创建了一条发票备注但无法读回,通常是 INSERT 策略存在但没有匹配的 SELECT 策略(或该会话未设置租户上下文)。

验证 RLS 配置的快速清单

添加功能而不增加风险
在不破坏租户边界的前提下,连接支付、消息和自动化服务。
探索集成

行级安全在审阅时看起来可能正确,但在实际使用中仍会失败。验证更多是试图用真实账户和查询攻破它们,而不是只看策略文本。按应用会使用的方式去测试,而不是你希望它运行的方式。

先创建一组小的测试身份。至少使用两个租户(租户 A 和租户 B)。为每个租户添加一个普通用户和一个管理员或经理角色。如果你支持“支持代理”或“只读”角色,也添加这些角色。

然后用一套小且可重复的检查来压测 RLS:

  • 对每个角色运行核心操作:列出行、按 id 获取单行、插入、更新、删除。对每个操作,尝试“允许”和“应被阻止”的情况。
  • 证明租户边界:以租户 A 的身份,尝试读取或修改存在的租户 B 数据。你应得到零行或权限错误,绝不应有部分返回的数据。
  • 测试连接以查漏:将受保护表与其他表连接(包括 lookup 表)。确认连接不能通过外键或视图拉入另一租户的相关行。
  • 检查缺失或错误上下文应拒绝访问:清除租户/用户上下文并重试。“无上下文”应当失败关闭。也要尝试无效的 tenant id。
  • 确认基本性能:查看查询计划并确保索引支持你的租户过滤模式(常见为 tenant_id 加上你排序或搜索的列)。

如果测试让你惊讶,先修策略或上下文设置。不要在 UI 或 API 端打补丁并寄希望数据库规则“多半还能守住”。

下一步:安全地推广并保持一致

把 PostgreSQL 行级安全当作安全系统来对待:谨慎引入、频繁验证,并保持规则足够简单以便团队能遵循。

从小处开始。选择那些数据泄露代价最高的表(支付、发票、HR 数据、客户消息),先在那里启用 RLS。早期的小胜利要胜过没人完全理解的大规模推广。

一个实用的推广顺序通常如下:

  • 先启用核心“有明确归属的”表
  • 有个人数据(PII)的表
  • 按租户过滤的共享表(报表、分析)
  • 连接表与边缘案例(多对多关系)
  • 基本稳定后再处理其他表

让测试成为必需。自动化测试应以不同租户和角色运行相同查询并确认行为。包括“应允许”和“应拒绝”的检查,因为最昂贵的 bug 是那些悄悄授予过多权限的问题。

在请求流中保持一个明确的位置来在任何查询运行前设置会话上下文。tenant id、user id 和 role 应在早期一次性应用,之后不要再猜测。如果在事务中途设置上下文,你迟早会遇到缺失或过期的查询。

当你使用 AppMaster 构建时,计划好生成的后端 API 与 PostgreSQL 策略的一致性。标准化将租户和角色上下文传入数据库的方式(例如对每个端点使用相同的会话变量),这样策略在任何地方都会有相同行为。如果你在 appmaster.io 使用 AppMaster,即使你也在 UI 中做权限控制,仍应把 RLS 视为租户隔离的最终权威。

最后,注意失败案例。鉴权失败是有用的信号,尤其是在推广初期。跟踪反复的拒绝并调查它们是来自真实攻击、客户端流程故障,还是策略过于严格。

一个有助于保持 RLS 健康的简短习惯清单:

  • 默认拒绝的思路,例外要有意识地添加
  • 清晰的策略命名(表 + 操作 + 受众)
  • 像审查代码一样审查策略变更
  • 在推广早期记录并审查拒绝日志
  • 每个启用 RLS 的新表都添加一组小测试
容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧
用于多租户应用的 PostgreSQL 行级安全模式 | AppMaster