数据权限
Wow 在写链路和读链路中携带四类数据访问上下文:
- Tenant — 聚合存储与路由作用域;
- Owner — 可选拥有者元数据与路由作用域;
- Space — 通过请求头提供的可选命名空间元数据;
- ABAC tags — 资源标签与应用提供的 Principal 过滤器。
在 WebFlux 查询路由中,Handler 用 QueryRequestScope 解析 tenant、owner、space scope 并放入 Reactor Context,由 Gateway 在 prepare 完成后合并。随后,Snapshot 与 EventStream Gateway 通过公共策略链评估 QueryPolicy 并追加规则条件,AbacQueryPolicy 负责其中适用于 Snapshot 与绑定了标签列的数据集的资源标签条件。
作用域不是身份认证
tenant/owner 路径、Wow-Space-Id 请求头或 ABAC 标签都是路由与过滤数据,不能证明谁发送了请求,也不能证明该 Principal 有权选择这些值。应用必须先认证身份,在服务端绑定允许的作用域,授权命令和查询路由,并避免让不受信请求访问原始查询 Factory。
RESTful URL 模式
生成的聚合路由遵循以下形状,各可选段由聚合元数据决定是否启用:
[tenant/{tenantId}/][owner/{ownerId}/]{resourceName}[/{resourceId}]/{action}| 作用域 | 线协议值 | 路由条件 |
|---|---|---|
| Tenant | tenant/{tenantId} | 聚合没有静态 tenant ID |
| Owner | owner/{ownerId} | 有效的拥有者策略(@AggregateOwner)不是 NEVER |
| Space | Wow-Space-Id 请求头 | 聚合声明了 spaced(@Spaced) |
AGGREGATE_ID 拥有者策略在 owner ID 已标识聚合时移除独立 resource-ID 段。命令级 @CommandRoute 可以覆盖聚合路由默认值。快照查询贡献者始终发布基础聚合路由,并在适用时增加 tenant/owner 变体,因此无作用域查询路由必须有显式安全策略。准确路由以运行服务的 OpenAPI 为准;限界上下文 alias 不会自动成为 URL 前缀。
查询 Schema 路由(/{aggregate}/snapshot/schema、/{aggregate}/event/schema)刻意不生成 tenant、owner 或 aggregate-ID 路径变体:它们描述聚合查询模型,而不是某个调用方的数据;spaced 聚合的公共聚合合同仍可能声明 Wow-Space-Id。
租户(Tenant)
Tenant 是 AggregateId 以及持久化消息/快照元数据的一部分。同一 named aggregate 内的 aggregate ID 仍需全局唯一;tenant 不会允许复用相同 aggregate ID。
基于注解的租户 ID
标记携带动态 tenant 标识的命令属性:
data class CreateOrder(
@AggregateId val orderId: String,
@TenantId val tenantId: String,
val items: List<OrderItem>,
)KSP 元数据记录 tenant 的解析方式。运行时 WebFlux 路由从路径读取 tenant,并将其带入命令或查询上下文。注解本身不会验证调用方是否属于该 tenant。
静态租户 ID
当一个聚合的所有实例都属于固定 tenant 时使用 @StaticTenantId:
@AggregateRoot
@StaticTenantId("system")
class SystemConfiguration(private val state: SystemConfigurationState)生成路由不会包含动态 tenant 前缀。这只是路由/存储选择,并不表示资源公开。
默认租户
TenantId.DEFAULT_TENANT_ID 是 (0)。静态单租户聚合通常使用该值。当预期动态 tenant 时,不要把默认值作为授权回退。
拥有者(Owner)
Owner 是聚合内的快照和消息元数据,可以约束生成路由与快照查询;调用方身份仍必须由应用安全层绑定。
基于注解的拥有者 ID
标记提供拥有者元数据的命令字段:
data class CreateCart(
@AggregateId val cartId: String,
@OwnerId val userId: String,
)不要仅因客户端字段带 @OwnerId 就信任其 userId。应在应用边界用已认证 Principal 比对或替换它。自 9.3.0 起,路由写了 {ownerId} 时,与路径不同的请求体拥有者返回 400(见请求身份);其他路由仍以请求体的值为拥有者。
拥有者路由策略
@AggregateRoot
@AggregateRoute(resourceName = "orders")
@AggregateOwner(OwnerPolicy.ALWAYS)
class Order(private val state: OrderState)自 9.3.0 起,拥有者策略用 @AggregateOwner 声明;缺少它时仍读取已弃用的 @AggregateRoute(owner = AggregateRoute.Owner.…)(见聚合策略)。
| 策略 | Owner 路径 | Resource ID | 含义 |
|---|---|---|---|
NEVER | 无 | 正常保留 | 没有 owner 路由元数据 |
ALWAYS | 必填 | 正常保留 | owner 与 aggregate ID 独立 |
AGGREGATE_ID | 必填 | 移除 | owner ID 同时标识聚合 |
生成路由与 OwnerAggregatePrecondition 可以保证已加载聚合的 owner 元数据与路由值一致,但不能证明已认证调用方拥有该路由值。
拥有权转移
实现 OwnerTransferred 的事件会在溯源时修改状态聚合的 owner 元数据:
data class OrderAssigned(
override val toOwnerId: String,
) : OwnerTransferred领域模型决定何时允许转移。发出事件前应授权转移命令;标记接口只负责应用新元数据。
命名空间(Space)
Space 是随消息和快照存储的字符串命名空间,与 tenant、owner 相互独立,默认值为空字符串。
启用命名空间
@AggregateRoot
@AggregateRoute(resourceName = "sales-order")
@Spaced
class Order(private val state: OrderState)自 9.3.0 起,用 @Spaced 声明聚合带 space;缺少它时仍读取已弃用的 @AggregateRoute(spaced = true)(见聚合策略)。
对启用 spaced 的聚合,WebFlux 读取 Wow-Space-Id:命令写入该 space,查询路由(快照、事件流、状态与溯源读取)追加 SPACE_ID 过滤器。请求头不会变成 URL 段,也不会认证调用方是否有权访问该 space。
未声明 spaced 的聚合在所有路由上忽略该请求头:无论客户端发送什么,其命令都使用默认 space(""),其查询也不追加 space 过滤器。其他来源的命令同理:Saga 响应 spaced 聚合的事件时,不会把该 space 传给非 spaced 聚合;非 spaced 聚合也从不校验或记录命令的 space。fetcher-cosec 等客户端会在每个请求上发送 space 请求头,因此依赖该请求头隔离非 spaced 聚合的部署必须为该聚合声明 spaced = true。
从 9.1 升级
Wow 9.1 对所有聚合都从请求里取 space;9.2 只对 spaced 聚合取。这是 v9 兼容冻结的一项批准例外,9.2.0 发布说明的「Breaking」(服务端行为)一节列出了它。
- 升级之前,给每个靠 space 请求头隔离数据的聚合声明
spaced = true(自 9.3.0 起为@Spaced),这项声明随 9.2 升级一起构建、上线。 - 滚动升级期间,9.1 与 9.2 节点对非 spaced 聚合的处理不同:9.1 节点把请求头的 space 写进命令、按它过滤查询;9.2 节点写默认 space、不过滤。同一个查询因此可能随应答节点不同而返回不同的行。网关、聚合节点和查询节点一起升级,混跑窗口尽量短。
- 升级之后,9.1 节点已经带 space 写入的数据保留原来的 space;之后的命令既不校验也不改变它,查询也不再按请求的 space 过滤。
- 回退到 9.1:9.2 以默认 space 写入的记录,对发送 space 请求头的客户端,命令会被拒绝、查询会被隐藏。
命名空间转移
实现 SpaceTransferred 的事件会修改状态聚合的 space 元数据:
data class OrderArchived(
override val toSpaceId: SpaceId,
) : SpaceTransferred与拥有权转移相同,该事件只应用状态变更;是否允许变更由命令侧策略决定。
ABAC(基于属性的访问控制)
Wow 保存资源标签。Snapshot Gateway 通过 QueryPolicy 接口组合查询约束,AbacQueryPolicy 是标签授权实现。应用从已认证上下文提供 Principal 标签,并决定缺少上下文时公开还是拒绝。
核心概念
AbacTags 是 Map<String, List<String>>:
val principalTags = mapOf(
"department" to listOf("engineering", "product"),
"role" to listOf("reader"),
)
val resourceTags = mapOf(
"department" to listOf("engineering"),
)对于每个 Principal 标签 key,普通值会匹配“资源缺少该 key / 值为空 / 与 Principal 值有交集”中的任一情况。listOf("*") 会映射为该 key 的 EXISTS 条件。多个 Principal key 之间用 AND 组合。
因此,在内置标签匹配表达式中,无标签资源是公开的。如果这不是业务规则,应覆盖策略,而不是把标签机制当成完整授权系统。
应用资源标签
DefaultApplyResourceTags 是内置命令,使用 PUT、action tags 和 ID 路径。当聚合没有注册自己的 ApplyResourceTags 处理函数时,Wow 可以使用默认命令函数,并把 DefaultResourceTagsApplied 溯源到状态聚合元数据。
val command = DefaultApplyResourceTags(
tags = mapOf("department" to listOf("engineering")),
)该命令与其他改变授权状态的操作一样必须受到保护。发布生成路由不等于该端点只允许管理员调用。
标签合并
merge 合并同一 key 的值:
val effective = mapOf("department" to listOf("engineering"))
.merge(mapOf("department" to listOf("product"), "role" to listOf("reader")))应明确选择“替换”还是“合并”。DefaultResourceTagsApplied 会替换状态聚合中保存的资源标签,不会自动合并。
动态标签提取(StateAggregateTagsExtractor)
需要从物化聚合派生标签时,在状态上实现 StateAggregateTagsExtractor<S>:
class OrderState(
val department: String,
) : StateAggregateTagsExtractor<OrderState> {
override fun extract(source: ReadOnlyStateAggregate<OrderState>): AbacTags =
mapOf("department" to listOf(department)).merge(source.tags)
}Extractor 在状态物化期间计算资源元数据,不负责解析 Principal 身份。
ABAC 查询策略
继承 AbacQueryPolicy,并让受保护查询失败关闭:
@Component
class MemberAbacQueryPolicy(
private val memberships: MembershipRepository,
options: AbacQueryOptions,
) : AbacQueryPolicy(options) {
override fun getPrincipalTags(
contextView: ContextView,
context: QueryContext<*>,
): Mono<AbacTags> = contextView.getOrEmpty<Principal>(Principal::class.java)
.map { principal -> memberships.tags(principal.name, context) }
.orElseGet { Mono.error(AccessDeniedException("Missing principal")) }
}该示例代表应用策略,应根据实际安全上下文调整。框架对空 tags 或 Mono.empty() 的默认结果是 MatchAllFilter,所以受保护应用必须显式拒绝缺失身份或标签。
Starter 根据 wow.query.abac.* 发布 AbacQueryOptions Bean;像上例那样在构造函数中接收它,即可启用两项可选收紧:
require-principal-tags=true:主体没有标签(空或Mono.empty())的HTTP快照查询返回403 IllegalAccessQueryScope,而不再匹配全部;进程内查询受信,不因此被拒绝。match-missing-tag-key=false:资源缺少主体的某个非通配标签键,或该键为空时,不再作为公开资源匹配,必须带有该键且取值落在主体的值中。
两者默认都保持上述放行语义。
用 CoSec 的主体属性作标签
用 CoSec 认证的应用不必自己写 getPrincipalTags:打开 wow.cosec.abac.enabled=true,Starter 就注册 CoSecAbacQueryPolicy(需要 cosec-support 能力与 CoSec 的 WebFlux 模块),从 CoSec 放在 Reactor 上下文里的主体读标签——属性键以 wow.cosec.abac.attribute-prefix(缺省 abac.)开头的就是标签,值是逗号分隔的字符串或集合:abac.region = "east,north" 即标签 region,值 east、north。匿名主体没有标签。应用声明了自己的 AbacQueryPolicy 时它让位。它同样接收 AbacQueryOptions,所以面向人读数据的服务应同时打开 wow.query.abac.require-principal-tags=true,让没有标签的主体被拒绝,而不是读到全部。
查询入口与策略执行
Spring 注册的 Snapshot 与 EventStream Gateway 都执行准入各步:入口预算、QueryFilter 改写、受信 scope 和 QueryPolicy。策略通过 QueryContext 判断适用范围,不适用时返回 MatchAllFilter;AbacQueryPolicy 仅对 Snapshot 读取 Principal 标签并生成条件,不为 EventStream 提供标签授权。Registrar 把路由后的 Backend 与 QuerySchemaCatalog 编译的 Schema Provider 交给 Gateway。JVM 调用不执行 HTTP scope 解析;可用 contextWrite { it.withQueryScope(scope) } 提供受信作用域。公共校验在改写、scope、policy 与默认条件全部合并后进行。受管查询与 aggregate-state load 的 Mask 行为见字段脱敏。
SnapshotQueryBackendFactory 与 EventStreamQueryBackendFactory 返回 QueryBackendBinding;受信原始执行明确解包 factory.create(namedAggregate).backend。它绕过 QueryGateway 策略链,属于必须保护的基础设施访问。自定义 Backend 从不实现 Provider;其 Factory 在 binding 中配对 Backend 与报告原生事实的 QueryStorageAdapter,Schema 由 QuerySchemaCatalog 编译。Schema 不可用时,所有受管查询都会在订阅 Backend 前失败关闭。
两种模型的聚合查询都执行同样的准入各步、scope 与 QueryPolicy;公共校验允许普通 filter/search/sort 使用脱敏字段,但拒绝 group、字段 metric 或 expression 引用受保护值,count 不变。仍不能仅因普通快照查询受 ABAC 约束就开放敏感聚合接口。
State 点读
State 路由(按 id、按版本、按时间加载与 tracing)通过事件回放读取,不经过 Gateway,因此默认不执行准入各步、范围、QueryPolicy 与脱敏;按 id、按版本、按时间加载的路由带拥有者路径段时,拥有者前置检查照常生效;tracing 的路径不含拥有者,不做该检查,见运维路由。设 wow.webflux.state.point-read-admission=true 后,每个读取的状态先转换为快照形状的记录,然后:
- Gateway 的入口策略接受
HTTP入口;开启wow.query.require-authenticated-scope时,已认证范围未固定租户的调用方与查询路由一样被拒绝(403)。这些路由只在点读准入下执行该开关,所以只开该开关、不开point-read-admission时启动失败; - 由查询准入(
QueryAdmission.admitRecord)在内存中对它执行查询的范围、策略与默认范围三步:QueryRequestScope给出的调用方范围、每个QueryPolicy的限制条件,以及快照的默认范围(读取未声明删除范围时隐藏已删除的状态;tracing 读取全部版本,含已删除)。策略与 Gateway 使用的是同一组 Bean,顺序相同,看到的是来自HTTP入口、按 id 的SINGLE查询。不满足的状态视为不存在:加载返回404,tracing 返回[]。限制条件先与查询一样规范化(字段别名替换为规范字段,null的EQ/NE降级),再按所有后端遵循的语义判定(数值按值比较;值为null的字段视为存在)。内存判定支持范围与 ABAC 策略会产生的节点:id、租户、拥有者、空间与删除状态过滤,AND/OR/NOR,以及字段上的EQ/NE/IN/NOT_IN/IS_NULL/IS_NOT_NULL/EXISTS/NOT_EXISTS/IS_EMPTY;其他节点按失败关闭处理; - 响应按聚合的快照查询 schema 脱敏,tracing 响应还按事件流 schema 脱敏;schema 无法加载时读取失败(
503),不会返回未脱敏的数据; - tracing 最多返回
wow.webflux.state.tracing-max-versions个版本(默认1000,0关闭上限)。超出的范围在响应开始前以400拒绝,可用headVersion、tailVersion或limit缩小;只有全部被追踪的状态都获准入时才输出结果。
开关默认关闭,保持现有行为。
运维路由
以下路由的路径只有租户与聚合 ID,没有拥有者路径段,不按拥有者隔离,面向运维而非终端用户:
| 路由 | 拥有者处理 |
|---|---|
| State tracing | 不做拥有者前置检查;开启点读准入后由调用方范围与 QueryPolicy 判定 |
| 事件流加载(按版本区间) | 经 EventStream Gateway 执行准入;拥有者过滤只来自自报的 Command-Owner-Id 请求头 |
| 快照重建(单个与批量)、事件补偿、批量重发 State 事件 | 不读取拥有者 |
Command-Owner-Id 是客户端自报的值,不是安全边界:知道拥有者 ID 的调用方可以照填。所以不要把这些路由直接开放给终端用户:
- 在网关按路径或角色只放行运维调用方;
- 需要按调用方读取 tracing 时,开启
wow.webflux.state.point-read-admission,并配合wow.query.require-authenticated-scope与失败关闭的QueryPolicy。事件流加载经过 Gateway,同样遵循这两项。
需要终端用户按拥有者访问的数据,请用带拥有者路径段的加载与查询路由。
必须完成的安全闭环
- 请求进入生成路由前完成身份认证。
- 从受信的服务端成员关系数据推导允许的 tenant、owner、space。
- 授权命令,特别是 owner/space 转移和资源标签变更。
- 为受保护数据注册失败关闭的查询策略。
- 让原始 Factory 和未包装自定义服务远离不受信请求路径。
- 测试匿名、伪造作用域、跨租户、缺少标签、聚合以及原始入口等负例。
隔离层级总结
| 层级 | 存储/查询元数据 | HTTP 表达 | Wow 提供 | 应用必须提供 |
|---|---|---|---|---|
| Tenant | tenantId | 动态时使用路径前缀 | 传播与查询约束 | 已认证作用域绑定 |
| Owner | ownerId | 可选路径前缀 | 路由形状、前置检查、查询约束 | 调用方到 owner 的授权 |
| Space | spaceId | Wow-Space-Id 请求头 | 传播与查询约束 | 调用方到 space 的授权 |
| ABAC | tags | 内部查询过滤器 | 标签存储与扩展点 | Principal 解析和失败关闭策略 |
只有应用完成身份绑定与授权后,这些机制才形成安全边界。路由形状、元数据传播、查询 Schema 校验与 HTTP 成本护栏都不能替代该策略。