行级安全悄悄失效的五种情形
行级安全是防范漏写 WHERE 子句的最佳防线,直到它悄无声息地不再生效。本文讲解在 PostgreSQL 17 上 RLS 失效放行的五种情形,每种都附带复现步骤、修复方法,以及一条能在 CI 中发现它的系统目录查询。
本页目录
行级安全(RLS)是 Postgres 提供的、防范漏写 WHERE user_id = … 的最佳防线。但它也很容易在不知不觉中被关掉:一个带某个属性的角色、一个由错误用户创建的视图、一个返回空字符串而不是 NULL 的配置项。本文介绍 RLS 悄悄不再保护你数据的五种情形。每种都附带可以直接粘贴到 psql 里的复现步骤和修复方法,文末还有一条能在 CI 中发现其中多数问题的系统目录查询。所有代码片段都在 PostgreSQL 17.11 上运行过,与 Relyt One 使用的主版本相同。
如果你读过 Relyt One 托管认证入门,就已经在使用我们这里要测试的模式:应用在每个事务开始时,用经过验证的令牌设置 app.user_id,策略再把每一行的所有者与它比较。这个模式本身是可靠的。下面这些失效并不是 Postgres 的 bug,而是这个模式悄悄不再生效的地方,而且它们的表现完全一样:没有错误,没有日志,只是有些行被送到了不该看到它们的人手里。
下面所有内容都在同一个 psql 会话中以超级用户身份运行,并用 SET ROLE 切换身份。创建或修改对象(表、策略、视图)的语句应以 demo_owner 或超级用户身份运行;以应用身份执行的语句则以 SET ROLE demo_app 开头。三个角色对应真实项目中的情形:
demo_owner拥有这些表,并负责运行迁移;demo_app是应用连接数据库时使用的角色,带有NOBYPASSRLS;demo_admin带有BYPASSRLS。在 Relyt One 上,它对应relytdb_owner,即每个项目一开始就有的所有者角色。
CREATE ROLE demo_owner NOLOGIN;
CREATE ROLE demo_app NOLOGIN;
CREATE ROLE demo_admin NOLOGIN BYPASSRLS;
CREATE SCHEMA demo AUTHORIZATION demo_owner;
GRANT USAGE ON SCHEMA demo TO demo_app;
GRANT USAGE, CREATE ON SCHEMA demo TO demo_admin;一张表、一条策略、四行数据。最后一行的所有者是空字符串;原因见第 4 节。
SET ROLE demo_owner;
CREATE TABLE demo.notes (id serial PRIMARY KEY, user_id text NOT NULL, body text NOT NULL);
INSERT INTO demo.notes (user_id, body)
VALUES ('alice', 'a1'), ('alice', 'a2'), ('bob', 'b1'), ('', 'orphan');
ALTER TABLE demo.notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE demo.notes FORCE ROW LEVEL SECURITY;
CREATE POLICY own ON demo.notes FOR ALL
USING (user_id = current_setting('app.user_id', true))
WITH CHECK (user_id = current_setting('app.user_id', true));
RESET ROLE;
GRANT SELECT, INSERT, UPDATE, DELETE ON demo.notes TO demo_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA demo TO demo_app;
GRANT SELECT ON demo.notes TO demo_admin;应用发出的一次正常请求如下:
SET ROLE demo_app;
BEGIN;
SELECT set_config('app.user_id', 'alice', true); -- true:仅在本事务内有效
SELECT body FROM demo.notes; -- a1, a2
COMMIT;Alice 看到了她自己的两行。现在我们来把它弄坏。
PostgreSQL 文档(英文;本文引用的文档原文均已翻译成中文)说得很直接:"超级用户和带有 BYPASSRLS 属性的角色在访问表时总是绕过行安全系统。"策略根本不会被查看。
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes; -- 2 (策略生效)
COMMIT;
SET ROLE demo_admin; SELECT count(*) FROM demo.notes; -- 4 (BYPASSRLS:所有行)
RESET ROLE; SELECT count(*) FROM demo.notes; -- 4 (超级用户:所有行)这个属性属于角色本身,而不属于它的成员。如果 demo_app 是 demo_admin 的成员,它仍然会被过滤,直到某段代码执行了 SET ROLE demo_admin:
GRANT demo_admin TO demo_app;
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes; -- 2 (仅仅是成员关系不会绕过)
COMMIT;
SET ROLE demo_admin; SELECT count(*) FROM demo.notes; -- 4在 Relyt One 上,这一点比听起来更重要。从控制台创建的数据库用户带有 BYPASSRLS,relytdb_owner 也一样。我们在一个真实的分支上验证过:一张带 FORCE 和正确策略的表,用 relytdb_owner 查询,返回了所有行。以这类角色连接的应用完全没有行级安全。请用 SQL 创建一个专用角色,并以它连接;托管认证指南演示了做法,我们也验证过这样的角色可以通过直连和连接池两种连接字符串登录。
检测方法: 以应用连接时使用的角色运行下面的查询,它必须返回 false。
SET ROLE demo_app;
SELECT rolsuper OR rolbypassrls AS bypasses_rls FROM pg_roles WHERE rolname = current_user; -- f默认情况下,"表的所有者通常也会绕过行安全,不过表的所有者可以用 ALTER TABLE ... FORCE ROW LEVEL SECURITY 选择受行安全约束。"只用 ENABLE 时,除了拥有这张表的角色之外,所有人都受保护。
SET ROLE demo_owner;
CREATE TABLE demo.notes2 (id serial PRIMARY KEY, user_id text NOT NULL, body text NOT NULL);
INSERT INTO demo.notes2 (user_id, body) VALUES ('alice', 'x'), ('bob', 'y');
ALTER TABLE demo.notes2 ENABLE ROW LEVEL SECURITY; -- 已启用,但未强制
CREATE POLICY own ON demo.notes2 FOR ALL
USING (user_id = current_setting('app.user_id', true))
WITH CHECK (user_id = current_setting('app.user_id', true));
SELECT count(*) FROM demo.notes2; -- 2 (alice 和 bob 的行:策略被跳过了)
ALTER TABLE demo.notes2 FORCE ROW LEVEL SECURITY;
SELECT count(*) FROM demo.notes2; -- 0 (现在所有者也受策略约束了)
RESET ROLE;如果你的迁移工具和应用共用同一个角色,那个角色就是所有者,你的应用就会绕过 RLS。对每张存放用户数据的表都使用 FORCE,并让所有者远离请求路径。在 Relyt One 上,所有者带有 BYPASSRLS,所以 FORCE 对它无能为力;那里的解决办法见第 1 节。
视图以其所有者的权限运行。CREATE VIEW 文档写道:"默认情况下,对视图所引用的底层基础关系的访问,由视图所有者的权限决定",行级安全策略也是如此。SECURITY DEFINER 函数则以函数所有者的身份做同样的事。两者都很有用;而当所有者绕过 RLS 时,它们恰恰就是 RLS 本该防住的东西。
再拿 notes2 来演示:把它改回"已启用、未强制",在它之上创建一个视图和一个 definer 函数,两者都归 demo_owner 所有,然后请求 alice 的行:
ALTER TABLE demo.notes2 NO FORCE ROW LEVEL SECURITY;
GRANT SELECT ON demo.notes2 TO demo_app, demo_admin;
SET ROLE demo_owner;
CREATE VIEW demo.v_notes2 AS SELECT * FROM demo.notes2;
CREATE FUNCTION demo.count_notes2() RETURNS bigint
LANGUAGE sql SECURITY DEFINER SET search_path = demo, pg_temp
AS $$ SELECT count(*) FROM demo.notes2 $$;
RESET ROLE;
GRANT SELECT ON demo.v_notes2 TO demo_app;
GRANT EXECUTE ON FUNCTION demo.count_notes2() TO demo_app;
SET ROLE demo_app;
BEGIN;
SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes2; -- 1
SELECT count(*) FROM demo.v_notes2; -- 2 泄露
SELECT demo.count_notes2(); -- 2 泄露
COMMIT;给表加上 FORCE 可以堵住这两个漏洞,因为所有者现在也受策略约束。但如果所有者本身就绕过 RLS,FORCE 就没有任何作用。以 demo_admin(我们用它代替 relytdb_owner)创建一个视图,它在已 FORCE 的表上照样泄露:
| alice 的查询在哪里运行 | 她看到的行数 |
|---|---|
| 直接查询表 | 1 |
| 通过表所有者拥有的视图(表未 FORCE) | 2 |
通过 SECURITY DEFINER 函数(表未 FORCE) | 2 |
同一个视图和函数,在 FORCE 之后 | 1 |
通过 BYPASSRLS 角色拥有的视图,表已 FORCE | 2 |
同一个视图加上 security_invoker = true | 1 |
要重现最后两行,请以 demo_admin 创建一个视图,并对表执行 FORCE:
RESET ROLE;
ALTER TABLE demo.notes2 FORCE ROW LEVEL SECURITY;
SET ROLE demo_admin;
CREATE VIEW demo.v_admin AS SELECT * FROM demo.notes2; -- 归 BYPASSRLS 角色所有
RESET ROLE;
GRANT SELECT ON demo.v_admin TO demo_app;
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.v_admin; -- 2 泄露,尽管表已经 FORCE
COMMIT;真正的解决办法是 security_invoker,它在 PostgreSQL 15 中加入("允许可选地由视图调用者的权限来控制视图对表的访问")。启用后,适用的是运行查询的那个用户的策略:
CREATE VIEW demo.v_safe WITH (security_invoker = true) AS SELECT * FROM demo.notes2;
ALTER VIEW demo.v_admin SET (security_invoker = true); -- 修复已有的视图;alice 现在看到 1 行对于建立在用户数据表之上的 SECURITY DEFINER 函数,最好不要有。如果必须用,就把用户作为参数传入,并在函数内部显式按它过滤,同时务必固定 search_path,就像上面的示例那样。
这个模式依赖 current_setting('app.user_id', true)。第二个参数的意思是"如果该配置项不存在,就返回 NULL 而不是报错",全新的会话返回的就是这个。然后一个事务用 set_config(…, true) 设置了值,并结束。下一条查询会看到什么?
先开一个全新的会话,因为前面的请求已经让这个配置项被定义了:
-- psql:重新连接以获得全新的会话
\c
SET ROLE demo_app;
SELECT coalesce(current_setting('app.user_id', true), '<NULL>'); -- <NULL>
BEGIN; SELECT set_config('app.user_id', 'alice', true); COMMIT;
SELECT '[' || coalesce(current_setting('app.user_id', true), '<NULL>') || ']'; -- []
SELECT user_id || '/' || body FROM demo.notes; -- /orphan不是 NULL,而是空字符串。我们在 PostgreSQL 15、16 和 17 上都看到了这一点:事务结束后,这个配置项仍然处于已定义状态,值为空。这不是文档中记载的行为,所以请在你自己的版本上验证。一条比较 user_id = '' 的策略,会匹配所有者为空字符串的任何行,而我们的表里正好有一行。这类行来自 bug 和迁移:一个绕过验证的空 sub,一个没人有意设置的默认值。它们恰恰是你最不想暴露的行。
本节的另一半是第三个参数上的错误。传 false 时,配置项会在整个会话期间有效,而在使用连接池时,该连接上的下一个请求会继承它:
SET ROLE demo_app;
SELECT set_config('app.user_id', 'alice', false); -- 会话级
SELECT count(*) FROM demo.notes; -- 2,下一个使用这个连接的人也是 2解决办法 是让策略拒绝空的配置项,并杜绝空所有者的出现:
RESET ROLE;
ALTER POLICY own ON demo.notes
USING (nullif(current_setting('app.user_id', true), '') IS NOT NULL
AND user_id = current_setting('app.user_id', true))
WITH CHECK (nullif(current_setting('app.user_id', true), '') IS NOT NULL
AND user_id = current_setting('app.user_id', true));
ALTER TABLE demo.notes ADD CONSTRAINT user_id_not_empty CHECK (user_id <> '') NOT VALID;在全新的会话中检查:
-- psql:重新连接以获得全新的会话
\c
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true); COMMIT;
SELECT count(*) FROM demo.notes; -- 0 (现在空的配置项什么也匹配不到)
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes; -- 2 (alice 的正常请求仍然有效)
COMMIT;(NOT VALID 让你在还存在旧的空行时也能添加约束;先清理这些行,再执行 VALIDATE CONSTRAINT。)你的数据访问函数应该始终只调用 set_config(…, true) 这一种形式。
策略是叠加的。 "当多条策略适用于同一个查询时,它们会用 OR(针对宽松策略,这是默认值)或用 AND(针对限制性策略)组合起来。"为某个报表或客服工具多加一条宽松策略,就会为所有人放宽访问:
RESET ROLE;
CREATE POLICY pub ON demo.notes FOR SELECT USING (true);
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes; -- 4,alice 看到了所有行
COMMIT;RESTRICTIVE 策略用 AND 组合,因此无论之后再添加什么宽松策略,它都能守住底线:
RESET ROLE;
CREATE POLICY tenant_guard ON demo.notes AS RESTRICTIVE FOR SELECT
USING (nullif(current_setting('app.user_id', true), '') IS NOT NULL
AND user_id = current_setting('app.user_id', true));
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes; -- 又变回 2,即使 `pub` 仍然存在
COMMIT;新表一开始没有 RLS。 如果你还给应用角色授予了默认权限,那么你创建后又忘记的第一张表,任何人都能读:
RESET ROLE;
ALTER DEFAULT PRIVILEGES FOR ROLE demo_owner IN SCHEMA demo GRANT SELECT ON TABLES TO demo_app;
SET ROLE demo_owner;
CREATE TABLE demo.notes_new (user_id text NOT NULL, body text NOT NULL); -- 没有人执行 ENABLE ROW LEVEL SECURITY
INSERT INTO demo.notes_new VALUES ('alice', 'n1'), ('bob', 'n2');
RESET ROLE;
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes_new; -- 2,alice 也看到了 bob 的行
COMMIT;反过来则是安全的:启用了 RLS 但没有任何策略的表是"默认拒绝",没有任何行可见或可更新。
- 只有
USING的策略。 对于ALL和UPDATE策略,"如果没有定义WITH CHECK表达式,那么也会用USING表达式"来检查新行。INSERT策略则根本不能有USING子句。在一个真实的 Relyt One 分支上,我们测试了用户写入归属于别人的行,结果被策略拒绝了。 - 某个命令没有策略。 启用了 RLS 但没有适用的策略时,文档说会假定存在一条"默认拒绝"策略。只有
FOR SELECT策略的表会拒绝插入。
唯一约束。 "引用完整性检查,例如唯一约束或主键约束以及外键引用,总是会绕过行安全,以确保数据完整性得到维持。"文档把由此产生的后果称为"隐蔽信道"。Bob 读不到 alice 的 slug,却能得知它存在:
SET ROLE demo_owner;
CREATE TABLE demo.slugs (id serial PRIMARY KEY, user_id text NOT NULL, slug text NOT NULL UNIQUE);
INSERT INTO demo.slugs (user_id, slug) VALUES ('alice', 'secret-project');
ALTER TABLE demo.slugs ENABLE ROW LEVEL SECURITY;
ALTER TABLE demo.slugs FORCE ROW LEVEL SECURITY;
CREATE POLICY own ON demo.slugs FOR ALL
USING (user_id = current_setting('app.user_id', true))
WITH CHECK (user_id = current_setting('app.user_id', true));
RESET ROLE;
GRANT SELECT, INSERT ON demo.slugs TO demo_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA demo TO demo_app;
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'bob', true);
SELECT count(*) FROM demo.slugs WHERE slug = 'secret-project'; -- 0
INSERT INTO demo.slugs (user_id, slug) VALUES ('bob', 'secret-project');
-- ERROR: duplicate key value violates unique constraint "slugs_slug_key"
ROLLBACK;把唯一性改成按租户区分,这个泄露就消失了:bob 的插入会成功。如果某个值必须在所有用户之间唯一,就要接受它是否存在是公开信息。
RESET ROLE;
ALTER TABLE demo.slugs DROP CONSTRAINT slugs_slug_key, ADD UNIQUE (user_id, slug);物化视图。 文档没有提到它们,所以请测试你自己的。物化视图保存的是有人刷新它时的行,而读取这些已存储的行并不会运行表的策略。在我们的测试中,一个在 alice 的配置项生效时构建的视图,把 alice 的两行展示给了 bob:
SET ROLE demo_owner;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
CREATE MATERIALIZED VIEW demo.m_notes AS SELECT * FROM demo.notes; -- 保存 alice 的行
COMMIT;
RESET ROLE;
GRANT SELECT ON demo.m_notes TO demo_app;
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'bob', true);
SELECT count(*) FROM demo.m_notes; -- 2 bob 读到了 alice 的行
COMMIT;不要给应用角色授予建立在用户数据之上的物化视图的访问权限。把租户列放进视图,并在查询中按它过滤,或者直接读取基表。
文档里还有一点要补充:策略表达式会对每一行先求值,"早于来自用户查询的任何条件或函数",但有一个例外:"leakproof 函数,它们保证不会泄露信息;优化器可以选择在行安全检查之前应用这类函数。"请把 LEAKPROOF 标记当作一个你必须有能力兑现的承诺。
上面列出的大部分问题都会在系统目录里留下痕迹。下面这条查询每发现一个问题就返回一行。它查看的是 public schema(其他 schema 请改名)。请以应用连接时使用的角色运行它,并在它返回任何内容时让构建失败:
-- rls_audit.sql: what can let one user's rows reach another (schema "public")
WITH tbl AS (
SELECT c.oid, c.relname, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
)
SELECT 'connected role bypasses RLS' AS problem, current_user::text AS object
FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolbypassrls)
UNION ALL
SELECT 'table without RLS', relname FROM tbl WHERE NOT relrowsecurity
UNION ALL
SELECT 'RLS enabled but not forced', relname FROM tbl WHERE relrowsecurity AND NOT relforcerowsecurity
UNION ALL
SELECT 'view without security_invoker', c.relname
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'v'
AND NOT EXISTS (SELECT 1 FROM unnest(coalesce(c.reloptions, '{}')) o
WHERE o ~* '^security_invoker=(true|t|yes|y|on|1)$')
UNION ALL
SELECT 'materialized view or foreign table (RLS does not apply)', c.relname
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('m', 'f')
UNION ALL
SELECT 'SECURITY DEFINER function', p.proname
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.prosecdef
UNION ALL
SELECT 'permissive policy that allows every row', p.polname
FROM pg_policy p
WHERE p.polrelid IN (SELECT oid FROM tbl) AND p.polpermissive AND pg_get_expr(p.polqual, p.polrelid) = 'true'
ORDER BY 1, 2;set -euo pipefail
out=$(psql "$DATABASE_URL" -X -A -t -v ON_ERROR_STOP=1 -f rls_audit.sql) # 连接或 SQL 出错会让构建失败
if [ -n "$out" ]; then echo "$out"; exit 1; fi我们用一个包含上述全部问题的 schema 运行了它。它把这些问题全部报告出来(角色、没有 RLS 的新表、未强制的表、普通视图、definer 函数、物化视图、外部表,以及放行所有行的策略),忽略了以任何写法开启 security_invoker 的视图,并且在 schema 修复之后不再返回任何内容。它看不出配置项是否为空,也看不出多条策略组合起来是否比你预想的更宽(它只能发现字面上就是 true 的策略)。这些需要由以用户身份发起请求并统计行数的测试来覆盖,比如 TODO 示例中的那些。确实不存放用户数据的表,例如查找表,会出现在"table without RLS"下;请为它们添加一份允许列表。
- 应用以带有
NOBYPASSRLS且不是超级用户的角色连接,并且自检的第一项检查结果为空。在 Relyt One 上,这绝不是relytdb_owner,也不是控制台创建的用户。 - 每张存放用户数据的表都有
ENABLE和FORCE ROW LEVEL SECURITY,并且所有者远离请求路径。 - 建立在这些表之上的视图使用
security_invoker = true;面向用户的SECURITY DEFINER函数要么避免使用,要么显式过滤。 - 策略拒绝空的配置项,并有
CHECK (user_id <> '')作为兜底。set_config始终只用true调用。 - 宽松策略之下有一条
RESTRICTIVE策略,并且自检在 CI 中运行,让新表无法在没有 RLS 的情况下上线。 - 用户数据上的唯一约束包含租户列,并且不给应用角色授予建立在用户数据之上的物化视图的权限。
行级安全值得像对待任何其他访问控制代码一样去测试:以每一种用户登录,发起每一种查询,统计行数。如果你基于托管认证构建应用,预览版文章解释了用户 ID 是如何到达数据库的,TODO 示例中则有这些测试。



