PostgreSQL函数权限测试,听起来像是只有DBA才在意的小话题。直到有一次项目临近发布,测试同事在数据库里随手敲了一段SQL,发现一个本该只给财务模块使用的月度汇总函数,任何角色都能直接调用,我们才真正意识到:PG里函数的默认权限比大多数人印象中要开放得多。这篇文章会结合我这几年的实测经历,聊透函数权限测试到底该测什么、怎么测,以及几个容易让测试结果失真、白忙一场的坑。
适合谁看?如果你正在维护PostgreSQL实例、负责应用权限设计,或者只是写了很多函数但从来没关心过"谁会执行它",这篇都值得读一读。我会尽量把原理讲清楚,也会给出可以直接抄的测试脚本和检查思路。
1. 一次函数越权事故引发的思考:PG函数权限不是"默认安全"
1.1 事故复盘:谁能执行这个函数?
当时的情况是这样的:A同学负责某个内部报表模块,他写了一个函数用来统计员工薪酬区间,放在业务schema下,名字很简单,就叫get_salary_detail(emp_id int)。开发阶段为了方便联调,他顺手对几个角色执行了GRANT EXECUTE,但从来没有想过要回收默认权限。
上线前的安全自查里,测试同事用了一个只拥有登录权限、没有任何业务授权的账号,居然也成功执行了这个函数,而且能看到返回的薪酬数据。排查下来原因让人哭笑不得:函数创建之后,PostgreSQL默认把EXECUTE权限授予了PUBLIC,也就是说所有能连上数据库的账号,只要知道函数名和参数,都可以直接调用。这不是哪条语句写错了,而是从一开始就没人意识到"函数需要收权"这件事。
这个案例太典型了。我们总以为数据库权限管理的重点是表、视图、schema,却忘了函数同样是可执行对象,而且PG在函数权限上有一个相当宽松的默认值。只要函数里塞了敏感的查询逻辑,哪怕表权限收得再严,函数也可能成为绕过表权限的后门。
1.2 函数权限在整个PG权限体系中的位置
PostgreSQL的权限体系可以简单分成几层:实例层、数据库层、schema层、对象层。函数是属于对象层的一个具体对象,和表、序列、视图平级。
但函数有个特殊性:它的可执行性受两层约束。第一层,用户必须对函数所在的schema拥有USAGE权限,否则连函数名都访问不到;第二层,用户要对函数本身拥有EXECUTE权限,否则即使是调用也会被拒绝。
更麻烦的是第三层:函数一旦执行起来,函数体里如果继续查询表、调用其他函数,又会触发底层对象的权限检查。所以测试函数权限时,你其实在验证一条完整的链路:schema的进入权、函数的执行权、函数内部对象的访问权。这三层只要有一层断掉,调用就会失败;反过来,这三层如果都洞开,权限测试就等于白做。
搞清楚这个三层链路之后,我们的测试目标就明确了:不是简单跑一句"能执行成功",而是要分角色、分函数、分场景去验证每一层到底通不通,以及不该通的场景到底会不会被挡下来。
2. EXECUTE与USAGE:函数权限的核心构成与默认陷阱
2.1 两层权限缺一不可
先说最基础的授权语句。假设我们有一个业务模式app,里面有一个函数:
CREATE SCHEMA app; CREATE FUNCTION app.order_summary(order_id bigint) RETURNS TABLE(order_no text, total numeric) LANGUAGE sql AS $$ SELECT order_no, total_amount FROM app.orders WHERE id = order_id; $$;现在我创建一个只读角色,希望它只能执行这个汇总函数,不能碰原始表:
CREATE ROLE app_readonly LOGIN PASSWORD 'readonly_pwd'; REVOKE ALL ON FUNCTION app.order_summary(bigint) FROM PUBLIC; GRANT USAGE ON SCHEMA app TO app_readonly; GRANT EXECUTE ON FUNCTION app.order_summary(bigint) TO app_readonly;然后模拟这个角色去调用:
SET ROLE app_readonly; SELECT * FROM app.order_summary(42);如果你漏掉了GRANT USAGE ON SCHEMA app,执行结果会是这样:
ERROR: permission denied for schema app而如果你保留USAGE权限,却漏掉了GRANT EXECUTE,则会得到:
ERROR: permission denied for function app.order_summary(bigint)两类报错信息完全不同,但排查方向很明确:前者是schema门没开,后者是函数钥匙没给。用生活化的类比来说,schema有点像一栋办公楼的大门,函数是办公室门。只给办公室钥匙却进不了楼,或者能进楼却开不了办公室门,都办不成事。
实际测试中,最容易出现的情况是:GRANT EXECUTE给了,但schema的USAGE忘了给,开发在本地用超级账号测又测不出来,直到用真实角色一跑就报错。反过来也有,USAGE给了,EXECUTE没给,结果应用侧一脸懵"明明能访问schema,怎么函数就不能调"。所以测试的时候一定要把这两层分开验。
2.2 默认PUBLIC EXECUTE:PG给了所有人一把钥匙
在继续往下说之前,必须先把PG的一个默认行为讲透:只要创建一个函数,且没有显式回收权限,那么PUBLIC——也就是所有角色——天然拥有这个函数的EXECUTE权限。
这并不是Bug,而是PostgreSQL长期以来的兼容性设计。它默认假设"函数是可以被调用的",就像很多SQL语言里函数天生是公开接口一样。但放在生产环境里,这个默认值就成了安全漏洞的高发区。
尤其需要注意的是,PostgreSQL 15之后,publicschema的默认权限发生了变化,普通角色不再能在publicschema里随意创建表了,但函数的PUBLIC EXECUTE默认行为一直保留着。也就是说,即使你升级到了新版本,也别指望系统帮你自动收紧函数权限。该手动REVOKE的,一条都不能少。
所以我的建议是:任何函数创建完成后,如果不希望所有角色都能调用,至少先执行一句收权:
REVOKE ALL ON FUNCTION app.order_summary(bigint) FROM PUBLIC;然后通过GRANT定向给需要调用的角色。如果不做这一步,后面的一切精细授权都是给"默认全开"的大门口只加了一道小栅栏。
2.3 用什么命令查看函数权限
测试之前先学会看权限。最直接的方式是用psql的\df+:
\df+ app.order_summary输出里会有一列Access privileges,当你没有做过任何权限调整时,它可能是空的或者显示默认ACL;当你执行过REVOKE和GRANT之后,会看到类似这样的内容:
app_readonly=X/postgres其中X表示EXECUTE,postgres是授权者。如果你看到=X/postgres这种“等号左边什么都没有”的条目,就要警觉了,这说明PUBLIC被授予了EXECUTE。
更准确的方式是直接查系统目录。PostgreSQL把函数权限存在pg_proc表的proacl字段里:
SELECT p.proname, pg_get_userbyid(p.proowner) AS owner, p.proacl FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'app' ORDER BY p.proname;proacl为NULL时,表示使用的是默认ACL,也就是PUBLIC EXECUTE。一旦你做了REVOKE操作,这个字段就会变成一条具体的ACL数组,你就能清楚地看到哪个角色拥有什么权限了。做权限审计时写一个这样的查询脚本,比在pgAdmin的界面里一个个点要靠谱得多。
3. SECURITY DEFINER与search_path:权限测试里最容易翻车的地方
3.1 执行者权限与定义者权限的本质差别
函数在创建时可以声明两种安全上下文:SECURITY INVOKER(默认)和SECURITY DEFINER。
SECURITY INVOKER的意思是,函数以"调用者"的身份执行,函数内部查询表、调用其他函数时,用的是当前调用用户自己的权限。这符合直觉,也很好测试:谁调用,就是谁的权利范围。
SECURITY DEFINER则完全不同。它会让函数以"函数属主"的身份执行,哪怕调用者是个权限很低的登录账号,只要你有这个函数的EXECUTE权限,函数内部就能用属主的权限去访问那些调用者原本碰不到的对象。
用个比喻:这就像大楼里某个房间的门禁卡绑定在领队身上,你是队员,只要领队带你进去,你就能进入房间;但你还想单独进别的房间,门禁不认你。
权限测试里,对SECURITY DEFINER函数的测试逻辑要彻底转变。普通函数测的是"调用者有没有足够的权限跑完函数体内SQL";而SECURITY DEFINER函数要测的是"函数属主的权限是否被安全地封装住了,调用者能不能借助这个函数越权拿到不该拿的东西"。这也是这类函数最容易出漏洞的地方。
3.2 search_path攻击的实际测试
说SECURITY DEFINER安全风险,最经典的就是search_path劫持。原理一句话就能讲明白:函数内部如果写了不带schema前缀的表名或函数名,那么这些名字在函数执行时是依赖当前会话的search_path来解析的。如果调用者能控制search_path,又能在某个提前搜索的schema里放一个同名对象,函数就可能解析到攻击者准备的对象上。
我建议每个做权限测试的人都在测试环境里复现一次这个过程,才能真正理解为什么SECURITY DEFINER函数必须固定search_path。
先建一个"看起来正常"的函数:
CREATE FUNCTION public.sensitive_lookup(account_id int) RETURNS text LANGUAGE sql SECURITY DEFINER AS $$ SELECT note FROM account_notes WHERE id = account_id; $$;注意,这个函数没有设置search_path。现在,假设一个低权限用户在pg_temp临时schema里建了一个同名的表:
SET ROLE app_readonly; CREATE TEMP TABLE account_notes (note text); INSERT INTO account_notes VALUES ('这是伪造数据'); SELECT public.sensitive_lookup(1); SELECT current_user;如果这个函数真的解析到了临时表,查询就会返回"这是伪造数据"。这意味着函数的执行上下文被调用者劫持了。即使这次只是搬出一个无害的临时表,真实攻击里也可以替换成恶意函数、读取不该读的数据。
正确的写法是,在函数定义时显式锁定搜索路径:
CREATE FUNCTION public.sensitive_lookup(account_id int) RETURNS text LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ SELECT note FROM account_notes WHERE id = account_id; $$;把pg_catalog放在最前面,再显式带上要访问的业务schema,调用者再想通过pg_temp拦截就无能为力了。
在测试环节,你至少应该做到:对每一个SECURITY DEFINER函数,检查它是否显式设置了search_path;然后模拟低权限用户设置一个恶意的search_path再调用它,看看结果是否发生异常。这两步做下来,能拦掉绝大多数经典漏洞。
3.3 重载函数是权限测试里最隐蔽的盲区
PostgreSQL允许函数重载:函数名相同,但参数类型或参数个数不同,它们是两个完全独立的对象。权限也是独立授予的。
举个例子:
CREATE FUNCTION app.fmt_price(int) RETURNS text ... CREATE FUNCTION app.fmt_price(numeric) RETURNS text ...这时候如果你执行:
GRANT EXECUTE ON FUNCTION app.fmt_price(int) TO app_readonly;那你只给int版本开了权限,numeric版本依然只有PUBLIC权限(如果没回收)或者谁都调不了(如果回收了)。应用端如果恰好调用的是numeric版本,测试就会莫名其妙失败;更危险的是,如果两个版本都没收PUBLIC,而测试只测了其中一个版本,就会漏掉另一个。
所以在测试脚本里,我建议按函数OID遍历,而不是按函数名遍历。用下面这种查询把同一个函数名下的所有重载都捞出来:
SELECT p.oid::regprocedure AS func, p.proacl, p.prosecdef FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'app' AND p.proname = 'fmt_price' ORDER BY p.oid::regprocedure::text;把重载版本全部列出来再逐一授权和测试,才能避免"测了A版本、漏了B版本"的尴尬。
4. 一套可落地的函数权限测试流程:从角色设计到模拟执行
4.1 准备工作:角色、模式、函数的规划
权限测试最忌讳临时抱佛脚。如果连"谁应该执行哪个函数"都没定义清楚,测试就只是碰运气。我的习惯是先把角色模型梳理出来,再谈测试。
一个常见的业务角色划分大概是这样的:
| 角色 | 用途 | 期望的函数权限 |
|---|---|---|
| app_anon | 匿名只读访问 | 只允许执行公开查询函数 |
| app_readonly | 内部报表只读 | 允许执行汇总、查询类函数 |
| app_writer | 业务写入 | 允许执行插入、更新类函数 |
| app_admin | 管理运维 | 允许执行管理类函数 |
| app_service | 服务账号 | 允许执行全部业务函数 |
然后为每个函数注册"预期权限"。不需要写复杂的工具,一张表格就够了,字段包括:函数签名、所属schema、预期可执行角色、是否SECURITY DEFINER、函数内部访问哪些表、是否需要表权限。
这个表是后面所有测试断言的参照物。没有预期就谈不上测试。
4.2 分场景测试:越权执行、正常执行、异常路径
准备好了之后,我会把测试拆成三类场景:
第一类是正常路径。用目标角色执行应该成功的函数,确认返回结果正常。比如:
SET ROLE app_readonly; SELECT * FROM app.order_summary(42);第二类是越权路径。用一个不该有权限的角色去执行不该调的函数,期望出现权限报错。比如:
SET ROLE app_anon; SELECT * FROM app.get_payroll_data(1); -- 期望报错:permission denied for function app.get_payroll_data这类场景非常关键。只测"该通的通"是不够的,还要测"不该通的必须被挡住"。
第三类是异常路径。包括给不存在的函数签名授权、用错误的数据类型调用、在schema USAGE缺失的情况下调用函数、对已删除的角色执行GRANT等。这些场景主要是验证权限系统不会因为异常输入而出现意外放行。
实际操作时,我强烈建议每个场景都单独开启一个事务,或者至少每次SET ROLE之后在场景结束立刻RESET ROLE。很多人把SET ROLE当作一次性操作,结果测试脚本越跑越长,角色越切越乱,后面的测试就全部失真了。
一个标准测试块的写法大致是这样:
BEGIN; SET LOCAL ROLE app_readonly; SELECT * FROM app.order_summary(42); RESET ROLE; ROLLBACK;用SET LOCAL ROLE可以避免角色切换泄漏到事务外,测试更干净。
4.3 用has_function_privilege自动化断言
手工场景测完,还要有自动化兜底。PostgreSQL提供了一个现成的权限判断函数has_function_privilege,可以直接在SQL里断言某个角色对某个函数是否拥有EXECUTE权限。
语法是:
has_function_privilege('app_readonly', 'app.order_summary(bigint)', 'EXECUTE')返回true或false。用它写权限矩阵非常合适:
SELECT r.rolname AS role_name, p.oid::regprocedure AS func_name, has_function_privilege(r.rolname, p.oid, 'EXECUTE') AS can_execute FROM pg_roles r CROSS JOIN pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'app' AND r.rolname IN ('app_anon', 'app_readonly', 'app_writer') AND p.proname IN ('order_summary', 'get_payroll_data') ORDER BY func_name, role_name;这个查询会生成一张“角色-函数-是否可执行”的矩阵表。你可以把预期结果写进断言脚本里,比如"app_anon对get_payroll_data的can_execute必须为false",一旦查询结果出现true就是回归失败。
同样的,schema层的USAGE权限可以用has_schema_privilege来断言:
SELECT has_schema_privilege('app_anon', 'app', 'USAGE');把这两类断言组合起来,就完成了三层权限链路里前两层的自动化覆盖。CI里直接跑SQL,不用人工反复点界面。
4.4 如何把测试覆盖到函数内部的SQL权限
很多测试做到has_function_privilege就停了,但我想提醒一句:EXECUTE权限通过,不代表函数内部SQL一定能跑通,也不代表安全边界一定没问题。
对SECURITY INVOKER函数,函数体里的表权限严格遵循调用者权限。也就是说,即使app_readonly有EXECUTE权限,但如果它没有函数内部查询表的SELECT权限,调用依然会报错。这种错误在权限测试里特别值得留意,因为它暴露了"函数接口与底层数据权限不一致"的问题。测试时一定要真实执行一下函数,不能只看ACL。
对SECURITY DEFINER函数,则要看另一个方向:函数属主对内部对象是否有足够权限;以及函数体内是否存在动态SQL拼接、未限定schema的引用、可被调用者干预的对象名。这类函数的测试要把代码Review和运行时验证结合起来,不能只靠黑盒。
注意:一个常见的安全误区是,把通用查询接口做成SECURITY DEFINER。如果函数内有拼接的SQL,调用者就可能通过参数注入改变语义,等于用函数属主的身份执行任意SQL。权限测试一旦发现这种函数,应该第一时间打回重做设计,而不是继续测EXECUTE权限。
关于PL/pgSQL还有一个值得注意的点:函数创建时,PostgreSQL不一定立即检查函数体里所有对象的权限,很多检查发生在第一次实际执行时。尤其是PL/pgSQL函数,定义时能创建成功,运行时才因为权限不足报错。所以"创建成功"绝不等于"权限无误",必须把执行这一步纳入测试。
5. 我踩过的几个坑:测试结果"看着对"其实全错
5.1 用超级用户做测试等于没测
有一段时间,我见过不少同事的权限测试流程是:用postgres账号连数据库,然后执行函数,发现一切正常,于是得出结论"权限没问题"。这个结论毫无意义。超级用户天然绕过所有权限检查,它在测试环境里的"成功"什么都证明不了。
正确姿势是创建一个最低权限角色,然后用它真实登录或者SET ROLE切换过去再执行。psql里可以直接用环境变量指定用户:
PGUSER=app_readonly PGPASSWORD=readonly_pwd psql -h localhost -d appdb -c "SELECT * FROM app.order_summary(42);"更麻烦的一点是,很多人测试时用postgres身份SET ROLE app_readonly,执行完忘了RESET ROLE,后续语句也全部在app_readonly身份下执行,整个测试会话就乱套了。每次切换都养成马上复位、或者用SET LOCAL ROLE的习惯,能省很多排查时间。
5.2 连接池复用导致的角色混乱
到了应用侧,坑就更隐蔽了。很多应用使用连接池,一个数据库连接会被多个请求复用。如果某个请求为了绕过权限临时执行了SET ROLE,却没有复位,连接放回池里之后,下一个请求就会顶着上一个请求的角色继续跑。
这在权限测试里造成的现象是:测试脚本跑出来的结果一会儿正常一会儿报错,完全取决于连接池把哪条连接分配给了哪段测试。
我的建议是:涉及角色切换的测试,一律使用独立连接、独立事务,并且用SET LOCAL ROLE限制在事务内。千万不要在长连接会话里到处SET ROLE然后指望它自己清理干净。
5.3 函数重载漏补授权,线上直接404
这个问题在第3章提过,但还是要再强调一次,因为我在实际项目里因为重载漏授权吃过亏。当时有两个同名函数,参数一个是int,一个是bigint。应用里调用的是bigint版本,测试脚本授权的是int版本。结果测试环境一切正常,到生产环境一调用,白屏报错。
权限测试的最后一公里一定是对照线上真实的函数签名。建议在测试脚本开头自动拉取所有函数签名,并和权限申请单逐一比对,而不是在文档里写"给order_summary授权"然后靠人脑猜是哪个重载。
5.4 函数内部动态SQL的权限绕过风险
最后说一个可能颠覆很多人直觉的点:即便函数本身是SECURITY INVOKER,如果函数体里执行的是EXECUTE动态SQL,权限检查依然基于调用者身份,但如果函数被设计成SECURITY DEFINER,动态SQL的权限就完全看属主了。
我曾经见过一个"为了查询方便"创建的SECURITY DEFINER函数,里面用format('SELECT ...')拼接了一个可变对象名。表面上"只有内部运维角色能调用",实际上任何获得该函数EXECUTE权限的用户,都可以通过特殊参数让函数执行属主权限下的任意查询,相当于被授权者瞬间变成了半个管理员。
权限测试的时候,对这类函数要格外敏感。我的一般处理办法是:把所有SECURITY DEFINER函数拉出来单独审查,只要函数体内有动态SQL、未限定schema的表名、或者接收外部输入的标识符,一律要求重写,或者改造为最小权限模式。
最后再分享一个小习惯。我现在每次上线前,都会跑一遍"函数权限快照"脚本,把库里所有函数的属主、ACL、SECURITY DEFINER状态、是否固定search_path导出成一个视图,跟前一次上线的快照做diff。只要出现了预期之外的权限变化,就能在发布前拦截掉,而不是等应用被用户调炸了再回查。这个习惯帮我挡掉了不少次权限事故,建议你也可以在自己的测试流程里加一道这样的检查。