可观测性
面向需要知道 CoSec 决定了什么、为什么、以及谁改了规则的运维和安全团队。本页介绍观察每个请求的决策监听器,它们产生的 审计事件、span 和指标,如何查看实例是否跟上了文档,控制面的变更日志,以及日志。要看到一个决策检查过的每条语句(而 不只是做出决定的那条),通过控制面对它做 explain(解释一个决策)。
决策监听器
经过管道的每个请求,无论结果如何,最后都产生一个决策事件:放行、拒绝、429、400、503、缺陷(500),或者调用方 已离开。决策监听器接收这些事件;审计日志、Kafka、指标、追踪和影子窗口都是监听器。监听器只做观察:它不能改变决策,唯一 的例外是失败的 REQUIRED 监听器会把放行变成 503。
| 投递方式 | 监听器何时运行 | 失败的后果 |
|---|---|---|
REQUIRED | 在响应之前,与其他 REQUIRED 监听器一起运行,每个都在 cosec.observability.listener.required-timeout(1 秒)之内,并且在 cosec.pipeline.timeout 剩余的时间之内。它看到的是到此为止决定的结果。 | 放行变成 503 unavailable,请求不会继续。其他结果保持原状态;失败以 ERROR 记录并计数。 |
BEST_EFFORT(默认) | 结果确定之后,在请求路径之外运行。每个监听器有自己容量为 cosec.observability.listener.queue-capacity(1,024)个事件的队列和自己的消费者,所以慢的监听器绝不会让别的监听器丢事件。它看到的是最终结果。 | 对请求没有影响。队列满时丢弃事件;抛异常的监听器最多每 10 秒以 WARN 记录一次,两者都会计数。 |
starter 注册的监听器:
监听器(name) | 投递方式 | 存在条件 |
|---|---|---|
LoggingAuditListener(logging-audit) | BEST_EFFORT | cosec.audit.enabled(默认 true) |
KafkaAuditListener(kafka-audit) | cosec.audit.kafka.delivery,默认 BEST_EFFORT | cosec-kafka、一个 KafkaOperations bean、cosec.audit.enabled 和 cosec.audit.kafka.enabled(都默认 true) |
MetricsListener(metrics) | BEST_EFFORT | cosec-micrometer、一个 MeterRegistry bean(包括 Spring Boot 自动配置的那个)和 cosec.metrics.enabled(默认 true) |
TracingListener(tracing) | BEST_EFFORT | cosec-opentelemetry |
ShadowListener(shadow) | BEST_EFFORT | cosec-cocache 和 cosec.authorization.shadow.enabled(默认 true) |
你自己的任何 DecisionListener bean 都会加入它们;名字必须唯一,否则启动失败(扩展)。 AuditEventSink bean 也会加入:starter 把它包装成以 bean 名命名的 BEST_EFFORT 监听器 (AuditListener.of(sink, beanName)),按它的 @Order 排在监听器之间。它总是 BEST_EFFORT,因为 AuditEventSink.publish 无法确认写入。同时是两者的 bean 只注册一次,作为它本身的 DecisionListener。
- break-glass 需要
REQUIRED监听器。 通过 break-glass 绑定放行的请求是503,除非至少注册了一个REQUIRED监听器并且每一个都记录了它。开箱时没有任何监听器是REQUIRED,所以 starter 在启动时记录No REQUIRED decision listener is registered: break-glass bindings cannot permit (RFC 0006 §9.4.3).依赖 break-glass 之前,设置cosec.audit.kafka.delivery=required,或者注册你自己的REQUIRED监听器。 REQUIRED监听器应按事件的id保持幂等:超时的写入可能其实已经成功。- 关闭时,监听器停止接收事件,已在队列中的事件仍会继续投递,最多
cosec.observability.listener.drain-timeout(5 秒)。之后剩下的被丢弃,并以WARN记录Decision listeners did not deliver every queued event within <timeout>; the rest is dropped.。随后 Kafka 审计监听器 最多再用同样长的时间,等待它手中的发送以及 broker 对这些发送的应答,然后才关闭 producer。到达这个上限时,排队的发送 被丢弃,进行中的发送被中断,并以WARN记录The Kafka audit listener of [<topic>] did not finish within <timeout>: ... - 求值追踪。
DecisionEvent带有evaluationTrace,即引擎查看过的内容,深度由cosec.pipeline.trace-level决定:off(默认,没有追踪,也没有开销)、full(决策到达的每一层的每条规则,包括决定性规则之后的规则)或exhaustive(还包括决策没有到达的层)。在off时,以及 请求没有到达引擎时,它为 null。只有你自己的监听器会读它:下面的审计事件、span 和指标都不包含它。 - gateway 模式的服务(
cosec.pipeline.mode=gateway)不产生决策事件:它们不做决策,也不注册监听器。由前面的网关 记录决策(部署网关服务)。
审计事件
审计监听器把每个决策事件映射为一个 AuditEvent。它保留响应体省略的内容:显式拒绝和隐式拒绝的区别、凭证被拒绝的 原因,以及请求为什么是 503。
| 字段 | 内容 |
|---|---|
eventId、timestamp、tenantId | 事件本身;tenantId 是主体的租户 |
kind | DECIDED、BAD_REQUEST(400)、AUTHENTICATION_UNAVAILABLE、CANCELLED 或 ERROR(500) |
principal | id、authenticated、roles(解析出的作用域中的角色) |
request | id(即 CoSec-Request-Id)、appId、spaceId、remoteIp、method、path(规范化后)、device.id |
authorization.decision、authorization.reasonCode | 见下表 |
authorization.reason | 结果,503 时还有其原因,例如 INDETERMINATE: SourceUnavailable |
authorization.elapsedNanos | 整个管道,从请求开始到得出结果 |
authorization.source | 是什么做出了决定:via、document、policy(策略 id)和 statement(index、name);见下面第二张表 |
binding | 做出决定的语句通过什么生效:绑定 id(角色派生的绑定为 role:<roleId>@<app>:<permission>)、attached:<policyId>、builtin(黑名单)或 boundary:<tenantId> |
snapshotVersion | 决策读取的修订:platform,以及读取了时的 app 和 tenant |
breakGlass | 放行是否来自 break-glass 绑定 |
trace | 追踪生效时请求 span 的 traceId 和 spanId |
| 结果 | decision | reasonCode |
|---|---|---|
| 放行 | ALLOW | ALLOW |
429 | TOO_MANY_REQUESTS | TOO_MANY_REQUESTS |
| 匹配到 DENY | EXPLICIT_DENY | EXPLICIT_DENY |
| 作用域被拒绝 | EXPLICIT_DENY | SCOPE_REJECTED |
| 超出租户边界的放行 | EXPLICIT_DENY | BOUNDARY |
| 没有任何允许 | IMPLICIT_DENY | IMPLICIT_DENY |
| 凭证被拒绝的请求的任何拒绝 | 同上 | TOKEN_EXPIRED、TOKEN_REVOKED 或 TOKEN_INVALID(API key 也是);带 act 声明的令牌为 DELEGATION_UNSUPPORTED,响应报告为 token-invalid。主体仍是匿名主体;已知时 authorization.reason 指明被拒绝的是谁的凭证(不能用的 key、成员关系提供者不认识或认为已停用的主体),与决策事件的 subject.refused 一致 |
400 | EXPLICIT_DENY | EXPLICIT_DENY(kind 为 BAD_REQUEST) |
503,严格的限流规则无法计数 | INDETERMINATE | RATE_LIMITER_UNAVAILABLE |
其他任何 503,以及被取消的请求 | INDETERMINATE | INDETERMINATE |
500 | ERROR | ERROR |
authorization.source.via 说明做出决定的内容如何生效,document 说明它在哪个快照文档中:
via | document | 含义 |
|---|---|---|
BINDING | PLATFORM、APP 或 TENANT | 该文档中的绑定,包括角色派生的绑定;binding 是它的 id。break-glass 放行是 cosec:root 的绑定,并带 breakGlass: true |
ATTACHMENT | null | 通过主体的成员关系附加的策略 |
BUILTIN | null | 内置黑名单 |
BOUNDARY | PLATFORM | 租户的平台边界拒绝了一次放行。policy 是该边界,statement 为 null,因为它没有语句匹配 |
NONE | null | 没有语句做出决定:没有任何允许、作用域被拒绝,或没有得出决策。policy 和 statement 为 null |
审计日志与 Kafka
审计日志监听器把每个事件写成一行 JSON,写到 logger me.ahoo.cosec.audit:ALLOW 用 DEBUG,拒绝、 TOO_MANY_REQUESTS 和 INDETERMINATE 用 WARN,ERROR 用 ERROR。在默认的 INFO 级别下,只出现拒绝、503 和 错误。
Kafka 审计监听器把同样的 JSON 写到 cosec.audit.kafka.topic(cosec-audit),key 是 JSON 数组 [tenantId, principalId]。它与审计日志并存,而不是替代它;要改变这一点,设置 cosec.audit.enabled=false 并只注册你想要的。它从自己的线程发送,最多有 1,024 个发送在等待:
best-effort:发送失败记录为Failed to publish audit event to Kafka topic [<topic>].required:请求等待 broker 的确认。发送失败或超时,或发送队列已满(The Kafka audit queue of [<topic>] is full.), 都会使放行变成503。生产者请配置acks=all。
追踪
有 cosec-opentelemetry 时,每个决策事件变成一个名为 cosec.authorize 的 span(instrumentation me.ahoo.cosec, kind 为 INTERNAL)。span 在决策之后根据请求的开始时间和管道的耗时生成,挂在适配器收到请求时的当前 span 之下;没有 当前 span 时它是根 span。因此追踪从不拖慢请求,也不会使请求失败。管道为请求发起的调用(Redis、成员关系提供者)是 cosec.authorize 的兄弟 span,挂在同一个父 span 下。tracer 来自 GlobalOpenTelemetry。
| 属性 | 值 |
|---|---|
cosec.tenant_id、user.id、cosec.request_id | 主体的租户和 id,以及请求 id |
cosec.app_id、cosec.space_id、device.id | 解析出的作用域和设备,非空时才有 |
cosec.authorize.result | PERMIT、TOO_MANY_REQUESTS、EXPLICIT_DENY、IMPLICIT_DENY、SCOPE_REJECTED、BOUNDARY、INDETERMINATE、AUTHENTICATOR_UNAVAILABLE、DEADLINE_EXCEEDED、INVALID_REQUEST、CANCELLED 或 ERROR |
cosec.authorize.policy.id、cosec.authorize.statement.index、cosec.authorize.statement.name | 做出决定的语句 |
cosec.binding.id | 语句通过绑定生效时的绑定 |
cosec.snapshot.version | 读取的修订,格式为 <platform>/<app>/<tenant>,未读取的文档写 - |
500 会把 span 的状态设为 ERROR。审计事件和 problem 响应体带有请求 span 的 trace id,把一个响应和它的追踪联系起来。
指标
有 cosec-micrometer 和一个 MeterRegistry(例如来自 Spring Boot Actuator)时,每个决策事件都由计时器 cosec.authorization 记录,覆盖整个管道。每个标签的取值都是一个固定的小集合,因此无论有多少策略、应用和租户,时间序列 的数量都有界:
| 标签 | 取值 |
|---|---|
result | allow、too_many_requests、explicit_deny(包括作用域拒绝和边界拒绝)、implicit_deny、indeterminate(每个 503)、bad_request、cancelled、error |
source | binding、attachment、builtin、boundary、scope(作用域拒绝)、none |
cause | 仅用于 indeterminate:source_unavailable、missing_policies、membership、attribute、matcher、authenticator、required_listener、boundary、rate_limiter、deadline、unknown |
监听器本身也被计数:
| 指标 | 标签 | 计数内容 |
|---|---|---|
cosec.listener.dropped | listener | 队列满时丢弃的事件 |
cosec.listener.failures | listener | 抛异常的监听器调用,以及超时的 REQUIRED 监听器调用 |
cosec.audit.required.failures | 无 | 发生在它无法改变的决策(非放行)上的 REQUIRED 失败 |
按 cause 对 result=indeterminate 的速率、对 explicit_deny 以及对 cosec.listener.dropped 设置告警。 indeterminate 伴随 cause=source_unavailable 上升,说明文档读不到(故障排查)。 CoCache 会在同一个 registry 中为 CoSec 的缓存记录它自己的指标(CoCache 文档)。
快照健康状况
CoSec 不为实例持有的文档注册指标。要查看实例是否跟上了文档:
- 健康。 有 Spring Boot Actuator 时,
cosecSnapshot健康指示器在平台文档和每个预热文档安装后为UP,之前为DOWN,细节为platform(installed、failed、absent或loading)和prewarmed。就绪状态遵循同样的条件 (就绪)。 - 驱逐。 在 Redis 上,Redis 可能驱逐快照时
cosecSnapshotEviction健康指示器为DOWN,并带说明原因的eviction细节。它报告实例最近一次读到的结果:每个revalidate-interval在自己的线程上读取的每个节点的maxmemory-policy, 探针从不读取。读不到策略时,eviction-check: fail下为DOWN,warn下为UP,详情eviction: unknown。它是告警, 不是就绪信号:不要把它放进就绪组,否则 Redis 的一次改动会让所有实例同时退出轮转,而请求仍在照常决策 (Redis 要求)。与每个指示器一样,它也会让聚合的/actuator/health变为DOWN:让负载均衡器 和平台的健康检查探测就绪组,或者把它排除在它们探测的组之外(management.endpoint.health.group.<group>.exclude=cosecSnapshotEviction)。 - 滞后日志。 文档超过
lag-warn未被 Redis 确认时,实例以WARN记录,超过lag-escalate时以ERROR记录 (实例如何读取文档)。其他值得告警的日志:Snapshot document [<doc>] disappeared from the store; failing closed.、Snapshot document [<doc>] regressed from revision <a> to <b>; ...,以及Snapshot prewarm failed for [<docs>]; retrying in <backoff>. - 传播情况。 控制面的
GET /documents/<doc>/revisions/<n>/propagation回答最近一分钟内报告过的实例中,有多少已经 装入修订<n>或更新的修订。实例只有在能够评估某个修订时才算装入了它:读到了该修订却编译失败的实例仍会报告,所以计入被统计的实例, 但不算已装入。只有开启影子窗口的实例才会报告(修订、历史与回滚)。 - 每个决策。 审计事件中的
snapshotVersion和 span 上的cosec.snapshot.version指明决策读取的修订。
控制面变更日志
控制面为变更的每一步发出一个变更事件:PROPOSED、CHECKED、SHADOW_STARTED、SHADOWED、APPROVED、REJECTED、 PUBLISHING、PUBLISHED、FAILED、BREAK_GLASS_REQUESTED、BREAK_GLASS_APPROVED、REVOKED、HEADS_MOVED、REVIEW_NEEDED、ABANDONED。事件指明变更、文档、 操作者、基准修订和候选修订、需要审批的内容、确认、影子窗口摘要和备注;它从不携带文档内容。变更监听器接收这些事件。
- 默认监听器以
INFO记录每个事件,格式为Change <changeId> <kind> on <doc> by <principal>@<tenant>: base=..., candidate=..., escalations=<n>, confirmations=<n>。 变更监听器是累加的:每个ChangeListenerbean 都会注册,只有应用自己声明了LoggingChangeListenerbean 时才不再 注册默认的日志监听器。 PUBLISHING、BREAK_GLASS_REQUESTED和BREAK_GLASS_APPROVED是受控的: 每个REQUIRED变更监听器都必须在cosec.control.audit.required-timeout(5 秒,所有监听器合计)内记录该事件,这一步才会生效,否则这一步不会发生,控制 API 返回503。其他事件,以及发给BEST_EFFORT监听器的 所有事件,都在之后投递;失败记录为Change listener [<name>] could not record <event>.修订记录是已发布内容的持久 记录。受控事件说明这一步即将发生,而不是已经发生:它之后的检查仍可能拒绝这一步(变更在此期间变化了时返回409),BREAK_GLASS_APPROVED或ABANDONED之后都可能如此。发生了什么以变更的状态为准,发布则另以修订历史为准。- 备注是自由文本,其中一部分出自变更的作者(提议、申请或撤销的原因):某一步是谁做的,看事件的
operator,不要看备注。 - break-glass 需要
REQUIRED变更监听器: 没有时,创建或修改 break-glass 绑定得到503(break-glass needs a REQUIRED change listener, and none is configured)。 ABANDONED指明是谁放弃了变更,并在备注中写明变更的作者(author <tenant>/<principal>: <note>)。放弃 break-glass 变更像PUBLISHING一样受控:它需要一个REQUIRED变更监听器(没有时返回503),由它在变更存为已放弃之前 记下。放弃其他变更在之后记录。- 再次撤销而发布的停住的撤销,其
REVOKED指明再次撤销的人,备注加上其原因 (<first reason>; published again by <tenant>/<principal>: <reason>),它的PUBLISHING也是如此。 PUBLISHED之后可能跟着HEADS_MOVED或REVIEW_NEEDED: 变更检查时读过的其他文档在它写入之前变化了 (一次发布比较什么)。事件列出变化的头(moved:文档,app 列表记为list:apps; 检查时的摘要、现在的摘要、能否读到),带上授出分析在当前的头上发现的升级项,并在备注中说明含义。HEADS_MOVED无需处理;REVIEW_NEEDED表示分析发现了发布时未经审批的升级项、分析无法运行,或比较没有按时完成(这时moved为空,唯一的升级项原因为DETECTION_TIMED_OUT):应当有人复核 这次发布,控制面还会以WARN记录Change <changeId> published on [<doc>]: <note>; moved [...].。发布的客户端断开连接时 它们也会被记录。它们和所有不受控的事件一样尽力投递:发布已经发生。- 事件中的升级项是
subject、reason与detail(测试变更)。TENANT_BINDING项只写出绑定和 它所在的租户文档(binding <id> of [tenant:<t>]),策略与语句在detail中;绑定绑定了谁、在哪个空间,属于该租户,不写出。 它仅供参考,从不算作需要审批的内容。
有 cosec-kafka 和 KafkaOperations bean 时,starter 还会注册一个 KafkaChangeListener,除非 cosec.control.audit.kafka.enabled 为 false。它把每个事件写到 cosec.control.audit.kafka.topic(cosec-control-audit),key 是文档(platform、app:order、tenant:acme),因此 一个文档的事件保持有序。它的投递方式是 cosec.control.audit.kafka.delivery:默认 best-effort;required 让发布和 break-glass 等待 broker 的确认(把生产者配置为 acks=all)。设为 delivery=required 时,如果 Kafka 变更监听器无法 创建(没有 cosec-kafka、没有 KafkaOperations bean,或 enabled=false),启动失败,而不是在没有记录的情况下发布: cosec.control.audit.kafka.delivery=required, but no Kafka change listener was created: ...。
日志
| Logger | 记录内容 |
|---|---|
me.ahoo.cosec.audit | 审计日志(审计日志与 Kafka) |
me.ahoo.cosec.runtime | 每个 500 都以 ERROR 记录 Request [<requestId>] failed with an unmodelled exception. 及堆栈;REQUIRED 监听器失败;被丢弃的事件;失败的属性提供者;无法计数的限流器 |
me.ahoo.cosec.runtime.snapshot | 加载、滞后、丢失和回退的文档(快照健康状况) |
me.ahoo.cosec.spring.boot.starter | 启动检查,例如 !!! 驱逐警告;应用没有声明 BlacklistSource 时以 INFO 记录的 No BlacklistSource bean: requests are not checked against a blacklist (ext.blacklist.*).(黑名单);gateway 模式服务的 WARN(网关后面的服务) |
me.ahoo.cosec.control | 变更日志,以及每个控制面 500 的 Control API <method> <path> failed (request <requestId>). |
通过响应头 CoSec-Request-Id 把一个响应与它的日志、审计事件和 span 关联起来;它也是每个 problem 响应体中的 requestId。
下一步: 从症状找到原因,见故障排查。