字段脱敏
适用范围与执行顺序
Gateway 在 Backend 返回节点之后、typed 物化之前固定执行 SchemaMasker。该步骤不能由通用请求 Filter 替换或绕过:
Snapshot 与 EventStream 的 typed/dynamic single、list、paged、cursor 以及经 Snapshot Gateway 的 state-only/aggregate-state load 使用同一路径。Mask 只处理当前响应,不修改存储文档、领域对象或通用 Jackson 序列化;count 和聚合行不经过结果 Mask。
内建注解
Kotlin 属性通常使用字段 use-site:
import me.ahoo.wow.api.query.mask.KeepMask
import me.ahoo.wow.api.query.mask.Mask
data class AccountState(
@field:Mask
val password: String,
@field:KeepMask(prefix = 3, suffix = 4)
val phone: String?,
)Mask 注解只能用于 JVM String/String? 属性;enum、UUID 等类型即使序列化后的 JSON 形状是 String,也会在 Schema 构建时失败关闭,避免 typed 结果无法重新物化。
@Mask按 Unicode code point 数量把每个 code point 替换为一个*,例如A中😀变为***。@KeepMask(prefix, suffix)按 code point 保留前后部分并遮蔽中间;值太短、无法同时保留两端时全量遮蔽,例如13800138000变为138****8000,1234567变为*******。- 缺失值与
null不变,空字符串仍为空字符串。嵌套对象、集合和嵌套字符串数组按 Schema 路径递归处理。
自定义 meta-annotation
领域专用规则由带 @Masking(strategy) 的运行时注解声明。Strategy 在 Schema 构建时实现 MaskStrategy<A>.compile,返回查询时复用的 CompiledMask;该过程不需要 KSP。
import me.ahoo.wow.api.query.mask.CompiledMask
import me.ahoo.wow.api.query.mask.MaskStrategy
import me.ahoo.wow.api.query.mask.Masking
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.FIELD
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
@Target(FIELD, PROPERTY_GETTER)
@Retention(RUNTIME)
@Masking(strategy = RedactStrategy::class)
annotation class Redact(val replacement: String = "[redacted]")
object RedactStrategy : MaskStrategy<Redact> {
override fun compile(annotation: Redact): CompiledMask {
require(annotation.replacement.isNotEmpty())
return CompiledMask { value ->
if (value.isEmpty()) value else annotation.replacement
}
}
}Strategy 可以是 Kotlin object 或公开无参类。示例不按 UTF-16 code unit 截断输入,并显式保留空字符串;需要保留字符位置时,应像内建实现一样按 Unicode code point 处理。
Query Schema 合同
JsonQuerySchemaSource 在运行时发现字段、Jackson 可见的非 public getter,以及从父类 Kotlin property 或接口 getter 继承的有效注解。规则随 Query Schema 合并和后端 adapter 传递,但公开 QueryModelSchemaMetadata 的递归值节点仅以 masked: Boolean 暴露脱敏信息;Strategy 类型、注解参数、编译后的规则和可执行函数只存在于内存中。
Gateway 每次订阅只取得一次 Schema,prepare、公共校验、Backend 与响应 Mask 使用同一实例。Mask 遍历定义在 Schema 发布时构建,订阅只使用这一代不可变数据;refresh 发布新实例不会改变在途订阅。Schema 获取失败不会跳过脱敏返回原值。没有 Mask 声明时不遍历响应 JSON。
行为矩阵
| 查询或结果 | 行为 |
|---|---|
Snapshot/EventStream typed single、list、paged | 在 typed 物化前脱敏 |
Snapshot/EventStream dynamic single、list、paged | 返回已脱敏的 ObjectNode |
Snapshot/EventStream typed/dynamic cursor | 对 CursorPage.list 脱敏,原样保留 nextCursor |
| Snapshot state-only / aggregate-state load | 复用 Snapshot Gateway,同样脱敏 |
| 普通 filter、全文 search、sort | 允许引用 Mask 字段;后端按原值匹配或排序,响应仍脱敏 |
CursorQuery 有效 sort | 必须具有已证明的 CURSOR_SORT binding、是单值字段、不能带 Mask 规则,也不能通过 projection 或物理 binding alias 指向 masked 字段;否则在 Backend 前拒绝,避免原始排序值或多值数组进入 nextCursor |
数据查询 count | 计数不变;Gateway 仍加载 Schema 完成准入,但 Mask 层不处理字段值 |
| 聚合 group、字段 metric、数值 expression | 公共校验在 Backend 执行前拒绝受 Mask 保护的字段或源别名 |
| 聚合所需 Schema 不可用 | 失败关闭;即使聚合只含 COUNT 也不降级执行 |
失败关闭边界
| 条件 | 结果 |
|---|---|
| 注解成员不是 JVM String,或被规则覆盖的值域不是字符串/字符串数组,或含 UNKNOWN | Schema 构建失败 |
| 同一成员有多个有效 Mask 注解,或 Schema 分支规则冲突 | Schema conflict |
Strategy 无法构造,或 compile 抛错 | Schema 构建失败,错误保留 |
被规则覆盖的响应值为非 String/非 String 数组,Strategy 执行抛错,或自定义 CompiledMask 返回 null | 当前结果 Publisher 失败,不返回原值 |
EventStream event item 含非 null payload,但 bodyType 缺失、不是字符串或未知 | 当前结果 Publisher 失败 |
EventStream 顶层 body 不是数组,或数组包含非 object event item | 当前结果 Publisher 失败 |
Event projection 完全没有顶层 body,或把该事件数组投影为 null 时,Mask 安全跳过。顶层 body 存在时必须是数组,且每个 event item 都必须是 object。合法 event item 内的 payload 属性 body 缺失或为 null,表示 metadata-only 或 payload 已排除;此时没有敏感 payload 可泄漏,不要求 bodyType。非 null payload 仍必须携带已知的字符串 bodyType;缺失、非字符串或未知类型都会在 Mask 前失败关闭。
明确的未脱敏联合分支(例如 INTEGER)保留原值;字符串分支继续脱敏。Map 的明确 property 优先于 additionalProperties,数组 Item 层级与原生别名必须按共享 Schema 保留,不能扩大到无关兄弟字段。
受信原始值边界
- 直接调用 Factory 返回 binding;受信原始访问为
factory.create(namedAggregate).backend,会绕过整个 Gateway,包括查询 Filter、错误观察和 Mask。 - 自定义 Factory 在
QueryBackendBinding中配对 Backend 与QueryModelSchemaProvider;自定义 Backend 从不实现 Provider。Provider 不可用时在 Context 与订阅 Backend 前失败关闭,不会跳过 Mask 返回原值。
两者都只适合存储扩展、Backend 合同测试和受信诊断,不能作为普通业务查询入口。
迁移与验证
从 V8 Registry/Filter Mask 迁移时,先按 V9 查询迁移删除旧类型并把规则移到领域字段,再完成以下检查:
- 通过查询模型 Schema端点确认目标字段只新增
masked: true,没有公开策略或参数。 - 分别验证 Snapshot/EventStream 的 typed、dynamic 与 state-only/aggregate-state load 响应。
- 验证普通 filter/search/sort 与数据查询
count保持可用;masked cursor sort、group、字段 metric、数值 expression 和 Schema unavailable 聚合失败关闭。 - 仅在受信测试中验证 direct Factory 返回原始值,并确认存储文档与通用 Jackson 序列化未被改写。
完整执行位置、Filter 顺序和绕过条件见查询网关。