/zh/posts/dynamic-data-masking-notes

动态脱敏设计笔记

EN

动态脱敏对我来说,最开始是为了把隐私规则放在 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

命名很重要。我更喜欢 shouldSkipcanReveal,而不是模糊的 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 对客户端应该保持形状稳定,但当访问者没有足够上下文时,敏感字段应该变得不那么精确。