# Webhook 审计轨迹：端到端追踪代理工作流

启动异步工作流的代理操作有两段历史：它发出的请求，以及后来返回的事件。团队通常会把第一段记录得足够好，可以回答“代理调用 API 了吗？”也会把第二段记录得足够好，可以回答“我们的接收端收到 webhook 了吗？”但到了故障发生时，他们才发现没人能证明这两条记录描述的是同一项工作。

Webhook 审计轨迹必须连接意图、授权、出站投递、远程确认、传入接收、核验，以及你最终决定接受的业务状态。把 200 响应当作记录终点，正是付款、部署、工单或权限变更在三天后变得无法解释的原因。

## Webhook 审计轨迹记录两类不同事实

Webhook 审计轨迹应当保留操作请求与事件通知之间的区别，因为它们回答的是不同问题。出站部分说明代理要求远程服务做什么。入站部分说明某个发送方后来声称发生了什么。

这些事实可能属于同一个工作流，但失败方式不同。代理可能提交命令、失去连接，然后再次提交。远程服务可能接受命令，几分钟后才执行，并发送两份完全相同的回调。接收端可能核验了第一份回调，却在提交结果状态前发生故障。普通应用日志中的一行记录无法解释这条链路。

我审查这类系统时会使用六种记录：

- 操作记录标识代理运行、人工授权、请求的操作和目标对象。
- 出站尝试记录标识每次 HTTP 传输，包括请求摘要和收到的响应。
- 远程引用记录保存提供方返回的标识符，例如任务 ID 或操作 ID。
- 接收记录在业务处理改变任何内容前，保存每次传入的 HTTP 投递。
- 核验记录准确说明接收端为什么接受、拒绝或隔离这次投递。
- 状态转换记录说明工作流处理已核验事件后发生了什么变化。

不要把尝试和操作合并。一项操作可能产生多次尝试。也不要把接收记录和事件合并。一个提供方事件可能多次到达你的端点。这个区别看起来有些琐碎，直到工程师需要解释第二次部署究竟来自代理重试、HTTP 客户端重试，还是提供方重新投递了同一个事件。

RFC 9110 对 POST 的定义有意保持宽泛：目标资源会按照自身语义处理该表示。因此，202 响应通常表示“已接受，稍后处理”，而 200 也只表示端点已经完成了请求处理。它不能证明远程业务结果。如果提供方另有操作状态端点或回调，后续证据才应当决定最终结果。

好的记录应让审查者无需根据时间戳猜测事实，就能按顺序读出完整故事：

```text
代理会话 sess_7c1e 授权了操作 act_01
act_01 创建出站尝试 out_01，并使用幂等引用 idem_44
远程服务接受 out_01，并返回操作 op_903
接收端接受提供方事件 evt_775 的投递 rcp_01
rcp_01 核验签名，并将 evt_775 关联到 op_903
工作流 wf_18 从 pending 变为 completed
```

这是一串断言，而不是一个状态字段。每条断言都需要自己的来源和时间。

## 关联需要多个标识符

单一关联 ID 无法解决 webhook 追踪问题，因为不同参与方会为不同范围创建 ID。应使用一组归属明确的标识符，并记录它们之间的关系。

先创建内部操作 ID。它应在任何网络调用前生成，并关联到代理会话、请求的操作、授权决定和不可变审计条目。这个 ID 回答的是：“哪条代理指令引发了这项工作？”客户端重试时不应改变它。

每次 HTTP 客户端传输时，都创建一个出站尝试 ID。它回答的是：“哪次线路传输产生了这个响应或错误？”远程 API 支持幂等时，还要加入幂等引用。幂等引用表示重复提交应映射到同一个逻辑远程操作，但它不能说明某次具体的 HTTP 尝试是否抵达服务器。

远程服务返回操作 ID 时，应立即将它与收到该 ID 的尝试一起保存。如果请求支持客户端引用或元数据字段，在确认提供方会在回调或状态响应中返回它之后，可以把操作 ID 放入该字段。不要把秘密、员工姓名或完整提示词放入引用字段。这些字段往往会出现在供应商控制台、支持工单和事件载荷中。

传入回调还会增加两个 ID：提供方事件 ID 和你的接收 ID。提供方事件 ID 支持针对该发送方去重。接收 ID 标识基础设施实际收到的那次 HTTP 投递，包括请求头、来源地址（如果你保留）、原始请求体摘要和核验结果。

关系表可以这样设计：

| 标识符 | 创建者 | 重试时保持稳定吗？ | 回答的问题 |
|---|---|---:|---|
| 操作 ID | 你的操作服务 | 是 | 哪个代理请求启动了这项工作？ |
| 尝试 ID | 你的 HTTP 客户端 | 否 | 哪次传输产生了这个结果？ |
| 幂等引用 | 你的操作服务 | 是 | 哪些提交代表同一个远程命令？ |
| 远程操作 ID | 提供方 | 通常是 | 哪个远程任务或对象发生了变化？ |
| 提供方事件 ID | 提供方 | 对同一事件是 | 哪个回调应当去重？ |
| 接收 ID | 你的接收端 | 否 | 我们收到了哪次投递？ |

即使提供方不发送 CloudEvents，CloudEvents 仍然很有用。它的规范将 `id`、`source`、`type`、`subject` 和 `time` 分开。这样可以避免一个常见错误：把事件 ID 当成工作流 ID。事件 ID 标识某个来源发出的一个事件，工作流 ID 标识你正在追踪的工作。它们可能指向同一个远程对象，但含义并不相同。

如果提供方只给出带对象 ID 的回调载荷，应谨慎建立关联。只有当对象 ID 来自记录过的出站响应，或来自经过身份验证的状态查询时，才能将关联标记为精确。通过邮箱地址、标题文字、金额或时间戳匹配，只是披着关联外衣的猜测。不要把它写入审计结论。

## 2xx 响应和回调回答的是不同问题

2xx 响应确认的是 HTTP 交互。经过核验的回调可能确认远程状态变化。工作流需要两者，并且必须诚实描述两者之间的空白。

假设代理要求托管构建服务发布一个构件。服务返回 202 和操作 ID。你的服务将请求记录为已接受，然后等待。十分钟后，回调表示发布失败，因为下游仓库拒绝了必要的清单。如果审计记录在收到 202 时就切换为“成功”，它就与提供方自己的证据发生矛盾。

使用能够说明现有证据的状态。例如：

1. `requested` 表示代理操作通过授权并创建了工作项。
2. `submitted` 表示至少一次出站尝试收到了接受响应，或者一个可恢复的模糊结果正在等待检查。
3. `confirmed` 表示经过核验的回调或身份验证状态响应确认了预期结果。
4. `failed` 表示权威证据确认失败。
5. `unknown` 表示你暂时无法确认远程端是否执行了操作。

`unknown` 状态是必要的。团队不喜欢它，因为它让仪表盘没那么好看。但相比之下，我更不喜欢静默重复。发送 POST 后发生超时，会造成投递状态不明：远程系统可能已经收到并处理，也可能从未看到请求。没有幂等机制就重试，可能创建两个远程操作。把第一次尝试称为“失败”，正好会诱发这种错误。

迟到的回调也不会自动拥有最终决定权。假设代理在初始命令之后请求取消，你的内部工作流记录了一次有效取消。随后到达的完成回调，可能反映的是取消生效前远程端发生的情况。保留它、核验它、建立关联，并记录冲突。不要因为某个枚举中“completed”的级别更高，就让通用处理程序覆盖已经结束的取消状态。

为每种回调类型写出状态转换规则。付款批准、构建完成、用户创建事件和删除确认不应使用同一套转换。规则应说明哪些先前状态允许这次转换、处理程序需要什么证据，以及是否需要操作员解决冲突。

## 接收端必须先保留证据，再解析内容

接收端应先捕获原始投递、核验并去重，然后才执行副作用。先解析 JSON，只保存选定字段，会在解析器、模式或应用代码后来被发现有问题时破坏证据。

在接收时，应在受保护的事件存储中记录：

- 接收 ID 和服务器接收时间戳。
- 请求方法、路由、选定的请求头，以及完全一致的原始请求体的加密摘要。
- 预期的发送方身份和采用的核验方案。
- 载荷提供的提供方事件 ID，以及解析出的事件类型。
- 决定：接受、重复、拒绝或隔离，并附带原因代码。

只在调查和合规需求足以证明必要时保留原始载荷，并设置相应保存期限。摘要通常足以证明两份载荷相同。如果保留请求体，应加密、限制访问，不要复制到普通应用日志中。Webhook 经常包含个人数据、仓库元数据、地址和内部备注。会泄露载荷的审计存储是负债，不是证据。

签名核验必须针对发送方实际签名的请求体进行。中间件如果先解析 JSON、重新格式化，再核验格式化后的字节，就会拒绝合法投递，或者更糟糕，造成处理不一致。仔细阅读提供方的核验文档。有些方案签名的是 `timestamp + "." + raw_body`，有些只签名原始请求体，还有些使用非对称签名和轮换的公钥。

对于只签名原始请求体的通用 HMAC 方案，下面的命令展示了未修改字节应产生的摘要形式：

```sh
printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"
# SHA2-256(stdin)= 4d3c...hex digest...
```

这只是诊断工具，不能替代提供方规定的确切规范字符串。如果提供方加入了时间戳或版本前缀，照搬这个通用命令会得到错误结果。此类错误很常见，因为工程师核验的是方便的近似值，而不是发送方文档规定的算法。

签名有效并不能阻止重放。如果发送方提供带签名的时间戳，应在考虑测得的时钟偏差后，拒绝超出窄时间窗口的投递。然后在调用下游工作前，将提供方事件 ID 写入持久化去重存储。如果不能依赖事件 ID，可以使用按发送方划分的摘要和适当保存期限进行去重，但要知道，两个合法的相同事件可能因此需要特殊处理。

只有在接收决定已经持久化后，才返回 HTTP 响应。如果先返回成功，却在写入去重记录前崩溃，发送方可能重试，处理程序也可能两次处理同一事件。这种错误在低流量测试中很难出现，却会在 webhook 流量恰好激增的故障期间暴露。

## 重试会暴露记录中的模糊之处

重试是正常行为，不是边缘情况，而且每一层都可能独立重试。代理会在超时后重试。HTTP 库会在连接失败后重试。API 提供方会重试回调。队列消费者会在处理程序失败后重试。将这些情况都压缩成“重试次数：3”的审计记录帮不上任何忙。

下面是一种我见过多次的故障。代理请求创建远程访问记录。客户端发送 POST，字节离开机器后发生超时。远程服务创建了记录，并将回调加入队列。代理框架看到超时后重试。由于操作层每次尝试都生成新的幂等引用，第二个请求创建了另一条记录。两个回调都到达。接收端只用邮箱地址进行匹配，认定它们是重复事件，于是抑制第二条。审计页面显示只有一个已完成请求，而远程服务实际上有两条访问记录。

每个组件的行为看起来都合理。系统失败，是因为它没有跨越重试边界保留同一个逻辑命令。

应按下面的顺序修复：

1. 在第一次出站尝试前生成操作 ID 和幂等引用，并且只生成一次。
2. 分别记录每次尝试，包括超时和传输错误。
3. 状态不明确时，先通过幂等引用或客户端引用查询提供方，再发出另一个命令。
4. 将每个经过身份验证的回调都作为一次接收记录，然后只对提供方事件 ID 去重，不要对远程对象本身去重。
5. 在宣告工作流完成前，将预期的远程对象数量与记录中的操作进行核对。

第一次幂等检查应在发送端，第二次应在接收端。它们解决的是不同问题。发送端幂等可以防止重复远程命令，接收端去重可以防止重复处理同一个远程事件。团队经常只部署其中一种，却以为两种能力都有了。

不要用到达时间作为业务事实的排序依据。提供方可能延迟或乱序发送事件，你自己的队列也可能延迟处理。至少保存三个时间：操作服务创建操作的时间，HTTP 客户端发送尝试或收到响应的时间，以及接收端接受回调的时间。发送方声称的事件时间也要单独保存。发送方的时钟是来自发送方的证据，不是你的时钟。

## 授权必须跨越异步边界

代理操作的人类审批应当附着在操作本身，而不是附着在后来到达的某个回调上。回调携带的是远程工作的信息。它不应仅仅因为与已批准请求共享某个关联字段，就悄悄获得触发新特权操作的权限。

当回调包含 URL、对象名称、用户控制的元数据或内部处理程序会执行的指令时，这一点尤其重要。一种常见的糟糕设计是，收到“任务完成”事件后，让通用自动化工作器使用宽泛凭据获取结果 URL 或运行后续命令。原始代理审批覆盖的是提交任务，不是一组嵌入事件中的开放式操作。

用具体内容记录已授权操作：操作者会话、请求的端点或 SSH 命令模板、目标范围、凭据身份、审批结果和审批时间。每次出站调用都应指回这条授权记录。每次回调都应在核验和关联之后，才指回该操作。方向很重要。传入请求不应在数据库中寻找任何方便的既有审批并借用它。

Sallyport 将代理凭据隔离在代理进程之外，并记录代理运行和单独调用，这让出站证据更容易保留。回调接收端仍需要自己的接收记录和工作流记录，因为 HTTP 操作日志无法知道远程系统后来是否发送了有效事件。

为两个方向使用不同凭据。授权出站 API 调用的凭据通常不应负责核验入站签名，入站核验密钥也不应授权回调处理程序调用任意外部 API。分开保管凭据，可以在接收路由、依赖或日志汇聚端出问题时限制损害范围。

## 防篡改证据应覆盖关联，而不只是调用

只记录出站调用的追加式日志很有帮助，但它无法证明之后做出的关联决定。操作员或应用程序错误可能把错误回调关联到错误操作，却不改变两条原始 HTTP 记录。

把关联作为一类独立的审计事件。事件应包含操作 ID、接收 ID、关联依据、做出决定的操作者或进程，以及所用字段的摘要。使用明确的依据，例如 `remote_operation_id_exact`、`client_reference_exact`、`authenticated_status_lookup` 或 `manual_review`。不要只写“已匹配”，把调查工作留给后来的人猜。

哈希链日志可以在记录创建后证明它们未被修改，前提是你保护了日志追加路径并保留检查点。但它无法证明应用程序当时做出了正确决定。坦率说明这一限制是有益的。防篡改证据能为系统记录的内容提供稳定依据，却不能把薄弱的关联变成事实。

Sallyport 的加密哈希链审计日志可以使用 `sp audit verify` 离线检查，即使没有保险库密钥也可以。可以用这种方式核验操作记录，然后在工作流存储中保留一份类似的不可变引用，指向相关操作和调用 ID。

对于后果严重的工作流，还应增加对账任务，比较三个集合：已提交的操作、提供方已知的远程操作，以及接收端接受的回调。对于未配对项目，任务应创建异常记录，而不是自动关闭。缺少回调可能意味着提供方故障、端点错误、签名轮换失败或工作流缺陷。需要的是证据，而不是乐观判断。

## 可观测性必须让调查者重放决定过程

调查者应能从任意标识符开始，在不访问代理提示词或 API 密钥等特权信息的情况下重建工作流。上线集成前，就应先设计搜索路径。

从操作 ID 开始，记录应显示代理会话、授权、凭据标签、脱敏后的请求形态、所有尝试、远程引用、关联的接收记录和工作流终态。从提供方事件 ID 开始，应显示该事件的每次投递、核验结果、去重结果、关联操作和状态变化。从内部业务对象开始，应显示将它与代理操作关联起来的确切证据。

使用结构化字段，不要只写一段叙述文字。下面的事件契约可以复制到模式评审或日志管道中：

```json
{
  "record_type": "callback_receipt",
  "receipt_id": "rcp_01J...",
  "received_at": "2025-03-08T22:14:31Z",
  "sender": "build-service",
  "provider_event_id": "evt_775",
  "event_type": "publication.finished",
  "raw_body_sha256": "4d3c...",
  "signature": {"scheme": "hmac-sha256", "result": "valid"},
  "correlation": {
    "action_id": "act_01J...",
    "remote_operation_id": "op_903",
    "basis": "remote_operation_id_exact"
  },
  "processing": {"deduplication": "new", "result": "completed"}
}
```

请求体摘要、核验结果和关联依据，比模糊的 `status: success` 更有用。它们让你能够检验各种说法。提供方质疑某个回调时，可以比较保存的摘要。工程师质疑某次匹配时，可以检查关联依据。重复事件造成副作用时，可以检查接收端是否在派发工作前写入了去重记录。

避免记录授权请求头、bearer 令牌、私钥、签名密钥或包含完整凭据的 URL。查询参数包含敏感数据时应脱敏，但仍要保留足够的请求身份信息，以区分两个目标。我见过团队把 URL 脱敏到完全无法使用，后来连代理访问的是生产端点还是测试端点都无法判断。应保存规范化主机、路由模板、方法和范围谨慎的目标标识符。

## 在代理发起异步调用前建立追踪

在允许代理启动异步工作前，就应定义标识符、接收规则和状态转换。发生争议后再补救代价很高，因为缺失的证据从未存在。

进行一次有意设计的故障演练。提交一个无害的测试操作；如果测试环境允许，让客户端在传输完成后超时；重新发送同一个回调；发送签名无效的回调；再在工作流进入终态后投递一个有效回调。检查审计记录是否能解释每种结果，而不需要人根据记忆补齐空白。

如果系统无法回答“哪个获得授权的操作导致了这次回调，确切证据是什么，以及我们如何处理了它”，那么它还没有真正的 webhook 审计故事。它只有两组恰好共享同一时钟的日志。
