基于行级安全策略实现用户数据隔离解决方案
背景
在多用户架构中,多个用户共享同一套数据库实例与应用层资源,因此必须保证用户之间的数据严格隔离。任何一个用户的查询、修改、删除操作都不得触及另一用户的数据,否则将导致数据泄露、合规违约甚至法律责任等严重后果。
传统方案通常在应用层完成用户隔离,即每一条SQL在执行前由业务代码拼接用户ID条件(如WHERE user_id = ?)。该方案存在以下局限性:
- 代码漏洞风险:只要有一条SQL遗漏了用户ID过滤,就会立刻暴露给所有用户数据;新增字段、复杂子查询、ORM框架自动生成的SQL都可能成为漏洞点。
- 维护成本高:随着业务规模和开发团队扩大,难以保证所有人、所有模块、所有历史代码都遵循隔离规范,长期审计成本居高不下。
- 绕过风险:DBA、运维人员或第三方工具直连数据库执行查询时,应用层隔离完全失效。
- 难以审计:无法在数据库层面统一记录“哪些查询越过了用户边界”,问题排查依赖应用日志,定位周期长。
PostgreSQL 9.5及以上版本原生提供行级安全策略(Row-Level Security,RLS),将用户隔离能力下沉到数据库内核,由数据库强制执行数据可见性,从根本上解决应用层隔离方案的上述局限。RDS for PostgreSQL基于PostgreSQL 11及以上版本,完整支持RLS特性,本章节结合多用户场景,给出一套完整、可落地的隔离方案与最佳实践。
行级安全策略原理
行级安全策略(Row-Level Security,RLS)是PostgreSQL 9.5引入的特性,允许在表上定义一组策略(Policy)。当表启用RLS后,所有涉及该表的SELECT、INSERT、UPDATE、DELETE语句在执行时,数据库会自动将策略定义中的条件以AND方式拼接到原SQL的WHERE条件中(对INSERT/UPDATE则是USING / WITH CHECK谓词),从而限制每一行数据的可见性与可写性。
RLS的核心价值在于:隔离规则集中存储于数据库内核,所有访问路径(应用、ORM、第三方工具、DBA)都必须经过策略校验,无法绕过。
功能介绍
策略创建语法如下:
CREATE [OR REPLACE] POLICY name ON table_name
[AS { PERMISSIVE | RESTRICTIVE }]
[FOR { ALL | SELECT | INSERT | UPDATE | DELETE }]
[TO { role_spec | PUBLIC } [, ...] ]
[USING ( using_expression ) ]
[WITH CHECK ( check_expression ) ]; 参数说明:
- AS PERMISSIVE(默认):宽松策略,多策略之间以OR合并。
- AS RESTRICTIVE:限制策略,与所有宽松策略以AND合并,常用于做“黑名单”式强制过滤。
- FOR子句:限定策略生效的命令类型,ALL表示对所有DML生效。
- TO子句:指定策略适用的角色,PUBLIC表示所有角色。
- USING:对现有行(SELECT/UPDATE/DELETE时的旧行)的可见性谓词。
- WITH CHECK:对新增或更新后的新行(INSERT/UPDATE新值)的合法性谓词,不满足则报错。
策略表达式中通常引用如下所示的上下文函数来确定当前访问者的身份与用户归属:
- 当前会话执行SQL所使用的角色(受SET ROLE影响)。
SELECT current_user;
- 当前会话登录时使用的原始角色(不受SET ROLE影响)。
SELECT session_user;
- 自定义GUC参数,常用于在事务内传递用户ID。
SELECT current_setting('app.user_id');
在方案中,应用层在每个事务开始时通过SET LOCAL app.user_id = xxx将用户ID注入会话上下文,策略表达式再通过current_setting('app.user_id')取出,作为行级过滤的依据。
RLS涉及两类关键属性:
- BYPASSRLS:表owner与拥有BYPASSRLS属性的角色默认不应用RLS策略,可为应用角色显式授予或撤销该属性。
- FORCE RLS:对表owner强制启用RLS策略,防止owner通过自身身份绕过隔离。
命令示例:
- 授予应用角色绕过RLS的能力(生产环境慎用)。
ALTER ROLE app_user BYPASSRLS;
- 撤销绕过RLS的能力。
ALTER ROLE app_user NOBYPASSRLS;
- 对表owner强制启用RLS。
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
RLS不是对标准GRANT/REVOKE权限的替代,而是补充。
- 标准权限决定“能否执行某类操作”,RLS决定“能操作哪些具体的行”。
- 必须先通过标准权限校验,才进入RLS策略校验;两者同时满足才能执行。
- 即使某用户拥有表的SELECT权限,若无对应RLS策略许可,也查不到任何行(不是报错,而是返回0行)。
- 对于INSERT/UPDATE,RLS既校验旧行(USING)也校验新行(WITH CHECK),防止越权写入。
具体步骤如下:
- 环境准备及账号权限配置。
环境准备包括:规划数据库账号、创建用户表和业务表、配置权限等。
- 创建应用连接角色。
CREATE ROLE app_user LOGIN PASSWORD 'your_password';
- 创建用户主表。
CREATE TABLE users ( user_id BIGINT PRIMARY KEY, user_name VARCHAR(128) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); - 创建业务表 orders(含 user_id 列)。
CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, amount NUMERIC(12,2) NOT NULL, status INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); - 在user_id上创建索引(提升性能必须的操作)。
CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_orders_user_status ON orders(user_id, status);
- 授予app_user权限。
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user; GRANT SELECT ON users TO app_user;
- 创建应用连接角色。
- 启用行级安全策略并创建隔离策略。
- 启用行级安全。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
- 创建隔离策略。
CREATE POLICY user_isolation ON orders FOR ALL TO app_user USING (user_id = current_setting('app.user_id')::bigint) WITH CHECK (user_id = current_setting('app.user_id')::bigint); - 强制表owner也遵守RLS(防止绕过)。
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
- 启用行级安全。
- 测试用户数据隔离效果。
使用不同用户上下文测试数据隔离,验证每个用户只能访问自己的数据。
- 准备测试数据(以管理员身份)。
INSERT INTO orders (user_id, order_no, amount) VALUES (1001, 'ORD001', 100.00), (1001, 'ORD002', 200.00), (1002, 'ORD003', 300.00); - 模拟用户1001的连接。
SET ROLE app_user; SET app.user_id = '1001';
- 用户1001查询(只能看到自己的数据)。
SELECT * FROM orders;
预期返回2条记录(user_id=1001)。
- 尝试越权插入(会被拒绝)。
INSERT INTO orders (user_id, order_no, amount) VALUES (1002, 'ORD004', 400.00);
预期返回:
ERROR: permission denied for sequence orders_id_seq
- 切换到用户1002。
RESET app.user_id; SET app.user_id = '1002';
- 用户1002查询。
SELECT * FROM orders;
预期返回1条记录(user_id=1002)。
- 准备测试数据(以管理员身份)。
用户隔离方案设计
用户隔离方案整体架构自上而下分为三层:
- 应用层:应用在每个请求开始时,根据登录态解析出当前用户ID,并在获取数据库连接后立即注入用户上下文。
- 连接池层:如PgBouncer或应用内置连接池,复用物理连接;连接借出时由应用执行SET LOCAL app.user_id,归还前执行RESET或确保事务已结束。
- 数据库层(RDS for PostgreSQL实例):每张业务表都含user_id列,并启用RLS策略;策略表达式为user_id = current_setting('app.user_id')::bigint。
PostgreSQL的自定义GUC参数(Custom GUC)允许应用层在事务级别注入任意的命名参数。SET LOCAL设置只在当前事务中生效,事务结束自动RESET,能天然避免连接复用导致的用户串号问题。
BEGIN; SET LOCAL app.user_id = '10086'; SELECT * FROM orders WHERE status = 1; -- RLS自动拼接 AND user_id = 10086 COMMIT;
每张业务表都必须包含一个user_id列,并建议满足以下要求:
- 数据类型:建议使用BIGINT以容纳大规模用户号;小型也可使用INT或UUID。
- 非空约束:user_id应设为NOT NULL,避免出现“无用户”行被策略过滤后变成幽灵数据。
- 索引:必须在user_id上建索引,避免RLS谓词导致全表扫描;联合查询高频列建议建复合索引(user_id, 列名)。
- 外键:业务表之间的外键列也应携带user_id,并在外键中包含user_id,防止跨用户引用。
推荐的通用策略模板如下(适用于绝大多数业务表):
CREATE POLICY user_isolation ON schema.table_name
USING (user_id = current_setting('app.user_id')::bigint)
WITH CHECK (user_id = current_setting('app.user_id')::bigint); USING与WITH CHECK一致,保证查询、修改、删除的可见行同新增/更新后的新行属于同一用户。若某场景允许只读跨用户但不能写,可省略WITH CHECK,场景建议两者均配置,实现真正的行级强隔离。
推荐划分以下角色类别:
- 应用角色(app_user):仅授予业务表的DML权限、NOBYPASSRLS、FORCE RLS对表owner生效,避免被绕过。
- DBA角色(dba_role):授予DDL与运维权限;可由安全管理员在有审计、有审批的情况下临时BYPASSRLS处理故障。
- 只读角色(ro_user):数据中台、BI报表角色,授予SELECT权限,并配置与app_user相同的RLS策略,确保只看本用户数据。
最佳实践建议
- 性能影响:RLS策略会自动拼接WHERE条件,因此必须确保user_id列已建立索引(B-tree),否则原本高效的查询可能退化为全表扫描。对于高频查询列,建议建立复合索引(user_id, 业务列)。
- 连接池配置:PgBouncer等连接池在事务池化模式下,SET LOCAL仅作用于事务,事务结束自动RESET;但若使用会话池化模式(session pooling),应用必须在归还连接前显式RESET app.user_id,防止用户ID串号遗留到下一个使用者。
- 策略维护:新增业务表必须立即配套创建RLS策略并ENABLE/FORCE,建议通过DDL审批流程或自动化脚本统一管理,避免遗漏。
- 监控:通过pg_stat_user_tables.seq_scan/idx_scan监控RLS谓词是否触发全表扫描;通过pg_stat_statements观察慢SQL中是否出现user_id未命中索引的情况。
- 事务边界:SET LOCAL仅在事务内有效,事务结束自动失效;切忌在事务外使用SET LOCAL,否则不生效或退化为SET(会话级),留下安全隐患。
- 测试:必须覆盖跨用户访问场景,包括越权INSERT、越权UPDATE(修改user_id列)、越权DELETE等用例;建议在自动化CI流水线中加入“跨用户可见性”回归测试。
- 视图与函数:视图默认遵循调用者的RLS上下文。SECURITY DEFINER函数会以函数owner身份运行,可能绕过RLS,必须谨慎使用并显式声明SECURITY INVOKER。
常见问题
- 问题1:RLS是否影响查询性能?
RLS在SQL执行计划中表现为一个额外的WHERE谓词。只要user_id列建立了索引(B-tree或复合索引),查询规划器会优先使用索引扫描,性能影响可忽略;但如果user_id未建立索引,原本的单表查询可能会退化为全表扫描,因此索引是RLS方案的性能前提。可以通过EXPLAIN ANALYZE查看RLS谓词是否命中索引。
- 问题2:表owner为什么默认绕过RLS?
由于表owner对表拥有完全控制权,PostgreSQL默认认为owner可信,因此默认绕过RLS。生产环境必须对每张业务表执行ALTER TABLE ... FORCE ROW LEVEL SECURITY,强制owner也遵守该策略,这样即使owner被泄露或被误用也无法越权访问用户数据。
- 问题3:如何在连接池(如PgBouncer)中传递用户ID?
推荐使用PgBouncer的事务池化模式(pool_mode=transaction):应用在事务开头执行SET LOCAL app.user_id = xxx,事务结束自动RESET,连接归还连接池后无残留。若使用会话池化模式(pool_mode=session),应用必须在归还连接前显式执行RESET app.user_id,事务池化模式下若使用SET(非SET LOCAL,仅SET会变为session级),则在会话内残留,会导致下个用户串号,应严格避免。
- 问题4:RLS与视图(View)的交互关系?
普通视图默认遵循当前调用者的RLS策略,即在视图内部访问底层表时会应用调用者的RLS上下文;这是推荐用法。但带SECURITY DEFINER的函数或视图会以definer身份执行,可能绕过RLS,应尽量使用SECURITY INVOKER(默认)或显式限定。物化视图(Materialized View)的刷新由owner执行,刷新时绕过RLS,但查询物化视图时仍按调用者策略过滤。
- 问题5:如何审计哪些查询被RLS过滤?
可通过以下手段审计:
- 通过pg_stat_statements视图观察SQL执行次数与平均耗时,识别异常慢SQL。
- 通过pg_audit扩展(RDS支持)开启DDL/DML审计,逐条记录SQL执行者与SQL文本。
- 在策略表达式中加入日志函数(如通过触发器或自定义函数记录访问行为),但会增加性能开销,仅用于排查阶段。
- 问题6:多层嵌套查询中RLS是否生效?
是的,RLS在子查询、JOIN、CTE、视图、UNION等所有SQL形态中都生效,因为RLS作用在物理表上而非SQL语法层。即使是“SELECT * FROM (SELECT * FROM orders) t”这种嵌套子查询,内层对orders的访问也会被RLS过滤。但要注意SECURITY DEFINER函数会切换上下文,可能绕过。
- 问题7:批量导入数据时如何处理user_id?
批量导入有两种方式:
- 应用层导入:连接使用app_user角色,事务开头执行SET LOCAL app.user_id = xxx,再执行COPY或INSERT,WITH CHECK会校验导入数据的user_id必须与上下文一致,防止越权写入。
- 运维导入:临时使用BYPASSRLS角色直接COPY,但必须经审批并留审计日志,导入完成后立即撤销BYPASSRLS。
- 问题8:RLS策略是否支持复杂的业务规则?
支持。RLS的表达式可以引用任何SQL表达式,包括子查询、函数、JOIN等。例如可实现“用户管理员可以看本用户所有数据、普通用户只能看自己创建的数据”的多策略组合:通过对同一表创建多条PERMISSIVE策略(OR关系)实现。但是过于复杂的策略会影响SQL规划器优化空间,建议保持策略表达式简单可索引,复杂业务规则尽量放在应用层与RLS协同工作。