资讯动态

PostgreSQL函数权限测试:规避PUBLIC默认权限与自动化实践

发布时间:2026/10/16 12:51:33 来源:尧图企业网站定制
1. 先弄明白函数权限为什么容易出问题做数据库开发这几年我有个很深的体会表权限、视图权限大家通常都会盯得很紧毕竟是直接承载数据的地方。但函数权限很多时候会被下意识忽略。这次聊的“PostgreSQL函数权限测试”说白了就两件事一是验证该有的执行权限有没有给到位二是不该有的权限有没有被挡在门外。函数权限在PostgreSQL权限模型里处于一个“既普通又特殊”的位置普通在它跟其他对象一样有属主、有ACL特殊在它的默认权限策略与真实使用场景之间存在一个特别容易被忽略的落差。这篇文章就是围绕这个落差展开的适合后端开发、DBA以及那种“一个人扛整个项目数据库”的全栈工程师参考。1.1 PostgreSQL权限模型里函数的位置要把函数权限测明白先得捋清楚PostgreSQL的权限模型。整个权限体系的起点是“属主owner”对象的owner天然拥有该对象的全部权限这是PostgreSQL一贯的默认逻辑。除owner之外其他角色能不能操作对象完全看ACL访问控制列表怎么配。表有INSERT、UPDATE、DELETE等权限序列有USAGE函数则主要是EXECUTE权限。建函数时PostgreSQL会给它一个初始ACL之后通过GRANT和REVOKE调整。函数权限存在系统目录pg_proc表里确切地说是proacl列记录的就是这个函数的访问控制列表。平时我们用psql执行\df或者在图形化客户端里看“权限”页签看到的其实就是proacl的可读化展示。对于测试工作来说搞清楚proacl怎么解析非常重要。比如acl列里面出现{app_adminX/app_admin,app_userX/app_admin}意思是app_admin和app_user都有EXECUTE权限grantor是app_admin。如果这个列展示为空很容易让人误解成“没有权限”其实恰恰相反空ACL表示回落到默认策略。因为默认策略不是无权限而是PUBLIC有EXECUTE。这个特点最容易踩坑所以我单独专门讲一下。理解了proacl之后函数权限测试就可以用SQL直接查询数据库内部的真实状态比一次次手动在客户端里试效率高得多。1.2 让所有人头疼的默认PUBLIC权限PostgreSQL对函数有一个历史悠久的默认行为任何函数在创建后EXECUTE权限默认授给PUBLIC。这意味着只要用户能连接到数据库就默认可以执行库里的函数直到你主动REVOKE。这个设计在单机小应用时代问题不大但放到多人协作、多角色并存的项目里几乎必然带来安全风险。举个例子。假设业务表orders上有个删除辅助函数cleanup_old_orders专门给定时任务用。开发环境里你只有一个超级用户什么都能跑谁也不会在意。等上了生产接入了只读报表用户、外部服务账号如果没第一时间把PUBLIC的EXECUTE收回来这些账号理论上都能调用这个删除函数。极端一点如果某个函数是SECURITY DEFINER且owner是超级用户那问题的严重性就不再是普通权限问题而是一条通往高权限的通路。所以函数权限测试的第一件事不是验证谁有权限而是先确认“默认PUBLIC权限是否已按要求收回”。我的习惯是新项目建完函数后立即执行REVOKE ALL ON FUNCTION schema_name.func_name FROM PUBLIC;再按实际需要GRANT给指定角色。这一步必须写进测试前置条件不然后面所有测试都可能被默认权限干扰测出完全失真的结论。2. 测试前的准备工作权限测试不像功能测试跑到一半发现问题还能临时改配置。它需要干净的环境、明确的预期不然你根本没法判断“报错”究竟是权限问题还是SQL写法问题。2.1 准备最小测试环境我在测函数权限时习惯搭一个独立的最小环境避免把业务数据卷进来。这里所谓最小不是一库一表就完事而是要模拟真实项目里的角色关系。典型配置实例本地PostgreSQL 14或者测试服务器上的独立实例端口专用。角色A属主角色模拟开发管理者拥有函数的创建权。角色B业务角色模拟应用连接账号应当有正常执行某类函数的能力。角色C只读角色模拟报表账号在权限正确的情况下这些账号应无法执行敏感函数。然后建一个独立schema比如perm_test再建两张表一张业务数据表一张记录操作日志的表。函数至少建两个一个普通函数一个SECURITY DEFINER函数。为什么一定要有SECURITY DEFINER因为它的权限语义和普通函数完全不同处理不好就是权限漏洞后面细说。搭环境时有个细节容易被忽略所有测试SQL在同一实例上跑连接串要写清楚。不要用同一个连接把所有角色试一遍要切角色。推荐用psql连接时指定用户比如psql -h 127.0.0.1 -p 5432 -U app_user -d testdb -c SELECT * FROM perm_test.handle_refresh();或者在一个psql会话里用SET ROLE切换。SET ROLE更省事但模拟的是会话级角色切换跟外部连接的实际登录账号还是有区别。如果角色的LOGIN属性不一样要验证外部登录行为还是建议用不同连接分别测。2.2 建立清晰的测试用例模板没有测试用例模板的权限测试很容易变成“我试试看”的随缘测试。我会在本地维护一套固定表格每新增一个函数就复制一行改函数名。模板大致长这样用例编号覆盖场景角色预期结果函数名是否依赖search_pathFC-001正向验证app_user执行成功get_order_count()否FC-002负向验证readonly_reportpermission deniedget_order_count()否FC-003SECURITY DEFINERreadonly_report执行成功写入日志record_audit(text)是具体到执行验证我的用例分成三类。第一类是正向验证角色确实有权限函数正常执行返回预期结果。第二类是负向验证角色不应该有权限执行时必须在第一个语句就报permission denied。第三类是边界验证批量循环调用多个函数时某些有权限的角色执行到内部对象权限不足时如何处理。每一条用例都要写清楚前置条件比如“已REVOKE FROM PUBLIC”“已GRANT给app_user”。前置条件不满足测试结论就是无效的。我自己吃过这个亏测试报告里写了“app_user可以执行删除函数”后来排查发现PUBLIC权限压根没回收等于白测。这里再提醒一句PostgreSQL函数权限的粒度精确到“函数签名”。两个同名但参数类型不同的函数是相互独立的对象权限互不影响。写测试用例时函数名后面必须带上参数类型比如handle_refresh(int)和handle_refresh()不要混淆否则很容易出现“我改了某个函数的权限测试里却看的是另一个同名函数”的情况。3. 函数权限测试的完整实操流程下面给出一套可以照着跑的流程。以下SQL都在PostgreSQL 14环境验证过高版本行为一致老版本如果有系统视图字段差异思路不变。3.1 创建角色与测试函数环境搭建以最小权限为出发点不要把角色都加上superuser属性。SQL大致是这样的-- 角色创建 CREATE ROLE perm_owner LOGIN PASSWORD owner_pwd; CREATE ROLE app_user LOGIN PASSWORD app_pwd; CREATE ROLE readonly_report LOGIN PASSWORD read_pwd; -- schema创建 CREATE SCHEMA IF NOT EXISTS perm_test AUTHORIZATION perm_owner;然后切到perm_owner去建表和函数。注意这里必须让perm_owner成为对象属主不是让超级用户创建后再ALTER OWNER。虽然PostgreSQL允许改属主但测试环境要模拟真实开发场景属主从一开始就是perm_owner。CREATE TABLE perm_test.orders ( id serial PRIMARY KEY, status text NOT NULL DEFAULT active, qty numeric DEFAULT 0 ); CREATE TABLE perm_test.audit_log ( id bigserial PRIMARY KEY, event_time timestamptz DEFAULT now(), event_text text ); -- 普通函数 CREATE OR REPLACE FUNCTION perm_test.get_order_count() RETURNS bigint LANGUAGE sql AS $$ SELECT count(*) FROM perm_test.orders; $$; -- SECURITY DEFINER函数 CREATE OR REPLACE FUNCTION perm_test.record_audit(p_text text) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path public, pg_temp AS $$ BEGIN INSERT INTO perm_test.audit_log(event_text) VALUES (p_text); END; $$;注意record_audit里我显式设置了search_path这是为了防路径劫持。实际项目中这个细节很关键后面排查部分再展开。创建完先别做任何GRANT和REVOKE直接看默认ACL。你会发现函数权限果然默认给了PUBLIC用这条SQL确认SELECT proname, proacl FROM pg_proc WHERE proname IN (get_order_count, record_audit);会看到proacl里存在PUBLICX的记录。这一步就是“亲眼确认默认行为”非常直观。3.2 权限的授予与回收验证接下来进入权限调整这是测试的重头戏。先回收PUBLIC权限REVOKE ALL ON FUNCTION perm_test.get_order_count() FROM PUBLIC; REVOKE ALL ON FUNCTION perm_test.record_audit(text) FROM PUBLIC;回收后再查proaclPUBLIC那条会消失。这时候用readonly_report连接执行函数应该立刻报错SELECT * FROM perm_test.get_order_count(); -- ERROR: permission denied for function get_order_count这个报错就是负向测试的预期结果。但很多人会有误解以为权限测试就是验证“别人不能执行”。不对这只是一半。另一半是验证正向权限该给的人要给对。GRANT EXECUTE ON FUNCTION perm_test.get_order_count() TO app_user; GRANT EXECUTE ON FUNCTION perm_test.record_audit(text) TO app_user;然后用app_user连接执行。注意app_user执行record_audit时虽然函数内部要INSERT到perm_test.audit_log表但函数是SECURITY DEFINER执行时使用函数ownerperm_owner的身份所以不需要app_user具备表的INSERT权限也能成功。这一点恰好是权限测试的重点场景。我还建议专门测一下GRANT OPTION确认它不会被意外带入生产环境。比如管理员只给app_user普通授权没有WITH GRANT OPTION那么app_user试图再授权给readonly_report时应该失败SET ROLE app_user; GRANT EXECUTE ON FUNCTION perm_test.get_order_count() TO readonly_report; -- 预期报错: must have grant option for FUNCTION这个用例很容易漏掉但生产环境很多权限蔓延问题就是从带GRANT OPTION的过度授权开始的。3.3 权限矩阵与批量验证逐个函数测试很慢尤其库里几十上百个函数时。我通常用一张权限矩阵做批量验证。思路先明确角色列表、函数列表、以及每个组合的预期权限然后用has_function_privilege函数批量查询实际授权情况最后对比预期与实际的差异。has_function_privilege(role_name, function_oid, privilege_type)是系统提供的权限查询函数返回boolean比手动解析proacl友好得多。批量验证SQL大致这样SELECT r.rolname AS role_name, p.proname AS func_name, has_function_privilege(r.oid, p.oid, EXECUTE) AS has_exec FROM pg_roles r CROSS JOIN pg_proc p WHERE p.proname IN (get_order_count, record_audit) AND r.rolname IN (app_user, readonly_report) ORDER BY p.proname, r.rolname;跑出来跟权限规划表一对比有没有漏授、有没有多授权限一目了然。这里有个经验proacl是持久化的不用担心重启丢失但角色成员关系会影响权限生效如果某个角色是通过继承父角色获得权限的has_function_privilege默认也会算进成员关系。这一点在不同客户端里表现不太一样要多留个心眼。4. 常见问题与排查实录这一节是我自己的踩坑记录每条都对应过真实环境里的故障。4.1 权限明明授了却还是报错非常典型的场景你确认给角色GRANT了测试时却发现调用函数仍然报权限不足。排查步骤很有讲究。第一步确认GRANT的签名和实际调用的签名一致。把函数签名用\df打出来看或者直接查pg_proc的proname和pronargs。我遇到过函数名显示handle_refresh没注意参数类型是text还是integerGRANT给了integer版本应用层调用的是text版本自然报错。第二步确认调用时实际生效的schema。如果search_path里有两个同名schemaPostgreSQL按search_path顺序找到第一个匹配的名字。可能GRANT是在A schema里但调用时解析到了B schema的同名函数。第三步考虑有没有绕过权限检查的机制。比如函数是否被嵌套在另一个SECURITY DEFINER函数里如果是外层函数的权限不等于内层对象直接对调用者开放因为SECURITY DEFINER会切换当前用户身份。测试时要模拟真实调用链别单独测内层函数就以为完事。另外有个小技巧遇到权限类报错时新建一个临时LOGIN角色配好所有权限用这个角色跑一遍。如果故障复现说明权限配置确实有问题如果不复现说明问题出在角色成员关系或search_path上。4.2 SECURITY DEFINER函数的权限陷阱SECURITY DEFINER是PostgreSQL里权限语义最容易出错的函数类型。规则很简单以函数owner的身份执行函数体而不是以调用者身份。也就是说如果owner拥有更高权限调用者实际上临时获得了这种权限提升。这个特性用在审核日志、跨schema操作等场景非常合适可以避免给每个普通账号授底层表的写权限。但权限测试必须针对它做额外用例。我最常提的风险owner是超级用户函数体里写了危险操作又没做好search_path控制。这等于把高权限入口开放给了所有能执行函数的人。测SECURITY DEFINER函数时除了验证外部执行权限至少还要验证三件事函数定义时是否显式设置了search_path。没有的话建议补上SET search_path ...。函数体引用的表是否带schema前缀。只写裸表名很可能被search_path劫持。函数内部是否有绕开权限的语句比如SET ROLE、dblink调用等这些都会让权限模型复杂化。举一个真实测试案例。某项目里record_audit函数原本不带SET search_path测试时用readonly_report调用成功执行并写入了日志。看起来权限配置没问题。但当我调整search_path让它包含一个实验schema后同一条测试开始操作到别的表。这不是权限配置错了而是函数设计缺陷。函数里全部显式限定之后测试才真正稳定。4.3 search_path导致的权限测试失真search_path这个设置在权限测试里经常扮演捣乱者角色。它决定无schema前缀的表名、函数名在解析时的查找顺序。如果测试环境search_path与生产环境不一致那么测试环境下测出的权限结果可能完全不代表生产行为。一个常见的失真场景测试环境search_path默认是$user, public正好public schema里有一个跟你业务schema同名的表或函数测试时解析到了不该解析的对象。结果权限测试出现“假通过”或“假失败”。规避方法函数定义里明确SET search_path把它固定在只包含可信schema的范围内这是函数本身的防御。测试侧也一样每个测试会话尽量固定search_path不要依赖psql默认值比如启动会话时先执行SET search_path perm_test, public;这样测试的就是一个相对确定的环境排查效率会高很多。另外测试脚本里不要用外部配置动态替换search_path避免把生产配置误带到测试脚本中。5. 进阶把函数权限测试挂进自动化手工测试在函数数量不多时够用一旦对象规模上来几十上百个函数、十几个角色手工测试效率完全跟不上。我后来把这套流程脚本化每次发布前自动跑一遍效果很好。5.1 自动化测试脚本思路自动化测试的核心是“断言化”。不是简单执行一遍函数而是对每个权限点做“前置条件 预期结果”的断言。我一般用PL/pgSQL写测试函数先做GRANT/REVOKE或数据准备然后查询has_function_privilege结果断言它等于期望值跑完所有用例输出通过或失败的统计。脚本大致结构DO $$ DECLARE v_result boolean; BEGIN -- 固定search_path PERFORM set_config(search_path, perm_test, public, false); -- 用例app_user可执行get_order_count SELECT has_function_privilege(app_user, perm_test.get_order_count(), EXECUTE) INTO v_result; IF NOT v_result THEN RAISE EXCEPTION FAIL: app_user should have execute on get_order_count; ELSE RAISE NOTICE PASS: app_user executes get_order_count; END IF; -- 用例readonly_report不可执行record_audit SELECT has_function_privilege(readonly_report, perm_test.record_audit(text), EXECUTE) INTO v_result; IF v_result THEN RAISE EXCEPTION FAIL: readonly_report should NOT have execute on record_audit; ELSE RAISE NOTICE PASS: readonly_report blocked from record_audit; END IF; END; $$;这种DO块直接跑在目标库上不需要额外装测试框架。如果项目用pytest或专业CI框架也可以换成bash脚本循环psql调用差别不大。需要特别强调动态测试除了静态权限检查还要准备一小批动态用例真正用角色去执行函数。因为has_function_privilege只能回答“权限是否存在”但函数体内部是否因为依赖表、序列等对象的权限问题而报错静态检查测不出来。所以动态执行不可省略。5.2 与CI流水线集成如果项目已经跑CI完全可以把这套测试塞进流水线。做法是准备独立数据库实例比如用容器直接起一个PostgreSQL在流水线里执行初始化数据库创建测试库、schema。按测试环境脚本建角色、建函数、建表。执行REVOKE FROM PUBLIC、按需GRANT。运行断言脚本。收集输出失败则构建失败。这套流程跑起来后函数权限回归就成了固定质量门禁。每次提交只要改动函数定义或GRANT语句都会自动触发权限测试权限问题根本走不到review环节。再提一个实践经验CI里最好连pg_proc的proacl快照一起保存。比如每次测试通过后导出所有函数权限清单保存为SQL或CSV放到制品库。以后排查“某个权限是不是某次变更改动的”直接对比快照就能定位比翻聊天记录高效得多。踩过几次坑之后我现在对函数权限测试的态度很简单它不复杂但必须有流程、有预期、有自动化。把PUBLIC的默认权限管住把所有GRANT的签名写清楚把SECURITY DEFINER函数当重点监管把search_path固定住权限问题基本就断根了。最后再说一个细节所有测试脚本里密码和连接信息不要写死用环境变量传。不然换环境就要改脚本改漏了很容易在测试里埋下一个更隐蔽的坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑