# 批准超时，防止旧批准执行新操作

批准是某个时刻对某项拟议操作的许可。它不是一张代理可以在方便时随意兑换的优惠券。

这听起来很明显，直到代理在人工审核者忙碌时排队等待生产部署、删除操作或 SSH 命令。审核者看到一个合理的请求，批准了它，然后现实继续变化。更新的构建版本排到了前面。目标集合发生变化。有人重新指向了环境别名。事故改变了安全条件。如果旧批准仍然可以触发，系统就把针对昨天状态作出的决定，变成了今天状态下的行动权限。

批准超时只能解决这个问题的一部分，但团队经常把这一部分留在开放状态。对于含义可能发生变化的操作，应设置短而明确的过期时间。然后，把批准绑定到确切的拟议操作，并在执行前重新检查可能变化的条件。两件事都要做。单独依赖其中任何一种控制都会留下缺口。

## 每个批准都有新鲜度预算

每项批准在等待执行时都会消耗时间。目标越容易变化，这个预算就应该越小。

重启一个可随时丢弃的预览工作进程，半小时后可能仍然有意义。把发布版本推送到生产环境的请求，如果另一个发布、回滚或事故响应可能改变正确的下一步，几分钟后就可能过时。删除指定备份快照的命令，在快照列表正处于保留策略调整期间时，应当迅速过期。针对可变主机别名的 SSH 命令，应该拥有其中最短的窗口。

最糟糕的默认做法，是为了避免打扰别人而选择一个很大的数字。八小时听起来很宽松。实际上，它会让批准在午餐时间、夜间或交接班期间不断积累。审核者可能在 10:02 批准部署，之后把它忘掉，直到 16:40 才发现队列中更晚的状态消耗了那次点击。批准界面看起来很谨慎，执行路径却很粗心。

应当问一个更具体的问题来设置窗口：这项确切的拟议操作，最长能在多长时间内准确描述即将发生的事情？

对大多数团队来说，可以从以下策略开始：

- 生产部署或回滚：5 到 15 分钟。
- 破坏性删除：2 到 10 分钟，具体取决于对象集合是否固定。
- 带有写入效果的 SSH 命令：2 到 5 分钟。
- 只读 SSH 检查：不需要逐次操作批准；如果环境仍然要求批准，也可以使用更长窗口。
- 使用固定构建版本的常规非生产变更：15 到 30 分钟。

这些是运行时默认值，不是适用于所有环境的常数。如果自动化循环每隔几秒就能修改目标，那么五分钟太长。如果操作需要值班审核者先收集证据，五分钟又可能太短。正确做法不是悄悄地无限延长截止时间，而是给审核者所需的上下文，并让请求能够根据当前状态轻松重新生成。

GitHub Actions 在这里做了一个重要区分：部署作业可以等待必要的审核，获得批准的待处理作业随后可以继续运行并访问其环境机密。GitHub 还说明，未经批准的作业在 30 天后可能失败。这能防止队列永远存在，但对于一个针对不断变化的部署作出的批准来说，30 天并不是有用的新鲜度窗口。你自己的控制门应把批准年龄视为授权的一部分，而不是队列清理问题。

## 把决定绑定到操作，而不是标签

批准必须覆盖具体的操作数据。「批准生产部署」只是一个标签，几乎没有告诉审核者真正会运行什么对象。

对于部署，应将决定绑定到不可变的制品摘要或提交标识、目标位置、迁移计划、配置版本以及发布操作。对于删除，应绑定到不可变的对象列表或查询结果快照，并包含删除模式。对于 SSH，应绑定到已验证的主机身份、用户、命令模板、展开后的参数、相关工作目录以及输入文件的限定描述。

团队最容易混淆的是以下区别：

- **批准意图**，表示审核者同意一个宽泛目标，例如「删除过时的预览环境」。
- **批准操作**，表示审核者同意执行器在截止时间前，使用这条命令删除这些对象标识符。

意图批准在变更管理中有其位置。但当代理可以对在线系统采取行动时，它不能替代执行批准。如果你批准了一个意图，然后让代理稍后再解析目标，就等于把决定中最重要的部分交给了代理。

OWASP 的 Transaction Authorization Cheat Sheet 在另一个领域指出了同样的问题。它要求授权交易的人识别并确认重要的交易数据，并警告授权后修改数据会造成检查时间到使用时间之间的失效。这个例子来自金融领域，但规则可以直接迁移：批准数据必须防止被修改，数据发生变化后应使原有授权失效。

摘要可以让执行器进行精确比较。不要只对模糊的自然语言摘要计算哈希，然后认为工作完成了。应将控制效果的字段规范化，以确定性方式序列化，再对规范化结果计算哈希。

```json
{
  "request_id": "appr_01JX...",
  "action_type": "deploy",
  "action_digest": "sha256:8b1d...",
  "summary": {
    "artifact": "registry.example/app@sha256:4fa2...",
    "environment": "production",
    "operation": "promote",
    "config_revision": "7c0e...",
    "migration": "none"
  },
  "issued_at": "2026-07-22T14:03:00Z",
  "expires_at": "2026-07-22T14:13:00Z",
  "status": "pending"
}
```

`summary` 面向人类，`action_digest` 面向执行器。两者都要保留。人需要看到有用的事实，服务需要进行精确的相等检查。如果代理哪怕只改变了一个已绑定字段，也必须提交新请求并获得新的决定。

## 过期和失效解决的是不同问题

截止时间可以防止旧批准长期存在。失效则会在相关事实发生变化时立即移除批准。两者都需要，因为系统已经知道请求不再符合现实后，还要等待计时器结束是不严谨的。

当操作摘要发生变化时，使待处理批准失效。这条规则不可妥协。对于会影响含义的依赖项变化，也应使批准失效，例如部署目标环境的版本、选定的删除集合、主机密钥、发布锁持有者或必要变更工单的状态。

不要因为每个无关事件都使批准失效。如果任何日志行、无关提交或无害的指标变化都会杀死一个批准，审核者就会认为请求不可靠，最终不再检查便直接批准。规则应跟踪那些会改变请求效果或安全前置条件的事实。

不要只使用两个状态，而应使用三个：

1. `pending` 表示确切请求仍可在截止时间前获得批准。
2. `approved` 表示审核者已经批准，但执行器尚未消耗该批准。
3. `consumed` 表示执行已经准确地使用过一次批准。

再增加 `expired`、`invalidated`、`rejected` 和 `failed` 等终态。被拒绝的请求不应因为代理重试网络调用而重新变成 pending。执行失败后也不应悄悄重复使用同一批准，除非你能证明操作从未开始且相关状态没有变化。在大多数操作系统中，让代理重新请求批准更安全，也更容易解释。

执行器应在一个事务中，或通过一次原子 compare-and-set 操作完成这些检查：

```text
if now >= expires_at: reject as expired
if status != approved: reject as unavailable
if stored_digest != supplied_digest: reject as changed
if live_preconditions fail: reject as stale
atomically change status from approved to consumed
execute the action
```

不要等操作开始后才把批准标记为 consumed。两个工作进程可能同时看到 approved，然后同时执行。应先通过原子状态转换消耗批准，再记录执行已经开始。如果进程在消耗批准后崩溃，除非执行器能确定请求是否已经到达目标，否则应将结果视为未知。这确实不方便，但重复执行破坏性操作更糟。

## 部署批准必须跟随制品

当制品、目标、发布计划或队列中的顺序发生变化时，部署请求就会过时。分支名称和可移动标签不够可靠。

部署提示应指明不可变制品，可以是镜像摘要、已签名发布包哈希或不可变构建记录。还应告诉审核者执行器是否会运行数据库迁移、修改功能配置、重启实例或替换之前的部署。这些细节会影响批准。把它们藏在一个笼统的「部署」按钮后面，只会助长不加检查的批准。

一份可靠的部署批准契约应包含：

- 不可变构建标识和源代码版本。
- 确切的目标环境以及账户或集群身份。
- 发布操作，例如提升、回滚或重新部署。
- 将要应用的配置和迁移版本。
- 并发令牌或部署代次。

当后来的变更取代较早请求时，并发令牌尤其重要。假设构建 A 正在等待批准。构建 B 完成并通过检查，成为你现在真正想发布的版本。如果构建 A 的请求仍然有效，审核者可能误将旧构建发布出去。当构建 B 进入同一发布通道时，应使构建 A 的待处理批准失效。不要指望审核者在繁忙的队列中注意时间戳。

GitHub 的部署文档将环境保护与工作流并发分开。并发控制可以取消某个组中的待处理工作，而环境批准控制作业是否可以继续。这种分离很有用：队列策略决定哪个运行实例是当前实例，批准门决定这个确切的当前实例能否执行。粗心地把两者结合起来，就会产生经典故障：正确的人批准了错误的运行实例。

部署前，执行器必须重新检查。实际的预检可以确认请求中的制品仍然存在，环境仍然映射到预期目标身份，没有更新的发布占用通道，迁移计划仍与批准的摘要匹配。如果任何检查失败，就将请求标记为失效，并向审核者展示新请求。绝不能因为构建 A 已获批准，就静默替换成构建 B。那是另一项操作。

应避免一种常见但薄弱的模式：批准拉取请求，然后把这次批准当成生产授权。代码审核回答的是某项变更是否属于代码库。它没有回答这个构建是否应该在此刻、在当前事故状态下、按照当前迁移和目标状态运行于生产环境。两项决定应保持分离。

## 删除提示需要冻结对象集合

当请求包含查询而不是已解析的对象时，删除批准会变得危险。「删除超过 30 天的备份」每分钟都可能指向不同的集合。

创建请求时，应将查询解析为对象标识符并记录快照标记。批准界面应显示数量、几个有代表性的标识符、保留依据以及确切的删除模式。执行器应使用这个冻结集合，而不是在批准后重新运行宽泛查询。

如果集合太大，无法完整显示，就提供稳定的清单标识符和简明分类。不要把提示压缩成没有边界的「删除 8,421 个项目」。数量无法告诉审核者列表是否包含错误租户、当前备份或意外前缀。

可以考虑以下请求：

```json
{
  "action_type": "delete_objects",
  "scope": "archive/preview/",
  "selection": {
    "manifest_digest": "sha256:19e7...",
    "object_count": 184,
    "newest_object_at": "2026-06-19T03:11:00Z",
    "oldest_object_at": "2025-11-02T18:24:00Z"
  },
  "mode": "permanent",
  "expires_at": "2026-07-22T14:08:00Z"
}
```

执行时，应确认清单仍然存在，并确认每个对象标识符仍解析到预期版本。如果存储系统支持版本控制，应将删除绑定到版本而不是名称。名称可能被重新使用。批准后写入同一路径的新对象，不能继承旧对象的死亡判决。

当请求基于对象年龄或实时清单时，短截止时间尤其重要。请求等待越久，新获得资格的对象、恢复的项目或重新分类的记录就越可能改变预期集合。如果系统无法冻结集合，就不应允许一次批准授权宽泛的删除查询，而应要求提交更窄、更近期的请求。

软删除和永久删除应使用不同提示和不同过期窗口。软删除可能可以恢复，但不要以可恢复为理由接受模糊批准。恢复往往缓慢、不完整，或者依赖发起删除的人无法控制的权限。

## SSH 批准比人们想象的更快失效

SSH 对过时上下文特别敏感，因为名称、会话、环境变量和工作树都可能在相同命令文本下发生变化。

`systemctl restart api` 看起来很具体，但你还需要知道它会发送到哪台机器、`api` 在那里指向什么、当前部署的是哪个版本，以及后来的事故是否改变了重启的理由。如果别名、挂载或 shell 展开结果发生变化，`rm -rf /srv/tmp/job-123` 在一台主机上可能安全，在另一台主机上却可能造成灾难。

对于 SSH 操作，应批准结构化命令请求，而不是终端记录。请求应包含主机经过验证的身份、目标用户、固定命令模板、完全展开且允许的参数、声明的工作目录以及预期输入摘要。如果代理需要 shell，应将 shell 命令限制为明确的负载，而不是授权开放式交互会话。

合理的批准卡可以写成：

```text
Host: prod-api-03, host key SHA256:K4f...
User: deploy
Command: /usr/local/bin/release-health --release 2026.07.22.4 --repair-cache
Directory: /srv/api
Effect: writes cache state, may restart one service
Expires: 14:08 UTC
```

这仍不能证明命令安全，但足以让人识别自己正在授权什么。随后，执行器应重新连接，再次验证主机身份，确认命令摘要，并在截止时间前运行它。

绝不能让针对 `prod-api` 的命令批准，在 DNS、资产清单或堡垒机映射将该标签解析到另一台主机后，仍然授权执行。只要连接设置允许，就应绑定到主机的加密身份。如果批准等待期间发生合法的主机密钥轮换，就使请求失效。维护期间这可能显得烦琐，但总好过批准发给一台机器的命令，最终发送给另一台机器。

只读命令应单独分类。团队常常因为只有一种控制，就把它应用到每次 SSH 调用。结果是批准疲劳，审核者开始对无法解析的命令一路点击确认。应将无害检查与会写入、重启、修改访问权限或暴露敏感输出的操作分开。只有在命令效果确实需要时才使用逐次调用批准，并让提示足够紧凑，便于阅读。

## 批准界面必须让变化清晰可见

如果审核者看到的只有代理写的一句话，再精确的后端契约也会被浪费。界面需要展示那些可能改变决定的字段。

先说明效果：将这个摘要部署到这个环境，永久删除这份固定清单，或在这台已验证的主机上运行这条命令。把截止时间放在审核者批准前一定会注意到的位置，并使用明确的时区显示时间。倒计时只能作为辅助，决定有效性的应是执行器所在服务器的时间戳。

请求发生变化时，不要在原位置替换旧内容，却仍让批准按钮保持可用。应将旧请求标记为失效，并创建带有明显说明的新请求，例如「制品已变化」或「目标清单已变化」。批准过早期版本的审核者必须重新作出决定。多出的这一次点击正是控制的意义。

NIST SP 800-63B-4 将认证意图描述为用户介入，以确认声明者确实打算进行认证或重新认证。操作批准也需要同样的纪律，只是范围更窄。一次按键或确认点击应表达对界面上所显示操作的意图，而不是笼统地表示愿意让代理继续运行。

不要让提示把时间压力变成陷阱。对于复杂删除操作，只有两分钟的窗口会迫使审核者在盲目批准和请求过期之间作出选择。请求在到达审核者前就应准备好供检查。让审核者获得充分上下文后，再设置较短的执行截止时间，不要用仓促的决定期限惩罚认真阅读的人。

当审核者需要说明某项异常操作为何可以接受时，评论字段会有帮助。不要为普通工作强制填写评论。强制生成的套话没人会读。对于覆盖规则、特殊的过期延长，或超过规定影响范围的操作，可以要求填写评论。

## 执行器负责强制执行

持有执行操作权限的系统必须强制执行过期、绑定和一次性消耗。工作流界面、聊天机器人或代理框架都可以请求批准，但如果另一个组件能够绕过它们，就不能由它们担任最终裁判。

这就是操作网关有用的原因。代理发起 HTTP 调用或 SSH 命令，网关检查请求是否有当前批准，在适当时注入凭据，执行操作并返回结果。代理不需要可重复使用的凭据，因此之后无法绕过批准路径。

Sallyport 对 HTTP API 和 SSH 采用了这种形态：代理通过其 MCP shim 连接，凭据仍保存在应用的加密保险库中，由应用执行操作，而不是把机密交给代理。对于上下文变化很快的操作，其逐次调用密钥是要求重新确认的自然位置。但超时和操作绑定逻辑仍必须在请求路径中明确实现；没有这些检查的确认，最终仍会变成过时的批准。

将批准状态放在执行器旁边，或让执行器能够对其进行加密验证。签名批准令牌可以奏效，前提是其中包含请求标识符、操作摘要、签发时间、过期时间、审核者身份和随机数。执行器仍然必须检查撤销状态，并且只消耗一次随机数。请求已经失效后仍然有效的签名令牌，只是一份签名完善的过时批准。

谨慎处理时钟。过期决定应使用可信服务时钟，时间戳应以 UTC 存储，并在达到过期时刻或之后拒绝批准。代理本地时钟和浏览器倒计时只能用于显示，不能作为授权输入。

## 日志应解释操作为什么执行或没有执行

批准过期时，团队需要的不只是「拒绝」二字。他们需要知道是审核者没有回应、批准后请求发生变化、执行器发现前置条件失败，还是另一个工作进程已经消耗了批准。

写入不可变事件链，将提案、显示的摘要、批准决定、失效或过期、预检、执行尝试和结果连接起来。每个事件都保存操作摘要。如果请求被重新生成，应记录替代请求标识符，但不要暗示早期批准已经转移过去。

有用的事件形式如下：

```json
{
  "event": "approval.invalidated",
  "request_id": "appr_01JX...",
  "action_digest": "sha256:8b1d...",
  "reason": "release_lane_superseded",
  "replaced_by": "appr_01JY...",
  "recorded_at": "2026-07-22T14:06:22Z"
}
```

也要记录消耗已过期批准的尝试。这些记录可以发现盲目重试的代理、时钟错误的工作进程以及未能刷新的界面路径。它们还能让事故审核者区分已过期请求和确实到达生产环境的操作。

Sallyport 的 Sessions journal 和 Activity journal 都从加密、哈希链式审计日志投影而来，`sp audit verify` 命令可以在离线状态下对密文检查这条链。只有在事件词汇如实反映情况时，这样的记录才有用。除了成功调用，也要包含过期、失效和预检失败事件，不要只记录那些让仪表板看起来干净的成功调用。

不要把可审计性和预防混为一谈。对旧批准针对新状态运行的过程进行完美记录，仍然只是失败的证据。预防性检查必须在执行器发送请求或打开 SSH 通道之前运行。

## 让过期请求容易替换

只有在创建新请求比争论旧请求更容易时，短窗口才有用。如果重新生成请求需要重新填写工单号、手工重建命令并联系三个人，团队就会要求你不断延长超时，直到它失去意义。

代理应能够根据新状态重新提交请求，但必须清楚展示新事实。如果只有过期时间变化，所有已绑定字段都完全相同，新请求可以保留相同的人类可读摘要，但必须获得新的标识符和截止时间。如果任何操作字段或实时前置条件发生变化，就说明变化内容。不要让审核者比较看不懂的哈希。

把延长过期时间作为例外。如果提供该功能，就要求执行器重新运行所有预检，并让审核者再次看到当前摘要。「延长 30 分钟」按钮如果只是续用旧令牌，那就是披着更漂亮界面的批准绕过。

一开始可以测量四项指标：批准多久会过期、已批准操作在执行前多久会失效、请求等待多长时间，以及哪些操作类型会产生反复重试。这些结果能告诉你，问题到底是截止时间太短、队列太慢，还是代理在输入稳定前就创建了请求。

过时批准应安静而明确地失败：执行器拒绝它，日志写明原因，如果工作仍然有意义，代理就请求当前决定。这个小小的拒绝，正是让人工控制始终绑定到实际发生操作的方式。
