动态脱敏对我来说,最开始是为了把隐私规则放在 API 边界附近,而不是散落在每个查询、转换器和响应对象里。
这个问题一开始看起来很简单:当当前访问者不应该看到完整信息时,把手机号、邮箱、地址、公司名或者精确数字隐藏掉。真正麻烦的不是脱敏函数本身,而是如何在正确的边界应用正确的规则,同时不把正常业务代码写成一堆 if。
1. 在响应边界脱敏
比较干净的位置,通常是在业务方法已经产出结果之后、结果序列化给客户端之前。
这样服务逻辑仍然聚焦在真实业务行为上:
- 查询数据
- 计算结果
- 执行真正的权限校验
- 返回正常的领域结构
脱敏和授权不是一回事。授权决定一个操作是否允许。脱敏决定一个允许返回的响应里,有多少内容应该可见。
我喜欢把这两个概念分开。如果混在一起,很容易在只是想改展示规则时,不小心改掉业务行为。
2. 用声明式规则描述
比较有用的形状,是在 API 方法旁边放一个注解或小配置:
@MaskResponse(fields = { @MaskField(path = "owner.phone", strategy = MOBILE_PHONE), @MaskField(path = "owner.email", strategy = EMAIL), @MaskField(path = "location.address", strategy = ADDRESS), @MaskField(path = "items.price", strategy = FUZZY_NUMBER, check = LoginCheck.class) }) public Page<ResultDTO> page(Query query) { return service.page(query); }
|
这读起来像一份契约。方法内部仍然返回完整 DTO,但对外响应会经过一份清晰的敏感字段列表。
关键是 path。真实 API 响应很少是扁平的。它们会有列表、嵌套对象、分页包装,有时还有数组。一个只能处理顶层字段的脱敏工具,很快就会不够用。
3. 通用地遍历嵌套数据
脱敏引擎不应该知道每个 DTO 的形状。它只需要知道如何沿着路径走。
一个粗略版本是:
function maskResponse(result, rules, requestContext): for rule in rules: if rule.check exists and rule.check.shouldSkip(requestContext): continue
applyPath(result, split(rule.path, "."), rule.strategy)
return result
function applyPath(value, parts, strategy): if value is null: return
if value is a collection: for item in value: applyPath(item, parts, strategy) return
current = parts[0]
if parts has one item: original = readField(value, current) masked = strategy.apply(original) writeField(value, current, masked) return
nextValue = readField(value, current) applyPath(nextValue, rest(parts), strategy)
|
这样 API 规则就不关心字段是在对象顶层、data 下面、records 里面,还是某个嵌套关系下面。
4. 策略要小而可预测
脱敏策略应该朴素。这里不适合塞复杂业务逻辑。
好的策略通常很简单:
- 只展示第一个字符
- 隐藏手机号中间部分
- 隐藏邮箱两侧细节
- 替换成
Login to see
- 把街道地址替换成结算后可见提示
- 把数字模糊到不精确区间
- 清成 null 或空字符串
策略接收字段值,然后返回替换值:
strategy MOBILE_PHONE_HARD(value): if value is blank: return ""
return keep first char + repeat("*", length(value) - 2) + keep last char
strategy FUZZY_NUMBER_ROUND_UP(value): if value is null: return null
return roundUpToReadableBucket(value)
|
这些函数越可预测,就越容易 review API 是否泄漏了不该泄漏的东西。
5. 可见性检查让它变成动态
动态的意思不是脱敏格式随机变化。动态的意思是,同一个接口可以根据上下文返回不同可见度。
例如:
- 未登录用户只能看到城市,看不到完整地址
- 登录用户可以看到更多细节
- 认证商家可以看到公司名
- 内部用户跳过脱敏
- 公开页面模糊精确数量
这可以表示成一个小的检查对象:
interface MaskCheck: function shouldSkip(context): boolean
class LoginCheck: function shouldSkip(context): return context.currentUser is not null
class VerifiedMerchantCheck: function shouldSkip(context): return context.currentUser has verified merchant profile
|
命名很重要。我更喜欢 shouldSkip 或 canReveal,而不是模糊的 check,因为布尔方向写反是很容易出现的 bug。
6. 不要忘记翻译字段
当响应里同时有翻译值时,脱敏会更复杂。
如果可见字段来自一个语言映射,只脱敏渲染后的字段还不够。原始多语言内容可能仍然会在别处被序列化,或者被另一个序列化器继续使用。
脱敏层应该理解这一点:
field "address" may have translations: language["address"].cn language["address"].en
when masking address: mask address mask language["address"].cn mask language["address"].en
|
这样响应才一致。用户不应该看不到原始字段,却还能拿到未脱敏的英文或中文翻译。
7. 让规则容易被 review
声明式脱敏最大的好处是可审查。
新增一个接口时,我希望在方法附近就能看到敏感信息决策:
@MaskResponse(fields = { @MaskField(path = "data.contacts.phone", strategy = MOBILE_PHONE), @MaskField(path = "data.contacts.email", strategy = EMAIL) })
|
这比在转换器、helper 方法和 mapper XML 里到处搜索要容易审查得多。
我现在的规则
对于动态脱敏,我的规则是:
return the same shape, reduce visibility by context
|
API 对客户端应该保持形状稳定,但当访问者没有足够上下文时,敏感字段应该变得不那么精确。