只读SQL代理实践:兼顾Yearning审批与DataGrip查询体验
先说结论:这个项目的核心不是“绕过安全”,而是把Yearning的权限管控能力和DataGrip的查询体验拼到一起。Yearning我一直在用来走SQL工单、审批和审计,但每次拉一段几十行的临时分析SQL到Web编辑器里,补全弱、报错提示迟钝、结果集也不能二次加工,真的很熬人。所以我做了个只读SQL代理,让DataGrip可以直连代理端口,代理在背后完成只读校验、真实连接、日志审计,体验跟直连数据库几乎一样,但安全底线一点没丢。
如果你也遇到过“审批流很规范但我就是不想在网页里写SQL”的纠结,或者团队里有人天天抱怨Web查询平台难用,这篇文章应该能给你一个可以直接落地的思路。我尽量把思路、原理、代码、踩坑都写清楚,不是概念包装,是实打实跑起来的东西。
1. 为什么要搞这个代理:Yearning查询体验的痛点与安全底线
1.1 Yearning解决的问题与其查询体验短板
Yearning是个开源的SQL审核平台,我内部管它叫“带门禁的SQL入口”。它能做用户权限管理、SQL语法校验、工单审批、操作审计。比如你想查线上某个表,得提交工单,审批通过后在Web端执行,整个过程留痕,这对合规来说非常有必要。
但问题也恰恰出在这个Web端。日常排查问题的时候,我经常要写那种带好几个临时表、窗口函数、各种join的长SQL。在Yearning的编辑器里,没有像样的自动补全,语法高亮勉强能用,函数提示基本没有,报错信息也不是很友好。更难受的是结果集,查完想再筛选、排序、导出,得重新写SQL,不能像DataGrip那样直接在结果集上操作。
用过DataGrip的人都知道,那个体验差距不是一点半点。DataGrip有智能补全、快速格式化、执行计划可视化、结果集筛选、历史记录,还能直接跳转到表结构。时间长了,团队里就总有人说“能不能让我用DataGrip连一下,我绝对只跑SELECT”。但直接放开账号又不行,审批和审计就断了。
1.2 直接放开只读账号的顾虑
有人可能觉得,给个只读账号不就完了,SELECT随便跑。我一开始也这么想,但仔细盘一下,问题并没有那么简单。
只读账号确实挡住了INSERT、UPDATE、DELETE这类写操作,但“只读”从来不是万能的。一个只读连接照样可以把数据库拖垮:比如跑一个没有WHERE条件的全表扫描,或者三个大表做笛卡尔积,数据库CPU和IO瞬间飙高。还有SELECT INTO OUTFILE这种语句,严格来说是查询语法,但它会把数据写到数据库服务器本地文件里,这是典型的“读语句干写的事”。
再一个问题是审计。只读账号是通用的,谁连的、跑的什么SQL,没有任何记录。出了问题后不知道是谁执行的,也没法回溯。如果团队规模大,这个风险是实打实的。
所以光靠数据库层面的只读权限是不够的,需要在更靠近人的地方加一道控制,既能识别SQL是不是真的“只读”,又能记录谁在什么时间跑了什么语句。
1.3 方案选型:为什么选了本地只读代理
我列过几个备选方案,对比下来才决定自己写代理。
直连只读账号最简单,但没法做细粒度控制和审计,排除。SSH隧道只解决了网络通道问题,SQL过滤、审计依旧没有,排除。在Yearning做二次开发倒是能改查询模块,但Yearning本身的定位是审批平台,我把IDE体验硬塞进去,等于重构人家核心功能,成本太高。
最后剩下的就是自建只读代理。它本质是一层夹在DataGrip和真实数据库之间的程序,对DataGrip伪装成MySQL服务器,对真实数据库当一个客户端。DataGrip发的SQL先落到代理手里,代理做只读校验,通过就转发,不通过直接拒绝,整个过程全部记录日志。
选这个方案还有个很重要的原因:它不侵入现有系统。Yearning的审批流程照旧,数据库账号策略不用改,团队成员也不用换工具。只是把“查数据”这个动作的入口从Web端换成了一个支持MySQL协议的本地代理。对使用者来说,体验是从零开始;对安全来说,边界一点没变,甚至更强了。
2. 只读代理的核心设计:协议代理、SQL检测与审计三位一体
2.1 代理工作链路
整个链路大概是这样的:
DataGrip -> 127.0.0.1:33060(代理监听端口) -> 只读校验 -> 后端MySQL/兼容数据库 -> 返回结果集
DataGrip里配置一个数据源,地址指向本机,端口是代理的端口。DataGrip发起连接时,代理跟它完成MySQL握手,然后DataGrip发查询,代理解析并校验,校验通过后代理再跟真实数据库交互,把结果集原样返回给DataGrip。
对DataGrip来说,它感知不到代理的存在,以为自己在直连一台MySQL。这种感觉很奇妙,我在写这个项目的时候,第一次把DataGrip连接到代理并且查询出数据的那一刻,真的有一种“这玩意能成”的兴奋感。
2.2 为什么必须自己实现协议层
很多人对“代理”的第一个联想是HTTP代理,但这里完全不是一回事。HTTP代理转发的是HTTP报文,目标地址写在报文头里,代理只需要拆包、转发、回包就行。但数据库客户端和服务端的通信走的是MySQL协议,一种二进制协议,里面有握手包、认证包、命令包、结果集包,结构比较复杂。
所以这个代理不能简单“透传”流量。它必须真正理解MySQL协议:能跟DataGrip完成握手和认证,能读取DataGrip发过来的COM_QUERY命令,能解析出SQL字符串,也能把真实数据库返回的结果集编码成DataGrip认识的格式。
打个比方,如果透传代理是“快递中转站”,那协议代理就是“同声传译”。两边其实在说同一种语言,但中间这个人必须完全听懂两边的每一句话,才能在中间做检查、做判断。
好在社区有现成的MySQL协议库可以用。我用的是Go语言生态里的go-mysql-server,它的server包实现了一个可定制的MySQL Server,你只需要实现Handler接口,就能接管查询、连接等核心操作。这样省去了自己啃协议细节的大量时间,可以把精力放在最关键的只读校验上。
2.3 安全设计总览
这个代理在安全设计上分了三层,缺一不可。
第一层是连接认证。代理自己有独立的账号密码,不是数据库账号,是代理层的账号。DataGrip连代理时,先通过代理的认证,这决定了谁能连上这个代理端口。
第二层是SQL只读校验。这是最核心的一层,也是我花时间最多的地方。所有SQL在透传到后端之前,都必须经过一次“意图检查”,判断这条SQL到底是不是只读操作。
第三层是数据库账号最小权限。代理连接真实数据库时,用的是专用账号,这个账号在MySQL里只有SELECT等只读权限。也就是说,即使代理的只读校验存在漏洞,后端数据库的权限体系还会兜底,形成双重保险。
审计日志贯穿始终。谁在什么时间连上了代理、执行了什么SQL、结果影响多少行、耗时多久,全部写入日志文件。这样一来,既保留了Yearning那种审批留痕的合规需要,又额外记录了更细粒度的操作日志。
3. SQL只读检测的实现:从关键词黑名单到语法树校验
3.1 为什么不能只做字符串匹配
最朴素的想法是:检查SQL里有没有INSERT、UPDATE、DELETE,有就拒绝。这个思路对付普通情况够用,但真要拿到生产环境,漏洞百出。
比如大小写就让人头疼。MySQL默认大小写不敏感,SeLeCt照样能执行。再比如注释,SQL支持很多种注释写法,SELECT 1; # DELETE FROM t这种如果只查关键字,会崩坏检测逻辑。还有更隐蔽的,MySQL支持/*!50000 UPDATE ... */这种可执行内联注释,注释里的内容实际上会被当成代码执行,字符串匹配根本拦不住。
最离谱的是多语句。DataGrip连接参数里可以开启allowMultiQueries,一条命令里用分号分隔多条SQL。如果只检查第一个语句,那SELECT 1; DELETE FROM users就漏过去了。
所以字符串匹配只能作为辅助手段,不能作为核心防线。真正要做的是“意图识别”,也就是把SQL解析成语法树,看根节点到底是个什么操作。
3.2 用解析器做真正的意图识别
SQL解析分两步:词法分析和语法分析。词法分析把SQL拆成一个个token,语法分析把这些token组装成一棵抽象语法树(AST)。有了AST,我们就能准确判断语句类型。
比如一个标准的查询,AST的根节点是SelectStmt;一条删除语句,根节点是DeleteStmt。判断只读就很简单:只允许特定的语句类型,其他一律拒绝。
我用的解析器是vitess的sqlparser,这是从Vitess里抽取出来的Go版SQL解析器,对MySQL语法支持得很好。用它解析SQL之后,先看根节点类型,再递归检查子节点,可以处理很多边缘情况。
下面是一个核心校验的代码思路:
这个函数是核心入口。列出白名单类型,只有明确只读的才放行。解析失败的SQL直接拒绝,宁可错杀不可放过。checkSelectOnly里还要检查子节点,比如有没有INTO OUTFILE、有没有LOCK IN SHARE MODE之类,这些属于“查询语法但干了写操作”的情况。
3.3 防绕过细节
解析器解决了大部分绕过问题,但有几个细节我还是专门处理了。
第一个是注释剥离。虽然解析器本身会忽略注释,但内联可执行注释/*!50000 UPDATE */比较特殊,Vitess解析器会把它当作普通注释处理,可实际上MySQL会执行它。所以我在解析前会先做一次内联注释处理,要么直接剥离带版本号的可执行注释,要么检测到就直接拒绝。
第二个是多语句。在代理入口处,如果检测到SQL里有分号分隔的多条语句,直接拒绝。为了这个,我专门写了多语句检测逻辑,不依赖DataGrip的配置,代理层强制检查。
第三个是预处理语句。MySQL支持PREPARE + EXECUTE,先准备一条带占位符的语句再执行。理论上PREPARE SELECT是安全的,但EXECUTE语句本身在AST里不是Select类型,如果拦截策略不当,会让正常的预编译查询也用不了。我目前的策略简单粗暴:检测到PREPARE或EXECUTE,除了EXECUTE IMMEDIATE里能明确解析出子查询并且子查询是只读的情况,其余一律放行并继续解析动态内容,如果动态内容解析不出来就直接拒绝。
还有一个小坑是WITH子句。MySQL 8.0支持公共表表达式,WITH cte AS (SELECT ...) SELECT * FROM cte这种写法在Vitess解析器里也是Select类型,没问题。但要注意WITH ... UPDATE ...这种写法的变异,这属于写操作,需要额外判断。
3.4 拦截清单与白名单机制
我在代码里维护了一张拦截清单,凡是不在白名单内的语句类型直接拒绝。这张表的粒度可以按团队情况调整,下面是我目前的版本:
| 语句类型 | 处理方式 | 原因 |
|---|---|---|
| SELECT | 放行 | 查询核心场景 |
| SHOW | 放行 | DataGrip元数据探测依赖 |
| DESC / DESCRIBE | 放行 | 查看表结构 |
| EXPLAIN | 放行 | 执行计划分析 |
| USE | 放行 | 切换数据库 |
| WITH (只读CTE) | 放行 | 复杂查询场景 |
| INSERT | 拒绝 | 写操作 |
| UPDATE | 拒绝 | 写操作 |
| DELETE | 拒绝 | 写操作 |
| REPLACE | 拒绝 | 写操作 |
| CREATE | 拒绝 | DDL操作 |
| ALTER | 拒绝 | DDL操作 |
| DROP | 拒绝 | DDL操作 |
| TRUNCATE | 拒绝 | 高危操作 |
| GRANT / REVOKE | 拒绝 | 权限变更 |
| CALL | 默认拒绝 | 存储过程可能有副作用 |
| PREPARE / EXECUTE | 谨慎放行 | 动态解析子节点 |
| SELECT ... INTO OUTFILE | 拒绝 | 写服务器文件 |
这个清单不是死的,像CALL语句。如果某个存储过程确实只做查询,可以把存储过程名加进白名单,然后代理额外记录一条审计日志。不过默认配置我还是建议拒绝,毕竟存储过程这种黑盒操作风险不可控。
4. 实操配置:从0搭建DataGrip可用的只读代理
4.1 环境准备与选型
我这边的环境是Go 1.21+,数据库是MySQL 8.0。代理用Go写,是因为部署方便,编译出来一个二进制文件直接扔服务器上跑,不依赖运行时环境。go-mysql-server的server包负责协议层,vitess的sqlparser负责SQL解析,这两个库是我的核心依赖。
如果你更熟悉Python,也可以用Python + pymysql + sqlparse做一套简化版,但协议层的处理要繁琐一些。Go方案在我看来是最省心的。
代理的拓扑结构我推荐这样:开发者的笔记本电脑上跑一个代理进程,DataGrip连本地代理,代理再连内网数据库。这样数据库端只需要允许代理所在机器的IP访问,不需要给每个开发者单独开网络权限。
4.2 核心实现步骤
第一步,定义代理配置,主要是监听端口和后端数据库的连接信息:
第二步,实现Handler接口。go-mysql-server的server包要求实现一个Handler,核心方法是ComQuery,也就是处理查询命令:
第三步,初始化并启动代理:
第五步,把代理做成系统服务。我是用systemd管的,写个service文件,配置好ExecStart,开机自启,崩溃自动拉起。这个细节别忽略,没有托管的话进程挂了没人知道,影响就大了。
4.3 DataGrip侧配置
DataGrip的配置很快,新建数据源选MySQL,填这几个地方:
- Host:127.0.0.1
- Port:33060
- User:analyst(代理认证账号)
- Password:代理认证密码
- Database:app_db(后端数据库名)
点Test Connection之前,DataGrip会发一堆探测SQL,比如查版本号、查当前用户、查information_schema里的表。这些SQL在我的白名单里都是SELECT/SHOW类型,能正常放行。测试连接应该一次通过。
注意,DataGrip自身也有个read-only模式,在数据源的高级选项里可以勾选。但这个模式只是客户端层面的,所有SQL还是会发到服务器。我把这个选项视为“客户端自觉”,真正的强制,还是靠代理层的校验。
4.4 验证连接与只读拦截效果
配置完成后,我建议做一组冒烟测试,确认整个链路都是通的。
先跑一个正常查询:SELECT * FROM users LIMIT 10;,DataGrip应该正常返回数据。然后跑一个写操作:DELETE FROM users WHERE id=1;,DataGrip会弹出一个错误,提示query blocked。这时候再去后端数据库看,那条DELETE确实没有执行,审计日志里也多了一条拦截记录。
还有一个测试点是元数据探测。DataGrip连接成功后会主动查表结构、外键、索引这些信息,走的都是information_schema和sys库的查询。如果代理不小心把这些查询拦了,DataGrip会显示一堆异常,表结构都看不到。我一开始就踩过这个坑,后面在只读校验里专门放行了SELECT * FROM information_schema.*这类系统查询。
5. 常见问题与排查实录:代理落地时踩过的坑
5.1 DataGrip一连接代理就断开
我第一次跑起来的时候,DataGrip点测试连接,五秒后直接报“Connection is not available”。看了代理日志,发现握手都完成了,但DataGrip在握手之后发了一个COM_INIT_DB命令,也就是自动选择默认数据库,而我当时没实现这个命令的完整处理。
排查方式是抓包。我在代理和后端之间跑了tcpdump,对比正常MySQL连接和代理连接的报文差异,发现DataGrip在连接初期会主动发几个命令探测服务器能力。go-mysql-server的server包对COM_INIT_DB默认有处理,但需要我正确初始化数据库选择逻辑。我重新看了一下Handler接口,发现我没有实现InitDB,补上之后就没问题了。
5.2 DataGrip测试连接报“Access denied”
这个问题通常是代理认证和后端认证混在一起导致的。DataGrip连代理,账号密码是代理层校验的;代理连后端,用的是另一个账号。如果你在DataGrip里不小心填了真实数据库账号,代理认证会失败,报Access denied。
排查思路:先确认DataGrip里填的是代理认证账号,再去后端数据库确认代理专用账号存在且权限正确。还要注意MySQL8默认的caching_sha2_password认证插件,有些版本的老协议库不兼容,报错信息会非常奇怪。我的解法是把代理专用账号改成mysql_native_password,省事。
5.3 只读检测误伤:SELECT里带了DELETE字样
有一回用户跑SELECT count(*) AS \update_count` FROM orders;`,居然被拦截了。原因是我的早期版本用了关键词匹配做第一道快速过滤,看到“update”就拦了,根本没有走到解析器。
这个案例很典型,让我彻底下决心用解析器做主要判断,关键词匹配只能放最后兜底。现在我的SQLValidator统一流程是:先剥离注释和字符串常量,再解析AST,最后按白名单判断AST类型。像反引号包起来的别名,解析器会当作标识符而不是SQL关键字,误报问题就消失了。
5.4 大结果集/长查询导致代理内存暴涨
有次同事跑了一个SELECT *查全表,几百个字段,上万行数据,代理进程内存直接飙升到1G多,差点把开发机干崩。原因是我的实现方式比较朴素:代理先把后端返回的所有结果collect到内存里,再一次性编码发给DataGrip。数据量一大就出问题。
解决方案两个方向。一个是给代理加结果行数限制,比如默认限制返回5万行,超过就报错提示,这个策略适合日常查询。另一个是改成流式处理,分批从后端读取数据并转发给DataGrip,内存占用恒定。我最后两个都做了,流式是主路径,行数限制是兜底。
5.5 CALL语句与存储过程:默认拒绝的代价
有用户提了一个需求,说某个存储过程明明只做SELECT,能不能放行。这个问题我权衡了很久,最终方案是允许通过配置把特定存储过程名加入白名单,但代理会额外生成一条风险审计记录。毕竟存储过程内部可能有动态SQL,解析器看不到里面的内容,自写存储过程的人知道它是安全的,但审计的人没法确认。
这个“配置化放行 + 额外审计”的思路也可以扩展到其他黑名单语句上,但建议不要默认放开,保持保守姿态。
5.6 审计日志风险:不要把密码打进去
审计日志本身有安全问题。我早期调试时,直接把完整连接字符串打印到日志里,里面有后端数据库密码。虽然只在本地调试,但这是个很危险的习惯。后来我严格区分:审计日志只记DataGrip用户、目标库、SQL语句、执行时间、影响行数、响应耗时,绝不记录密码、token等敏感信息。日志文件也要做权限控制,Linux上设置600权限,只允许代理运行用户读取。
还有SQL本身也可能包含敏感数据,比如SELECT * FROM users WHERE phone='138xxxx',这种日志如果被不该看的人拿到,也是泄露。建议在审计层做一个可配置脱敏规则,把字符串常量里的手机号、身份证正则替换成星号。
5.7 排查技巧:抓包分析MySQL协议
最后分享一个排查利器:Wireshark。当你遇到DataGrip和代理之间各种诡异问题,看代码找不到原因时,抓包往往能直接定位。在代理监听的端口上抓loopback流量,然后DataGrip点一下测试连接,就能看到完整的MySQL协议交互过程。
看包的时候关注几个关键信息:握手包的协议版本是否被人为改低、认证包的认证插件类型、COM_QUERY命令的SQL内容、结果集的列定义和行数据编码。我排过几个疑难Bug,最后都是靠抓包才发现的,比如字符集编码不一致导致中文乱码、连接空闲超时被DataGrip判定为断线等。
6. 做个好用的工具,而不是一堆代码
这个项目做完以后,我的体会是,只读代理这种工具,本质上不是“限制”,而是在“体验”和“安全”之间做了一次重新谈判。Yearning负责审批和流程,DataGrip负责体验,代理负责边界执行。谁都没有被削弱,反而各自做自己最擅长的事。
实际运行中,有两点让我觉得这个方向特别值。第一,团队里再也没有人绕过审批流程偷偷去连数据库了。因为代理本身就能让DataGrip这个更好的工具被合规地使用,没必要再去走灰色路径。第二,审计能力反而变强了。以前在Yearning只能知道谁提交了工单,现在能精确到每个查询语句、每次拦截、每条慢SQL,这对定位问题和追溯操作都很有帮助。
如果继续扩展,我会在几个方向上打磨这个代理:一是增加动态数据脱敏,不同角色查到的敏感字段自动打码;二是接入慢SQL告警,代理发现执行时间超过阈值就通知到群里;三是把审计日志输出到集中式日志系统,方便统一查询。后续还可以做一个小面板,让用户查看自己的查询历史。
最后再分享一个小技巧:代理如果要长期跑,一定要开机自启和健康检查。我给代理加了一个简单的HTTP健康检查接口,监听的端口返回200就代表活着。写个脚本定期探测,挂了就重启并告警。这类工具平时看着不起眼,等真挂了,你才知道它替你挡了多少事。