跳到主要内容

行级安全策略

行级安全(Row-Level Security,RLS)让 PostgreSQL 根据当前角色或会话上下文,限制一张表中哪些行可以被查看、插入、修改和删除。

普通权限控制“能不能查询这张表”,RLS 控制“能查询这张表的哪些行”。它适合多租户隔离、用户只能访问自己的记录、部门级数据范围等需求。

RLS 是 PostgreSQL 特性。它是纵深防御的一部分,不会替代正确的身份认证、表权限、参数化查询和安全审计。

基本模型​

假设每条项目记录都属于一个租户:

CREATE TABLE projects (
project_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
project_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

启用 RLS:

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

创建策略:

CREATE POLICY projects_tenant_isolation
ON projects
USING (tenant_id = current_setting('app.tenant_id')::BIGINT)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::BIGINT);

启用 RLS 后,普通角色还需要表级权限:

GRANT SELECT, INSERT, UPDATE, DELETE
ON projects TO app_runtime;

表权限与策略缺一不可。

启用 RLS 与默认拒绝​

如果表启用了 RLS,但对某项操作没有适用策略,普通受限角色默认不能访问相应行。这种默认拒绝行为有助于防止遗漏策略时意外公开全部数据。

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

禁用 RLS:

ALTER TABLE projects DISABLE ROW LEVEL SECURITY;

禁用会让所有策略停止过滤,属于高风险结构变更,应受到与权限变更同等严格的审查。

USING 与 WITH CHECK​

两种表达式保护不同方向。

USING​

决定哪些已有行对操作可见:

USING (tenant_id = current_setting('app.tenant_id')::BIGINT)

它用于 SELECT,也决定 UPDATE 和 DELETE 能找到哪些目标行。

WITH CHECK​

决定插入或更新后的新行是否允许存在:

WITH CHECK (tenant_id = current_setting('app.tenant_id')::BIGINT)

如果只限制旧行,不检查新值,用户可能把一条允许修改的记录更新到别的租户名下。反过来,只检查新值也不能阻止读取或选中不属于自己的旧行。

未显式提供 WITH CHECK 时,某些策略会复用 USING 表达式,但安全设计最好明确写出意图。

按命令创建策略​

策略可作用于全部命令,也可只作用于一种命令:

CREATE POLICY projects_read
ON projects
FOR SELECT
TO app_runtime
USING (tenant_id = current_setting('app.tenant_id')::BIGINT);
CREATE POLICY projects_insert
ON projects
FOR INSERT
TO app_runtime
WITH CHECK (tenant_id = current_setting('app.tenant_id')::BIGINT);
CREATE POLICY projects_update
ON projects
FOR UPDATE
TO app_runtime
USING (tenant_id = current_setting('app.tenant_id')::BIGINT)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::BIGINT);
CREATE POLICY projects_delete
ON projects
FOR DELETE
TO app_runtime
USING (tenant_id = current_setting('app.tenant_id')::BIGINT);

拆分策略更长,但审计时能直接看出每种操作的边界。

策略角色​

TO 指定策略适用于哪些角色:

CREATE POLICY support_read_projects
ON projects
FOR SELECT
TO support_agent
USING (support_visible = TRUE);

省略时通常相当于面向 PUBLIC,也就是所有角色。实际访问仍需要对象权限。

角色成员关系会影响策略是否适用。设计时应测试登录角色、继承角色和 SET ROLE 后的行为,不能只用超级用户账户验证。

宽松策略与限制性策略​

PostgreSQL 的策略默认是宽松的(PERMISSIVE)。同一命令有多个宽松策略时,只要满足其中一个即可,逻辑上近似用 OR 组合。

例如用户既可看自己创建的项目,也可看公开项目:

created_by = 当前用户 OR is_public = true

限制性策略(RESTRICTIVE)则与适用策略按更严格的方式组合,逻辑上近似 AND:

CREATE POLICY projects_not_archived
ON projects
AS RESTRICTIVE
FOR SELECT
TO app_runtime
USING (archived_at IS NULL);

限制性策略需要至少有一个适用的宽松策略提供允许路径。策略组合复杂后,应把每个角色和命令的最终布尔逻辑写成测试用例。

谁可以绕过 RLS​

以下身份需要特别关注:

  • 超级用户绕过 RLS;
  • 具有 BYPASSRLS 属性的角色绕过 RLS;
  • 表所有者通常绕过自己表上的 RLS;
  • 表所有者可通过 FORCE ROW LEVEL SECURITY 让自身在普通场景也受策略约束。
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

即使使用 FORCE,超级用户和 BYPASSRLS 角色仍属于特殊高权限边界。应用运行角色不应是超级用户、不应拥有表,也不应拥有 BYPASSRLS。

安全地传递租户上下文​

示例使用了会话参数:

SET LOCAL app.tenant_id = '42';

SET LOCAL 只在当前事务内有效,适合连接池环境:

BEGIN;
SET LOCAL app.tenant_id = '42';
-- 执行业务查询
COMMIT;

关键风险是谁有权设置这个值。如果数据库客户端可以随意把租户 ID 改成别人的编号,策略就没有隔离效果。

常见安全方案包括:

  • 每个租户或用户使用独立数据库角色,由策略读取 current_user;
  • 只允许受信任的服务设置会话上下文;
  • 通过经过严格审计的 SECURITY DEFINER 入口验证身份后设置上下文;
  • 连接池每个事务都显式设置,并在事务结束后自动清除。

不要使用跨请求保留的普通 SET 而忘记重置,否则连接复用可能把上一个请求的租户上下文泄漏给下一个请求。

策略表达式中的函数​

策略可以调用函数,但函数会扩大安全边界:

USING (can_access_project(current_user, project_id))

应检查:

  • 函数以调用者还是定义者权限执行;
  • search_path 是否固定;
  • 函数是否会被用户替换或劫持;
  • 是否读取其他受 RLS 保护的表并形成递归;
  • 每行调用是否造成明显性能成本;
  • 函数是否可能通过错误或执行时间泄露信息。

能用简单列比较表达清楚时,优先保持策略简单。

RLS、约束与引用完整性​

主键、唯一约束和外键等完整性检查不能简单等同于普通查询过滤。PostgreSQL 为保证引用完整性,某些内部检查可能绕过行级过滤。这也意味着约束错误、唯一冲突或执行时间可能间接暴露“某个不可见值是否存在”。

对高敏感系统,应评估这种隐蔽信道,不要把 RLS 描述成绝对的信息不可推断边界。

性能考虑​

策略表达式会成为查询条件的一部分。设计时应:

  • 为 tenant_id 等高频过滤列建立合适索引;
  • 让会话值在一次语句中保持稳定;
  • 避免每行执行昂贵子查询或函数;
  • 使用普通受限角色查看 EXPLAIN 计划;
  • 在接近真实租户分布的数据上压测。

常见多租户索引会把租户列放在前部:

CREATE INDEX idx_projects_tenant_created
ON projects (tenant_id, created_at DESC);

索引列顺序仍应结合真实查询,而不是机械固定。

测试策略​

RLS 测试至少覆盖:

  1. 每个应用角色;
  2. SELECT、INSERT、UPDATE、DELETE;
  3. 允许行、禁止行和不存在行;
  4. 把允许行更新成禁止状态;
  5. 缺失或伪造会话上下文;
  6. 多个宽松/限制性策略组合;
  7. 表所有者与普通运行角色的差异;
  8. 连接池连续处理不同租户;
  9. 外键、唯一冲突和错误信息;
  10. 迁移创建的新表是否启用并强制了预期策略。

不要使用超级用户运行唯一一套集成测试,因为它会绕过最需要验证的行为。

管理策略​

修改策略:

ALTER POLICY projects_tenant_isolation
ON projects
USING (tenant_id = current_setting('app.tenant_id')::BIGINT)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::BIGINT);

删除策略:

DROP POLICY projects_tenant_isolation ON projects;

策略变更会直接改变数据可见范围,应像代码发布一样进行评审、测试和回滚准备。

常见误区​

启用 RLS 后所有人都受限制​

超级用户、BYPASSRLS 角色和通常情况下的表所有者可以绕过。运行角色必须与这些身份分离。

有 USING 就能阻止写入别的租户​

UPDATE 和 INSERT 的新值需要 WITH CHECK 保护。

应用传来的租户 ID 可以直接信任​

如果调用方能自由设置上下文,它就能冒充其他租户。上下文必须来自经过认证和授权的可信路径。

RLS 自动解决所有安全问题​

RLS 不处理密码、网络访问、SQL 注入、函数提权、列级敏感信息和备份权限。它只负责行范围的一层控制。

RLS 检查清单​

  1. 表是否启用了 RLS,是否需要 FORCE ROW LEVEL SECURITY;
  2. 应用角色是否无所有权、无超级用户和无 BYPASSRLS;
  3. 每种命令是否有明确的 USING / WITH CHECK;
  4. 多策略组合究竟是 OR 还是限制性的 AND;
  5. 租户或用户上下文是否来自可信来源并在连接池中正确清理;
  6. 策略函数是否安全、稳定且性能可接受;
  7. 过滤列是否有合适索引;
  8. 是否用真实受限角色测试所有读写路径;
  9. 是否评估约束错误和侧信道;
  10. 新表和新分区是否纳入权限与策略审计。

RLS 最适合把明确的行归属规则放到数据库执行。策略越简单、身份上下文越可信、测试越贴近真实角色,它提供的隔离就越可靠。