对外接口自动裁掉敏感字段:聊聊列级权限与脱敏
数据合规(个保法、GDPR)时代,"谁能看哪些列"是硬需求。但脱敏代码往往散落在每个接口里:
if (role == ADMIN) ... else mask(...)。这一篇讲怎么用 AST 改写,在不改业务 SQL、不污染原句的前提下,统一裁掉敏感列。
先说个场景:给渠道方开的那个接口
去年我们给一家渠道方开过一个开放接口,做联合营销拉用户用,路径是 /openapi/v1/users。
开发图快,userMapper.selectByOrg(orgId) 查出来是个 DO 列表,连一层 VO 转换都没做,直接 return。"先把数据通了再说"——这种节奏你肯定见过。
第二天对方工程师在对接群里 @ 我:
"哥,你们这个接口怎么把身份证号原样返回回来了?我们这边合规不让落敏感信息,我现在只能先落到中转表,再用正则把 18 位抠掉。"
我第一反应是:"啊?哪个字段。"
然后就是标准补救流程——返回前加一段:
for (UserDO u : list) {
u.setIdCard(null);
u.setPhone(maskPhone(u.getPhone()));
}
上线了,评审过了,测试也过了。三个月后公司做个人信息合规抽查,翻出来三条路径,每一条当时都没想到:
- 两个月前排查内存泄漏时抓的那次 heap dump,DO 对象带着明文
id_card躺在 dump 文件里,谁能下载谁就有一份; - 日志平台的响应体采样——"记录响应"那个拦截器的注册顺序在 controller 之前,落进去的一直是脱敏之前的对象;
- 第二个渠道方接入时复用了同一个 service 方法,那段 for 循环忘了抄。
顺带提一句:对面那句"用正则把 18 位抠掉",听着耳熟。这跟用正则去抠 SQL 里的表名是同一类动作——拿字符串工具对付有结构的语言。这个系列第 1 篇的开头,就是这件事的另一个版本。
三条路,同一个根因:脱敏写在"数据已经到手之后"。
这段代码不是不努力,它是在错误的楼层干活。
一、先把"权限"这个词拆开
数据场景里,"权限"至少有两个维度,很多人把它们混着说,方案就必然乱:
| 维度 | 决定什么 | 靠什么实现 | 典型手段 |
|---|---|---|---|
| 行级权限 | 你能不能看这条数据 | WHERE 过滤 | 注入 tenant_id、dept_id |
| 列级权限 | 你能不能看这一列 | SELECT 裁剪 | 脱敏、字段级权限 |
行级权限靠 WHERE,列级权限靠 SELECT。本篇只讲后面这个。
典型场景列一下,你会发现它比想象中普遍:
- 对外 API / 开放接口返回用户列表,不能带
phone、id_card; - 薪酬 / 报销后台:主管能看部门汇总,但单条记录的
salary看不到; - 日志 / 审计导出的 SQL,要自动脱敏敏感字段;
- 给第三方 ISV 的回调,字段随版本迭代,脱敏规则还得跟着改。
二、"查出来再置空",四个致命问题
上面那三条泄漏路径不是运气差,是这个方案本身的结构性问题。四个致命处:
1. 敏感数据已经进过应用内存。
从数据库到应用的网络传输、JDBC 结果集、ORM 实体对象——敏感值已经完整地存在过。heap dump、DEBUG 日志、异常栈携带参数,任何一个出口都是泄露面。
2. 极易被日志和序列化捕获。
很多项目有"打印 SQL 结果""记录响应体"的拦截器。拦截器的注册顺序你不一定记得清——脱敏写在 controller 层,日志拦截器可能更早一步就把完整对象打出去了。这类事故最难复现,因为本地日志级别通常不是 DEBUG。
3. 改造成本随接口数线性增长。
N 个接口写 N 遍判断;新增一个敏感字段,要改 N 个地方。漏掉任何一个,就是一次合规事故。
4. 防不住"意外路径"。
内部管理接口复用了同一个 Mapper,忘了加判断——数据就这么出去了。这类漏改不会报错,只会静静泄漏。
而 SQL 层裁剪的逻辑正好反过来:数据库压根不返回这一列。没有值,就没有泄露的可能。 而且改造点从"N 个接口"收敛到"网关一处"。
一句话原则:
能"库压根不返回",就别"返回了再藏"。
三、正确姿势:在 SQL 层把列剪掉
jkit-sql 的 removeSelectItem 是 AST 级移除 SELECT 项,并且先 clone 再改,原句不动:
import com.alianga.jkit.sql.SQL;
import com.alianga.jkit.sql.ast.SqlStatement;
SqlStatement stmt = SQL.parse(
"SELECT id, name, phone, id_card FROM t_customer WHERE id = 1");
SqlStatement out = SQL.removeSelectItem(stmt, "phone");
out = SQL.removeSelectItem(out, "id_card");
// SELECT id, name FROM t_customer WHERE id = 1
SQL.toSqlString(stmt).contains("name"); // true —— 原语句未被污染
匹配规则(实现层就是这样):
- 忽略大小写,按简单列名匹配(
t.col只看最后一段),也能匹配显式别名; - 删到只剩一项时再删会抛异常(
cannot remove last select item)——这个保护很贴心,否则一条误配置能让整个接口静默返回空; - 返回新树,原语句不变,可配合解析缓存使用。
第三点值得多说一句:"宁可报错,不可静默返回空"。返回空列表是个比抛异常危险得多的失败模式——监控看不出异常,用户投诉了才知道。
四、三个必须知道的坑(第三个有点反直觉)
坑 1:removeSelectItem 一次只删第一个匹配项
这是实现决定的:找到第一个命中项就收手。所以 UNION、子查询里重复出现的敏感列,你只删了最左边那一个:
SqlStatement s = SQL.parse(
"SELECT phone FROM t_customer UNION SELECT phone FROM t_backup");
SqlStatement out = SQL.removeSelectItem(s, "phone");
// 只有第一个 SELECT 里的 phone 被删掉,第二个还在!
规避:用循环删到文本不再变化,或者直接上后面提到的 replaceSelectItem(它作用于所有出现,包括 UNION 和子查询)。
坑 2:别把 SELECT 列表删空
SQL.removeSelectItem(SQL.parse("SELECT id_card FROM t"), "id_card");
// IllegalArgumentException: cannot remove last select item: id_card
这是故意的。所以脱敏规则要按"角色 + 表"精细配置,别上来就一招 List.of("phone","id_card","email") 往所有 SQL 上套——SELECT phone FROM t 这种单列查询会被直接怼回来。
坑 3:SELECT * 裁不掉(但能救)
星号在 AST 里是一个"通配节点",不是具名的列节点,按列名匹配自然无从下手。
SQL.removeSelectItem(SQL.parse("SELECT * FROM t_customer"), "id_card");
// 什么都不会发生,SELECT * 原样留着
这就是为什么很多公司的 SQL 规范第一条就是"**禁止 SELECT * **"——除了性能,列级权限是更硬的理由:你的脱敏规则在 SELECT * 面前是完全失效的,而且失效得很安静。
但 2.0.2 起有个正解:先 expandStar 把星号展开成具名列,再裁。
Map<String, List<String>> cols = new LinkedHashMap<>();
cols.put("t_customer", Arrays.asList("id", "name", "phone", "id_card"));
SqlStatement expanded = SQL.expandStar(
SQL.parse("SELECT * FROM t_customer"), cols);
// SELECT id, name, phone, id_card FROM t_customer
SqlStatement cut = SQL.removeSelectItem(expanded, "id_card");
// SELECT id, name, phone FROM t_customer ← 星号也能脱敏了
cols 从哪来?从你的表元数据(JDBC DatabaseMetaData)来一次就够,也可以实现 SqlColumnResolver 接口接自己的元数据仓库:
public interface SqlColumnResolver {
// 未知表返回 null —— 该星号保持原样,不会误改
List<String> columnsOf(String tableSimpleName);
}
注意最后那句注释,这是设计上的克制:解析不到列的星号宁可不动,也不瞎展开。对不确定的东西保持沉默,比猜一个然后改错强。
另外:SqlRewrites.expandStar(cols) 也提供链式版本,规则链里直接 add 就行。
五、遮蔽也能在 SQL 层做:replaceSelectItem
前面说的是"这列完全不给"。但现实里更多是"给,但只给一部分":
SqlStatement out = SQL.replaceSelectItem(
SQL.parse("SELECT id, phone, name FROM t_customer"),
"phone", "CONCAT(LEFT(phone, 3), '****')");
// SELECT id, CONCAT(LEFT(phone, 3), '****') AS phone, name FROM t_customer
注意两个细节:
- 输出别名被自动设成原列名(
AS phone),对上层的契约没变——前端不用改代码就知道自己拿到的还是phone,只是值被遮蔽了; - 原始值没有进应用内存。这一点比"查出来再
toString().replace()"干净得多,因为遮蔽就在数据库里完成了。
命中范围也更大:别名的匹配、限定名(c.phone)的匹配、UNION 与子查询里的全部出现,都会被替换。而且限定名匹配时不会误伤非限定引用:
// 只替换 c.phone,裸写的 phone 不动
SQL.replaceSelectItem(SQL.parse("SELECT phone FROM t_customer"), "c.phone", "LEFT(phone, 3)");
// 结果:没有 LEFT —— 完全不匹配就不改
多个字段一起遮蔽,还有一个批量入口:
Map<String, String> masks = new LinkedHashMap<>();
masks.put("phone", "CONCAT(LEFT(phone, 3), '****')");
masks.put("id_card", "'****'");
SqlStatement masked = SQL.replaceSelectItems(
SQL.expandStar(SQL.parse("SELECT * FROM t_customer"), cols),
masks);
// SELECT id, name, CONCAT(LEFT(phone, 3), '****') AS phone, '****' AS id_card FROM t_customer
expandStar + replaceSelectItems 这一对组合,把"SELECT * 没法脱敏"这个老问题彻底关掉了。
六、脱敏不是二选一,是三个层次
实际落地时你会发现,"能不能看这一列"往往不是"给/不给"的二分法:

层次一 · 完全不可见 —— SQL 层裁掉。
这列压根不出现在结果里,数据库不返回。身份证号、银行卡号这类"这个角色根本不该碰"的字段归这里。removeSelectItem 负责这一层。
层次二 · 可见但遮蔽 —— SQL 层表达式。
手机号显示成 138****1234、姓名显示成 张*。要保留"能看出是谁"的信息量,所以不能整列裁掉。用 replaceSelectItem 在 SQL 层写遮蔽表达式,同样做到原始值不进应用内存。
层次三 · 可见但受控 —— 审计 + 限流。
某些敏感列(比如成本价)运营"可以看",但每次看都要留痕、频率要受限。jkit 能做的是识别这次查询碰了哪些表和列,剩下的交给你的审计系统:
SQL.tables(sql); // [t_order, t_customer]
SqlSchemaStat stat = SQL.stat(sql);
stat.getColumns(); // 含 WHERE / ORDER BY 里出现过的列
把这三层想清楚,脱敏方案才不会退化成"要么全给、要么不给"。
而 jkit 在这三层里的定位始终很明确:
它负责 SQL 文本层的确定改写,不负责业务策略。策略是你的,改写是它的。
七、合体:一个"列级权限网关"
把上面几件事串起来,规则和角色绑定,统一在网关层执行:

public final class ColumnPolicyGateway {
// 角色 → 需要裁掉的列(层次一:完全不可见)
private static final Map<String, List<String>> DENY_COLUMNS = Map.of(
"GUEST", List.of("phone", "id_card", "email", "cost_price"),
"STAFF", List.of("id_card", "cost_price"),
"ADMIN", List.of()
);
// 角色 → 需要遮蔽的列(层次二:可见但遮蔽)
private static final Map<String, Map<String, String>> MASK_COLUMNS = Map.of(
"GUEST", Map.of("phone", "CONCAT(LEFT(phone, 3), '****')")
);
public String apply(String sql, String role, SqlDialectSpec dialect) {
SqlStatement stmt = SQL.parse(sql, dialect);
SqlRewrites chain = SqlRewrites.create();
// 0) 先把 SELECT * 展开,否则后面两步全部失效
chain.add(SqlRewrites.expandStar(metadataColumnResolver()));
// 1) 列权限:按角色裁掉敏感列
for (String col : DENY_COLUMNS.getOrDefault(role, List.of())) {
chain.add(SqlRewrites.removeSelectItem(col));
}
// 2) 遮蔽:按角色把列换成脱敏表达式
chain.add(SqlRewrites.replaceSelectItems(maskExprs(role)));
// 3) 行权限:只能看本部门(复用上一篇的能力)
chain.add(SqlRewrites.andWhere(SQL.parseExpr("dept_id = " + currentDept())));
return SQL.toSqlString(SQL.rewrite(stmt, chain), dialect);
}
}
配合上一篇的 andWhere(行权限)和 replaceColumn(列改名映射),一条完整的行列级权限网关就成型了:
入口 SQL
→ 展开 SELECT *(否则列规则全部失效)
→ 按角色加 WHERE(行权限:能看哪些行)
→ 按角色裁 SELECT 列(层次一:完全不可见)
→ 按角色改遮蔽表达式(层次二:可见但遮蔽)
→ 按库映射改列名(老 SQL 兼容,见下)
→ 执行
顺手提一下 replaceColumn:数据库做了列改名(name → user_name),旧业务 SQL 不用改,在网关层映射一次就行。它跳过表名 / 表别名,只动真正的列引用节点,不会误伤字符串或表名——老系统对接新库结构的过渡期特别好用,等调用方迁完再摘掉映射。
这个网关的三个好处:
- 业务代码零侵入:Mapper 一行都不用改;
- 规则集中可审计:合规检查只看这一个文件;
- 新增敏感字段只改一处配置:不会漏、不会忘。
八、方案对比:为什么"不返回"比"藏起来"安全
| 方案 | 敏感数据进应用内存 | 是否进日志/缓存 | 改造成本 | 漏改风险 |
|---|---|---|---|---|
| 查出来 Java 置空 | 是(已查出) | 可能(取决于拦截器顺序) | 每个接口手写 | 高 |
| 视图 / 数据库列权限 | 否 | 否 | 每种角色一套视图 | 中(视图维护成本) |
| SQL 层裁列(jkit) | 否(库直接不返回) | 否 | 网关一处配置 | 低 |
从合规角度看结论很清楚:"库压根不返回"比"返回了再藏"安全得多。 涉及身份证、手机号、银行卡这类字段,SQL 层裁剪应该是默认选择,而不是进阶技巧。
边界也要说清楚:SQL 层裁剪不是万能的。视图方案的优势是"数据库层面强制",绕过应用也生效;而 SQL 层改写只保护走网关的那条路径——直连库的运维脚本、BI 工具、数据同步任务不在保护范围内。所以它不是替代品,是主路径上的第一道并且最有效的一道闸门。
还有一点容易被忽略:脱敏规则本身的变更也应该走评审。把某个字段从"完全不可见"改成"可见但遮蔽",本质是一次权限放宽,应当留痕。
九、小结
- 列级权限 / 脱敏的最佳落点是 SQL 层统一裁剪,而不是散落在每个接口里事后置空;
removeSelectItem按简单列名精确移除 SELECT 列:忽略大小写、自动 clone、删空会抛异常保护、一次只删第一个匹配项;SELECT *无法直接裁剪——星号是通配节点。但expandStar展开 +removeSelectItem/replaceSelectItems可以把这条路补上;replaceSelectItem在 SQL 层做遮蔽,输出别名自动保持原列名,前端契约不变;- 与
andWhere组合成完整的行列级权限网关:规则集中、可审计、业务零侵入; - 定位记牢:jkit 负责 SQL 文本层的确定改写,不负责业务策略——不执行 SQL、不引 JDBC 驱动、不替代 ORM。
你们项目的脱敏逻辑写在哪儿?controller、service,还是 SQL 层?有没有被 SELECT * 阴过?评论区聊聊。
在线文档:https://jkit.alianga.com/
源码:https://github.com/zhengmingliang/jkit/tree/develop/jkit-sql
Q.E.D.


