保护部署
Wow 服务有两扇门:公开端口,调用方在这里发送命令和查询;内部端口,系统自己的节点和服务在这里互相通信。本页说明怎样保护每一扇门、默认值是什么,以及日志对个人数据意味着什么。背后的模型见身份、租户与访问。
检查清单
- 只有网关能访问公开端口。
- 平台之外永远访问不到内部端口、Kafka 和存储:像
deploy/里那样,用网络策略只让平台自己的 Pod 访问它们。 - 网关自己写转发头,并去掉客户端发来的
CoSec-Identity;它从不把客户端发来的传下去。 - 调用方解析器认证发往公开端口的每个请求;CoSec 提供了一个。
- 第一个请求到达之前,CoSec 的文档已经发布到一个以
maxmemory-policy noeviction运行的 Redis。 - 只按路由授权不够的地方,命令按动作授权。
- 每个服务列出其他服务可以在它的内部端口上调用的系统命令;有助于发现错误时,也列出领域命令和模型。
- 只有服务自己的节点、给它发命令的服务和 ops 能写它的命令主题。
- 数据库和消息代理的密码是密钥。
- 事件里没有你可能不得不删除的个人数据。
认证调用方
宿主用它的 CallerResolver 把每个请求变成一个调用方。没有解析器时,开发 profile(wow.dev-profiles 中的某个 profile)中的节点从任何客户端都能设置的 Wow-User、Wow-Owner、Wow-Roles、Wow-Tenant 和 Wow-App 请求头读取调用方,不带 Wow-User 的请求是默认租户的匿名调用方(调用方是谁);仅因内部端口只绑定回环接口而处于开发中的节点,以这个匿名调用方处理每个请求。两者都会记录一条警告;其他节点拒绝启动。有意匿名的服务用一个返回 CallerResolver.ANONYMOUS 的 CallerResolver Bean 表明这一点。
wow-cosec 在 10.0 中是实验性的
wow-cosec 跟随 CoSec 8.0.0,而 CoSec 8.0.0 仍是预发布版本,所以 wow-cosec 在 10.x 内可能改变。它的类型(CoSecCallerResolver、CoSecCallerOptions、CoSecCommandAuthorizer、CoSecServiceRouteAuthorizer、CoSecActions)以及宿主的 CoSecAutoConfiguration 和 CoSecRouteMapAutoConfiguration 都是 @ExperimentalWowApi(API 级别),wow.cosec.* 配置键也可能随之改变。只靠依赖和配置键启用 CoSec 的服务无需再做什么;在 Kotlin 代码里用到这些类型时,以 @OptIn(ExperimentalWowApi::class) 选择使用。CoSec 8 正式发布后,一个 10.x 的次版本会去掉这个标记。不用 CoSec 的服务不受影响:其余 API 的级别不变。
类路径上有 wow-cosec 以及 CoSec 的 starter 和 WebFlux 模块(me.ahoo.cosec:cosec-spring-boot、me.ahoo.cosec:cosec-webflux)时,除非应用自己定义了 CallerResolver,CoSec 的解析器就是宿主的解析器。CoSec 的管道认证每个请求并解析它的作用域,即决定这个请求时所在的应用和空间;解析器把 CoSec 构建的安全上下文变成调用方:
- 租户和主体标识来自主体;CoSec 的默认租户
(0)即wow.tenant.default。没有已认证主体的请求,或者安全上下文不是 CoSec 的管道构建的请求,返回 401。 - 所有者。 主体是所有者级调用方,作用于它自己的聚合;除非它具有
wow.cosec.tenant-level中的某个角色或策略,这时它是租户级调用方,作用于它租户的每个聚合,例如为每个买家发货的员工。 - 空间是 CoSec 作用域的空间。客户端在
CoSec-Space-Id中指明它,只有当主体在那个空间中有角色时 CoSec 才接受;不带它时,请求处于 CoSec 的默认空间,这不是任何 Wow 空间。CoSec 宿主不从Wow-Space取空间:Wow-Space指明的空间与 CoSec 的不同时,请求返回 403wow.auth.forbidden。 - 应用。 调用方的应用(
Caller.app),也就是提交记录的请求事实app_id,是 CoSec 作用域的应用:宿主自己的应用(cosec.authorization.scope.app-id);没有时,是请求声明且被 CoSec 接受的CoSec-App-Id;再没有时,在 CoSec 网关之后,是网关转发的应用。作用域没有应用时,调用方也没有应用。 - 角色和属性。 调用方的
roles属性列出主体在作用域空间中的角色,policies列出它的策略,其余每个主体属性都以自己的名字成为一项声明,供访问策略读取(caller.claims("departments"))。在 CoSec 网关之后,主体没有策略,所以那里只有wow.cosec.tenant-level和wow.cosec.cross-scope的角色能匹配,声明就是网关转发的那些(cosec.gateway.identity.claims)。 - 在别处行事。 只有具有
wow.cosec.cross-scope中某个角色或策略的主体,才能在Wow-Scope里指明另一个租户或作用域。 - 服务。 CoSec 主体从来不是服务身份。服务只在内部端口上声明自己(关上内部端口)。
CoSec 怎样认证请求、怎样检查客户端声明的应用和空间,见 CoSec 的指南:认证、应用与空间。
记录客户端地址
每条命令记录客户端的 User-Agent 和地址(user_agent、remote_ip),每个都截断到 512 个字符。公开端口前面总有网关,所以宿主从网关的 X-Forwarded-For 取地址:除非应用自己设置,宿主把 Spring Boot 的 server.forward-headers-strategy 设为 framework,并在启动时记录生效的策略。网关必须覆盖客户端发来的这个请求头。把策略设为 none 则记录连接的地址。
授权命令
授权在准入时、命令运行之前进行:
- 按路由。 在 CoSec 决定请求的地方,它的管道在路由运行之前,按一个资源上的动作为请求授权。
- 按动作。 设置
wow.cosec.authorizer.enabled: true后,CoSec 还会以调用方的身份为请求的每条命令和每次服务路由调用授权(CoSec 在哪里决定)。你自己的CommandAuthorizer也做同样的事。 - 按作用域。 无论授权器怎么说,内核都会拒绝作用于调用方作用域之外的聚合的命令,如同聚合不存在(
404 wow.aggregate.not-found),查询也只返回调用方可以看的东西(授权读取)。 - 按访问策略。 有自己访问策略(其状态的
AccessPolicyBean)的聚合,只在策略的write允许调用方修改它时才接受命令,取代作用域。被拒绝的调用方,若策略的read也对它隐藏该聚合,内核答404 wow.aggregate.not-found,若它能读,答403 wow.aggregate.forbidden;创建,以及改变聚合访问属性的命令,会在它留下的聚合上再检查一次。服务派生的命令和以服务自身身份发送的命令受信任;进程内客户端的默认调用方是匿名的,会被拒绝(策略在哪里生效、哪些是受信的)。
运维人员操作停放聚合的路由 /wow/ops/parked 默认关闭(wow.http.ops.enabled)。打开后,它们的动作和读取按系统命令 wow.ledger.* 授权。没有命令授权器时,它们全部被拒绝(403 wow.auth.forbidden),默认从不放行,宿主在启动时发出警告。
Wow 内置聚合的系统命令在公开端口上遵循同一规则:定时任务的命令(POST /<context>/wow.schedule/{id}/enable、reschedule 和 disable)以及聚合类型的快照重建(POST /<context>/<aggregate>/wow.snapshot.rebuild)。没有命令授权器时,公开端口对所有调用方(包括匿名调用方)都拒绝它们(403 wow.auth.forbidden),宿主在启动时说明这一点。有授权器时由授权器决定:在 CoSec 下,由策略把它们的动作(见下文)授予运维人员。网关模式的服务没有 CoSec 授权器,需要自己的授权器才能放行它们。只有公开端口拒绝它们:任务自己的命令、Saga 发出的命令,以及其他服务(如 ops)在内部端口调用的命令照常发送。聚合的生命周期命令(wow.lifecycle.*)作用于聚合本身,与它的领域命令一样授权并检查作用域。
客户端永远不该发送的命令,例如由 Saga 记录的支付网关答复,应标注 @InternalCommand,而不是只靠策略挡住:漏配一条规则就会失守。公开端口不挂它的路由:无论调用方是谁、策略怎样,请求它都答 404,与任何未知路径的 problem 相同;CoSec 的路由映射也不含它(只在内部受理的命令)。内部端口照常受理 wow.callers.<service>.commands 许可的服务发来的调用,节点的授权器与对任何命令一样裁决它。
动作名称
一条命令、一个服务路由在 CoSec 中只有一个名字,无论是 CoSec 决定 HTTP 请求,还是授权器在进程内决定命令,所以一条策略同时守住两者:
| 对象 | 动作 | 资源 |
|---|---|---|
| 命令 | <context>:<command>,命令在 Manifest 中列出的名字:eshop-ordering:CreateOrder、shop:wow.lifecycle.DeleteAggregate | <aggregate>/<id>:order/o-1 |
| 不带聚合 ID 的命令路由,例如创建 | <context>:<command> | <aggregate>:order |
| 一个聚合类型的快照重建 | <context>:wow.snapshot.Rebuild | snapshot/<aggregate> |
| 定时任务的命令 | <context>:wow.schedule.Enable 及其他任务命令 | schedule/<job> |
| 服务路由 | <context>:<route> | route/<route> |
| 查询或状态路由 | http:<METHOD> | 它的路径 |
不带聚合 ID 的路由把 ID 留给服务端选择,所以它的 HTTP 请求只作用于聚合类型,而进程内的命令作用于它指向的聚合:这种命令的策略要同时写出带 ID 和不带 ID 的聚合类型。http: 动作上的策略从不覆盖命令路由和服务路由:它们处于各自上下文的命名空间。
谁能做什么,由 CoSec 的文档决定:平台的文档、每个应用的文档和每个租户的文档。编写它们请参考 CoSec 的指南,而不是本页:策略语言、角色权限、测试策略。
CoSec 在哪里决定
- 在自己做决定的服务中(
cosec.pipeline.mode为enforce,CoSec 的默认值),CoSec 的管道读取宿主根据服务 Manifest 构建的路由映射:它的命令路由和服务路由,使用上面的名字,取代cosec-routes.json。内部命令的路由不在其中,因为公开端口不提供它们。应用自己声明了RouteMapBean 时保留它。这样的服务持有 CoSec 的签名密钥,并从 CoSec 的 Redis 读取文档(CoSec 快速开始)。 - 在 CoSec 网关之后(
wow.cosec.mode: gateway),由网关决定。网关不加载路由映射,所以它的策略写的是请求路径上的http:<METHOD>动作,服务自己不按策略决定任何事(以网关模式运行服务)。
设置 wow.cosec.authorizer.enabled: true 后,CoSecCommandAuthorizer 和 CoSecServiceRouteAuthorizer 在进程内为每条命令和每次服务路由调用授权,无论它经哪条路径到达命令网关:按上面的动作,以它所来自的请求的主体、在那个请求的作用域中授权。只有 CoSec 自己做决定的地方才装配它们,这需要 CoSec 的 AuthorizationChecker:网关模式的服务没有它,这个开关在那里不起作用。开关默认关闭:没有它、也没有你自己的授权器时,通过了路由的命令就被允许,上面内置聚合的系统命令除外。你自己的 CommandAuthorizer 与 CoSec 的授权器一同询问,命令需要两者都允许(组合规则);只应在没有 CoSec 时才决定的、按你自己的角色授权的授权器,注入 ObjectProvider<CoSecCommandAuthorizer>,在存在它时不提出异议,平台服务就是这样做的。
不由请求携带的命令,CoSec 的授权器不提出异议:Saga、定时器或补偿发出的命令,定时任务的命令,以及其他服务在内部端口上的调用。CoSec 没有属于它们的请求可以决定。这样的命令带着根命令的作用域和操作人,而根命令在根请求到达时已经由准入授权过;节点的其他授权器仍然作用于它,服务在内部端口上的调用由它的调用方列表决定(关上内部端口)。
授权读取
查询、状态读取和 trace 分层授权,每一层收窄前一层允许的范围:
- 按路由。 在 CoSec 决定请求的地方,路由路径上
http:<METHOD>动作的策略放行或拒绝它(动作名称)。在 CoSec 网关之后,由网关的策略决定。 - 按读取者。 服务自己的
ReadAuthorizerBean 决定调用方是否可以读取某个模型:给定调用方和它读取的东西(聚合类型或数据集、模型、操作和入口),它在读取任何东西之前允许或拒绝这次读取,所以被拒绝的调用方得不到任何信息,连某个状态是否存在都不知道。拒绝答403 wow.auth.forbidden,或授权器指明的错误码表里的另一个码,例如没有应用的请求。它按谁在读拒绝(角色、应用),从不按读的是什么拒绝。每个入口的每个调用方都会被询问,包括进程内的QueryApi.of(caller);不带调用方读取的受信任进程内代码不被询问。 - 按作用域。 查询准入把调用方的作用域加进每个查询,状态读取也检查它:租户总是加上;所有者只对
Ownership.OWNER的聚合加上;调用方在某个空间里行事时,加上空间。调用方没有值的那一项不加,所以租户级调用方读取整个租户。查询里的过滤条件不能放宽它。在事件上,所有者和空间是聚合当前的所有者和空间,每条流都记着它们(谁能看到什么)。 - 按访问策略。 有服务自己的访问策略的聚合,按策略的
read授予调用方的读取,用每个聚合的访问属性比较,取代作用域的所有者与空间:下推到每个查询,在状态读取时求值,也作用于该聚合的数据集(策略在哪里生效)。
属于租户的聚合,租户里的每个调用方都能读取。 Ownership.TENANT 不记录所有者,所以作用域不加所有者:购物者和员工一样读取商品,所有者级调用方也读取它的租户和空间里所有这样的聚合。这对目录是对的,对只有某些角色才能读取的聚合就不对了。Wow 没有自己的读取角色,所以这样的服务要带一个读取授权器,拒绝没有该角色的调用方。平台的服务就是这样做的:运维服务的 ReaderRole 只放行查看者或操作员,分析目录的 AnalyticsCatalogReaderRole 只放行它的读者。在运维服务自己由 CoSec 决定且打开了它的授权器时,运维服务不注册角色检查,由 CoSec 在查询路由上的 http: 策略决定谁能读取;在网关模式下由角色决定。
class ReaderRole : ReadAuthorizer {
override suspend fun authorize(caller: Caller, read: ReadAction): ReadAuthorization =
if (OpsRoles.rolesOf(caller).any { it in OpsRoles.READERS }) {
ReadAuthorization.Allowed
} else {
ReadAuthorization.Denied.of(
"${caller.subject ?: "An anonymous caller"} reads no parked entry: it needs ${OpsRoles.VIEWER} or " +
"${OpsRoles.OPERATOR}.",
)
}
}把它注册为 ReadAuthorizer 类型的 Bean;宿主按 Bean 顺序询问每一个,在读取任何东西之前。有两点要做对:
- 它按谁在读拒绝,从不按读的是什么拒绝。 拒绝是
403 wow.auth.forbidden,或Denied指明的错误码表里的另一个码,例如视图存储对没有应用的请求回答的wow.query.malformed。调用方看到哪些行由访问策略决定:被它隐藏的聚合读起来就像不存在,状态读取返回 404wow.aggregate.not-found。 - 受信代码不被询问。 受信代码(
NodeQueryApi.trusted())和订阅的处理函数不带调用方地读取,读的是整个租户,作为节点自己的代码:不会为它们询问任何读取授权器。
读取授权器只授权读取。谁能修改属于租户的聚合由命令授权器决定(授权命令):没有授权器时,路由放行的任何调用方都能发送它的命令。
把网关放在前面
Wow 信任网关转发的身份,所以网关本身就是一条边界。每个部署都要满足两条要求:
- 只有网关能访问公开端口。 绕过网关访问到公开端口的客户端,可以自己发送一个
CoSec-Identity,冒充任何人。部署清单里的网络策略只让网关的 Pod 访问它。 - 网关覆盖服务信任的请求头。 它根据自己接受的连接设置转发头
X-Forwarded-For和Forwarded,而不是把客户端发来的传下去或在后面追加;它还去掉客户端发来的CoSec-Identity,只转发它自己校验过的身份,CoSec 网关在每条路由上都这样做,与配置无关。否则客户端可以自己选择地址或身份。
宿主看不到网络,所以启动时不检查这两条。
再次发送的命令,只有带着同一个请求 ID 才是同一条命令。客户端在丢失应答后重发命令时,要带上同一个 Wow-Request-Id,服务会把它当作重复请求应答,而不会执行两次。使用 CoSec 时,没有 Wow-Request-Id 的命令取请求的 CoSec-Request-Id 作为请求 ID。CoSec 网关为它转发的每个请求设置一次这个头,请求的重试(例如 Spring Cloud Gateway 在连接重置后的重试)会再次发送同一个值,所以服务把重试当作重复请求应答。请求同时带有两者时,以 Wow-Request-Id 为准。
以网关模式运行服务
在 CoSec 网关之后,服务不必再校验一遍令牌,也不必持有 CoSec 的文档。wow.cosec.mode: gateway 为此设置 CoSec 的默认值:cosec.enabled=true、cosec.pipeline.mode=gateway 和 cosec.jwt.enabled=false。应用自己设置的 cosec.* 键仍然优先。
wow:
cosec:
mode: gateway网关认证并授权每个请求,把它校验过的内容放在 CoSec-Identity 中转发:主体、主体的租户,以及作用域的应用、空间和角色。服务从中读取调用方。它不持有 CoSec 的签名密钥、文档或 Redis,也没有 AuthorizationChecker:在网关的决定之外,调用方还能做什么,由服务自己的授权器决定。wow.cosec.mode: disabled 关闭 CoSec(cosec.enabled=false),用于不带网关运行的服务。eShop 的服务和 Wow 的平台服务(ops 服务、视图存储和分析目录)在 cosec profile 下以网关模式运行。
只有网关能访问网关模式的服务。 它相信收到的任何 CoSec-Identity:其他能给它发请求的人,声称是谁就是谁。要在网络上保证这一点:像部署清单那样,用只放行网关 Pod 的网络策略。不带这个请求头的请求是匿名的,解析器以 401 回答;CoSec 以 400 回答无法读取的请求头,以 401 回答早于 cosec.pipeline.gateway.max-age(60 秒)的请求头。详情见 CoSec 的指南:网关之后的服务、信任边界。
配置 CoSec Gateway
CoSec Gateway(cosec-gateway-server,镜像 ghcr.io/ahoo-wang/cosec-gateway)基于 Spring Cloud Gateway,Wow 的端到端测试在 eShop 和平台服务之前运行的就是它,使用下面的设置。它们是上面两条要求在这个网关上的写法:
- 它是边缘。
server.forward-headers-strategy: none:它从不把客户端的Forwarded或X-Forwarded-For当作自己的远端地址,而 Spring Boot 在 Kubernetes 上的默认做法会这样做。 - 它只写一次客户端地址。 从 Spring Cloud Gateway 4.1 起,X-Forwarded 头过滤器只为
trusted-proxies匹配的对端写入,所以只设for-append: false不起作用。trusted-proxies: ".*"加上for-append: false,把它接受的对端写成唯一的X-Forwarded-For,替换客户端带来的值;forwarded.enabled: false不发送服务会优先采用的Forwarded头。 - 它重试丢失的连接,从不重试回答。
Retry过滤器在IOException或TimeoutException时把请求重发一次,POST也一样:重试带着同一个CoSec-Request-Id,或客户端的Wow-Request-Id,所以服务把它当作重复请求回答。series: INFORMATIONAL不匹配任何最终状态,所以不会重试任何响应。连接池中的连接最多空闲 15 秒,所以很少会复用到已经消失的 Pod 的连接。 - 它按自己 Redis 中的 CoSec 文档做决定。 网关从 Redis 读取 CoSec 8 的文档(schema 2:平台文档,以及每个应用、每个租户各一份),这个 Redis 必须以
maxmemory-policy noeviction运行:网关在启动时检查它,不满足就不启动。在流量到达之前用cosec-migrate publish --to-redis发布文档:平台文档存在之前,网关对每个路由的请求都回答 503,并报告自己未就绪。没有任何 Wow 服务连接这个 Redis。 - 它只把身份转发给声明了的路由。 只有指向已注册服务的路由(
lb://),或元数据写了cosec-identity: true的路由,才会收到CoSec-Identity。下面的路由指向 Kubernetes Service 的 URL,所以每条都带这个元数据;缺了它,路由后面的服务读不到调用方,以 401 回答。不要把它加到指向第三方的路由上。 - 它不转发凭据。 默认情况下,它会从转发的每个请求中去掉
Authorization请求头、API Key 请求头、authorization查询参数和authorizationCookie。网关模式的服务不读取其中任何一个。 - 它的应用端口上只暴露 Actuator 的
health。 按文档做决定的网关,只要在应用端口上通过 HTTP 暴露了health以外的 Actuator 端点,就不会启动。要么像这里一样只暴露health,要么用management.server.port把 Actuator 放到单独的端口上,并用网络策略保护。
server:
port: 8080
# The gateway is the edge: it never takes a client's Forwarded or X-Forwarded-For as its remote address. Spring
# Boot defaults to the native strategy on Kubernetes, which would (run 7 recorded the claimed 203.0.113.9).
forward-headers-strategy: none
spring:
data:
redis:
host: redis
port: 6379
cloud:
gateway:
server:
webflux:
# The X-Forwarded headers filter writes X-Forwarded-* only for a peer trusted-proxies matches (Spring Cloud
# Gateway 4.1+); unset, it writes none and the service sees the gateway's own address. Every peer matches:
# the gateway is the edge, its peer is the client (server.forward-headers-strategy: none above), and
# for-append: false writes that peer as the one X-Forwarded-For, whatever the client sent.
trusted-proxies: ".*"
x-forwarded:
for-append: false
# Only X-Forwarded-For carries the client's address on: a Forwarded header the services would prefer over
# it is not sent.
forwarded:
enabled: false
# Connections to a service's pods idle at most 15 s: a pod that died stays in the endpoints a little
# while, and a pooled connection to it answers a reset; shorter than that, a stale one is rare.
httpclient:
pool:
max-idle-time: 15s
# Every route retries a request once when its connection fails (reset, refused, timed out), POST
# included: Wow deduplicates a command by its request id, the client's Wow-Request-Id or else the
# CoSec-Request-Id the gateway sets once per exchange, and a retry carries the same one
# (the "a resent request keeps its request id" scenario, through the flaky upstream below). A response is
# an answer and never retried (series INFORMATIONAL: no final status matches).
default-filters:
- name: Retry
args:
retries: 1
methods: GET,POST,PUT,PATCH,DELETE
series: INFORMATIONAL
exceptions: java.io.IOException,java.util.concurrent.TimeoutException每条路由把一个上下文的路径前缀发往它的服务:
# Each service by its context prefix. A route takes the CoSec-Identity of a permitted request only when
# it opts in (metadata cosec-identity: true) or targets lb://: these target a Kubernetes Service's URL.
routes:
- id: eshop-catalog
uri: http://eshop-catalog-service
predicates: [Path=/eshop-catalog/**]
metadata: { cosec-identity: true }
- id: eshop-basket
uri: http://eshop-basket-service
predicates: [Path=/eshop-basket/**]
metadata: { cosec-identity: true }网关用 CoSec 的签名密钥(cosec.jwt.secret)校验每个请求的令牌,按文档决定请求,并转发身份。它的设置和文档的格式属于 CoSec:部署网关服务、第一批文档、配置。网关上粗粒度的文档,例如让任何已认证主体通过的文档,把每个调用方能做什么留给服务自己的授权器决定。
网关的 Pod 带有 app.kubernetes.io/component: gateway 标签,也就是服务公开端口的网络策略所放行的标签。
关上内部端口
内部端口承载平台的节点和服务之间转发的命令、等待信号、同步调用和读。平台内部是受信环境:内部端口不用令牌,它的边界是网络。谁能发信号,谁就能伪造任意命令的结果,所以:
- 把它留在平台之内。 不能有公网负载均衡、Ingress 或端口映射指向它,并用网络策略只让平台自己的 Pod 访问它。Kafka 和存储也一样。
- 用
wow.internal.host把它绑定到内网网卡。 - 每个请求都声明自己的服务。 节点或服务在每次转发、信号、调用和读的
Wow-Service请求头里放上自己的服务名,即wow.service,没有时为spring.application.name。服务名由字母、数字、.、_和-组成,以字母或数字开头,最多 253 个字符;服务名不符合的节点不会启动。这个请求头是声明,不是凭据:它能发现配置错误,例如某个服务的列表不允许的调用,或者发到另一个服务节点上的转发。 - 每条路由只接受它服务的那些服务。 转发,以及待机和激活路由(
GET /wow/node/standby、POST /wow/node/activate),只接受同一个服务的节点:没有这个请求头时返回 401wow.auth.unauthenticated,来自另一个服务时返回 403wow.auth.forbidden。等待信号接受任何声明了自己的服务,没有请求头时返回 401。同步调用(Wow-Call)或读接受任何声明了自己的服务,并以该服务的身份(主体service:<name>)处理;没有声明服务的请求返回 403。公开端口从不读取Wow-Service,并以 403 拒绝Wow-Call。 - 在有帮助的地方,列出每个服务可以调用什么。 在
wow.callers.<service>下,以调用方声明的名字为键,commands列出该服务可以调用的命令,reads列出它可以读的模型。两者都是可选的:没有commands时,服务可以调用所有领域命令;没有reads时,可以读所有模型。系统命令(wow.*,例如删除、恢复、重建快照)不列出就永远不能调用。这些列表用来发现错误,挡不住谎报名字的 Pod。
eShop 的 ordering 服务把这些组合在一起。它的 basket 服务同步调用一条命令,别的都不调用;它的 inventory 和 payment 服务为跨进它们的等待发信号,这不需要配置项:
wow:
# One node, its data in memory, for trying the service out. The `mongo` profile serves it from MongoDB, the `postgres`
# profile from PostgreSQL; the `kafka` profile connects it to the other contexts; without it, the node on this machine
# keeps its topics in its JVM (wow.transport), where the inventory does not see the commands its sagas send.
internal:
port: 9093
# The basket service's checkout calls this command gateway synchronously, on the internal port
# alone, naming itself (Wow-Service): the internal port is reachable by the shop's own pods alone,
# so a service that names itself is taken on that network's word, and may name the buyer the order is for.
callers:
eshop-basket-service:
# The commands the basket may call, a list that catches a misconfiguration: its checkout
# submits the order, and nothing else. A system command (wow.*: delete, restore, transfers) is never
# called unless listed here.
commands:
- eshop-ordering.order.SubmitOrder它的 Kubernetes 网络策略(由 Wow 的 chart 渲染)就是边界。第一条只让网关的 Pod 访问 HTTP 端口;第二条只让本服务的节点、eShop 的其他 Pod 及其对端访问内部端口:
{{- include "wow-service.validate" . -}}
{{- if .Values.networkPolicy.enabled }}
{{- $name := include "wow-service.name" . -}}
# The public port: reachable from networkPolicy.public.from alone, the gateway's pods by default (labelled
# app.kubernetes.io/component: gateway, in any namespace), which verify the callers. Enforced where the cluster's
# network plugin enforces NetworkPolicy.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ $name }}-public
labels:
{{- include "wow-service.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "wow-service.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
ingress:
- from:
{{- toYaml .Values.networkPolicy.public.from | nindent 8 }}
ports:
- port: http
protocol: TCP
{{- if and (include "wow-service.metricsEnabled" .) (eq (include "wow-service.metricsPort" .) "metrics") }}
- from:
{{- toYaml .Values.networkPolicy.metrics.from | nindent 8 }}
ports:
- port: metrics
protocol: TCP
{{- end }}
---
# The internal port: the cluster channel, the wait signals and the calls of other services. It takes no token: a node
# names its service on each request (Wow-Service), and this policy is the boundary. It admits the service's own nodes,
# the pods of its platform (partOf), the peers it calls (their wait signals come back to it), in their namespaces, and
# networkPolicy.internal.from. Where the network plugin does not enforce NetworkPolicy, keep the internal port off every
# network but the platform's.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ $name }}-internal
labels:
{{- include "wow-service.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "wow-service.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
{{- include "wow-service.selectorLabels" . | nindent 14 }}
{{- with .Values.partOf }}
- podSelector:
matchLabels:
app.kubernetes.io/part-of: {{ . }}
{{- end }}
{{- range .Values.service.peers }}
{{- if .service }}
- podSelector:
matchLabels:
app.kubernetes.io/name: {{ .service }}
{{- if and .namespace (ne .namespace $.Release.Namespace) }}
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ .namespace }}
{{- end }}
{{- end }}
{{- end }}
{{- with .Values.networkPolicy.internal.from }}
{{- toYaml . | nindent 8 }}
{{- end }}
ports:
- port: internal
protocol: TCP
{{- end }}不执行网络策略的网络插件会忽略它们。这时要用别的办法,例如节点的防火墙,让内部端口只在平台的网络上可达,因为没有别的东西关得上它。
加密。 内部端口按设计使用明文 HTTP/2(h2c)。平台内的流量必须加密时,把服务放进带双向 TLS 的服务网格,例如 Istio 或 Linkerd。Wow 在内部端口上没有原生 TLS,也没有这个计划。
限制命令主题
Kafka 也在平台之内,而命令主题是一个入口:wow.<context>.commands 上的记录不经准入就会执行,所以谁能写它,谁就能指定任意操作人、租户和作用域。记录不签名。用 Kafka 自己的认证和 ACL,让只有给这个上下文发命令的服务能写它的命令主题:它自己的节点、Saga 和定时器给它发命令的上下文,以及发送运维人员操作的 ops。对 eShop 的 ordering 上下文,payment 服务会给它发命令:
kafka-acls.sh --bootstrap-server kafka:9092 --add --allow-principal User:ordering --operation Write --topic wow.eshop-ordering.commands
kafka-acls.sh --bootstrap-server kafka:9092 --add --allow-principal User:payment --operation Write --topic wow.eshop-ordering.commands
kafka-acls.sh --bootstrap-server kafka:9092 --add --allow-principal User:ops --operation Write --topic wow.eshop-ordering.commands其他主体在这个主题上都没有 Write ACL。启用授权器并且 allow.everyone.if.no.acl.found=false(代理的默认值)时,没有 ACL 的主体会被拒绝。Kafka 列出了每个上下文拥有的主题以及谁写它们。
记录还说明命令是否由提交派生(causation):写上它就是声称这是服务端派生的命令,像 Saga 的命令一样受信任。不是派生的,记录说明是谁发送的(caller),目标的访问策略比较其中的声明。能写这个主题的一方写这两者和写操作者一样容易,所以主题的 ACL 是唯一的边界。在访问策略下,两者都没有的记录被拒绝。调用方的声明在主题保留期内留在主题的记录里(主题上的命令)。
写错的设置
写错的安全设置会悄悄保留默认值,而有些默认值允许一切。所以任何 wow.* 键未知时,宿主都拒绝启动;wow.callers 和 wow.peers 下每一项的键也会检查。
事件中的个人数据
事件是事实。日志永久保存它们,Wow 既不提供删除事件,也不提供加密粉碎(crypto-shredding)。在模型里要为此做好打算:
- 尽可能不让个人数据进入事件,例如姓名、地址、电话号码和关于某个人的自由文本。把它们保存在可以删除的读模型里,例如投影的表或另一个服务,并在事件里放一个不透明的 ID。
- 决定函数需要个人数据时,让它通过领域服务读取,而不是把数据带在事件里。
- 提交的操作人记录为不透明的 ID。每次提交都会记录请求事实:已认证的应用,以及对公开端口上的请求,客户端的远程地址和 User-Agent,即
remote_ip和user_agent。IP 地址和 User-Agent 属于个人数据,一旦写入就无法删除。把事件日志以及显示这些事实的服务日志都当作含有个人数据的数据来对待,并据此设置日志的保留期。wow.http.client-facts.enabled(默认true)可以关闭地址和 User-Agent 的记录:设为false时,公开端口两者都不记录。不要再往wow.http.request-facts里加其他个人数据。 - 快照、数仓和检索索引都从日志派生:在那里删除一行,并不会删除事实。
租户
一个系统的所有服务共用一个租户空间:一个租户 ID 在每个服务里都指同一个客户,并原样跨服务传递。在每个服务里都以同样的方式解析调用方的租户。可以给聚合类型指定一个固定租户 wow.aggregates.[<context>.<aggregate>].tenant,无论调用方是谁,它的所有命令和读都落在那个租户里。