对外接口自动裁掉敏感字段:聊聊列级权限与脱敏

数据合规(个保法、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()));
}

上线了,评审过了,测试也过了。三个月后公司做个人信息合规抽查,翻出来三条路径,每一条当时都没想到

  1. 两个月前排查内存泄漏时抓的那次 heap dump,DO 对象带着明文 id_card 躺在 dump 文件里,谁能下载谁就有一份;
  2. 日志平台的响应体采样——"记录响应"那个拦截器的注册顺序在 controller 之前,落进去的一直是脱敏之前的对象;
  3. 第二个渠道方接入时复用了同一个 service 方法,那段 for 循环忘了抄。

顺带提一句:对面那句"用正则把 18 位抠掉",听着耳熟。这跟用正则去抠 SQL 里的表名是同一类动作——拿字符串工具对付有结构的语言。这个系列第 1 篇的开头,就是这件事的另一个版本。

三条路,同一个根因:脱敏写在"数据已经到手之后"。

这段代码不是不努力,它是在错误的楼层干活。


一、先把"权限"这个词拆开

数据场景里,"权限"至少有两个维度,很多人把它们混着说,方案就必然乱:

维度决定什么靠什么实现典型手段
行级权限你能不能看这条数据WHERE 过滤注入 tenant_iddept_id
列级权限你能不能看这一列SELECT 裁剪脱敏、字段级权限

行级权限靠 WHERE,列级权限靠 SELECT本篇只讲后面这个。

典型场景列一下,你会发现它比想象中普遍:

  • 对外 API / 开放接口返回用户列表,不能带 phoneid_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 的 removeSelectItemAST 级移除 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

注意两个细节:

  1. 输出别名被自动设成原列名AS phone),对上层的契约没变——前端不用改代码就知道自己拿到的还是 phone,只是值被遮蔽了;
  2. 原始值没有进应用内存。这一点比"查出来再 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:数据库做了列改名(nameuser_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.


寻门而入,破门而出