Skip to content
工程

如何在电子邮件中一键核准一笔可计费时间记录——同时还能通过 SOC 2 审计

结构 AI 的签章动作链接把一个 4 分钟的 SSO+2FA 核准仪式换成电子邮件中的单一一键。这篇文章是线格式、验证顺序、威胁模型、以及让它跟审计员证据等价的那四个属性。

JT
JieGou Team
· · 6 分钟阅读

那 4 分钟的核准

上一篇文章中我们说过:「密码学做了 SSO + 2FA 过去做的工作。」几位工程师写信来问这在代码里实际上是什么意思。这篇文章是答案。

起点是 MSP 客户工作流中可量测的摩擦。AI 提案一笔可计费的 ConnectWise 时间记录。主管必须在它入账前签核。旧路径是:

  1. Slack 通知:「需要核准」
  2. 点链接 → SSO 重定向 → 密码 → 2FA → 核准页面
  3. 读条目 → 点核准

首次动作的中位时间:4 分钟。一天 30 笔 × 一个典型的 10 人 MSP 4 个技师,那是一天 8 小时主管注意力,做的是功能上一堆「赞」。

那 4 分钟不是因为任何人慢。是因为核准的路径要求一个完整的身份仪式,而它从信息上看只是单一一个比特(核准/拒绝)加上选填的原因文字。

我们把它换成了这个:主管的电子邮件包含核准拒绝按钮。一键。动作执行、审计日志记录、时间记录送到 ConnectWise。没登录。没 2FA。

它通过 SOC 2 问卷。下面是怎么通过的。

让它跟审计员证据等价的那四个属性

每个按钮里的链接不是带 ?approval_id=42 的 URL。它是用我们服务器私钥签章的 JWT(JSON Web Token),具有四个属性:

  1. 已签章。 该 token 的真实性可以用密码学验证。我们不信任 URL 列;我们信任签章。
  2. 有时效。 每个 token 会过期(exp claim),通常是发行后 72 小时。之后点击不会做任何事。
  3. 单次使用。 一个 token 只能赎回一次。同一个核准按钮的第二次点击会打到幂等性缓存,返回「已被你在 [时间戳] 核准」。
  4. 幂等。 不管网路怎么样——重复点击、重试、浏览器预取——底层动作最多执行一次。每次相同效果。

第一个属性让我们可以略过 SSO。其他三个让我们可以略过审计团队的「但如果……」问题。

线格式

token payload(在 jwt.decode 之后但在签章验证之前)长这样:

{
  "iss": "jiegou.ai",
  "aud": "approval.action",
  "iat": 1714502400,
  "exp": 1714761600,
  "jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R",
  "approval_id": "appr_2bF7kQpL9xR3",
  "approver_email": "alex@example-msp.com",
  "action": "approve",
  "account_id": "acc_4nM8tYz2",
  "scope": "time_entry"
}

关于这个 payload 三件事:

  • token 把动作绑到特定核准者。即使电子邮件被转发,收件人也无法以自己的身份赎回它——审计日志仍然会记录 alex@example-msp.com 为核准者,而不是转发者。(转发邮件威胁在下面进一步讨论。)
  • jti 是一个 ULID——每个 token 唯一,在我们赎回缓存里当幂等性键用。
  • scope 字段告诉服务器派发到哪个赎回处理器。time_entry 走跟 content_publishoutbound_message 不同的代码路径。一样的 JWT 模式,不同的 scope 处理器。

验证顺序很重要

点击到服务器时,我们以这个确切顺序验证。任何一步失败,就停下:

  1. 验证签章。 这是第一件事——在 parse 之前、在信任任何字段之前。如果签章无效,请求收到一个通用的 400 Bad Request,没有细节。我们不泄漏哪个字段失败。
  2. 检查 issaud 错的发行者或受众 → 拒绝。这抓住了由其他服务铸造或在系统间 replay 的 token。
  3. 检查 exp 过期 → 渲染「这个核准链接已过期,请申请一个新的」页面。
  4. 对赎回缓存检查 jti 已被赎回 → 渲染「已被 [核准者] 在 [时间] 核准」幂等性响应。
  5. 查找核准记录。 如果 approval_id 不存在或已经透过其他路径解决(例如在控制台核准),渲染「这个核准已经被解决」。
  6. 比对 approver_email 与该核准的允许核准者列表。 如果 alex@... 不在这个核准的政策核准者列表上,用 403 拒绝。
  7. 执行动作。 原子性:写赎回记录(jti → 已赎回时间)、发出 approval_event 审计条目、派发核准后工作流(例如把时间记录送到 ConnectWise)。
  8. 渲染成功。 显示确认页面;不重新发行任何 token;不在 URL 列里储存任何可能被重新分享的东西。

关键的不那么明显的选择是步骤 1。先验证再 parse。 有些实作先做 jwt.decode 来抽出 kid(key ID)以便验证签章,但那意味着你已经信任了未签章的资料。用一个能在一个调用中验证并在暴露 claim 之前拒绝格式错误 token 的 JWT 函式库。

威胁模型——四件事,每件对攻击者的成本

我们坐下来思考四个最可能的攻击,问:我们的设计怎么处理每一个?

1. 电子邮件被转发到公司外的人

会发生什么: 不是 alex@... 的某人点了按钮。

对攻击者的成本: 零——他们有明文 token。

缓解: token 把动作绑到 alex@... 在审计日志中的身份。转发者无法以自己身份赎回它。动作会执行,但是以 Alex 身份执行。下一次治理检视会浮现「Alex 从 IP 203.0.113.7 核准了这个(那不是 Alex 的办公室)」——而 Alex 在下次登录时马上会发现。

这是我们无法完全用密码学防止的。我们从营运上防止:核准者 IP/UA 的异常侦测、每周治理摘要、以及一个 3 秒的确认页面说「你即将以 alex@example-msp.com 身份核准——继续吗?」电子邮件链接的转发诈骗是可侦测的,不是可预防的。我们在客户 SOC 2 问卷中清楚地写明这件事。

2. token 在某个服务器日志中外泄

会发生什么: 一个 token 出现在我们的 nginx 存取日志、或第三方 webhook 日志、或一个失误的 console.log

对攻击者的成本: 取决于新鲜度。

缓解: 三层。

  • exp 72 小时过期意味着外泄的 token 在 3 天内就死了。我们不发行 30 天 token。
  • 单次使用赎回。 如果合法用户先点,token 就死了。赎回缓存查找在任何副作用之前发生。
  • 日志卫生。 动作链接 URL 在我们存取日志和 SIEM 摄取中被 pattern-match 并编辑。token 永远不会在 JWT 本身之外进入持久储存。

3. Replay——同个 token 被点两次

会发生什么: 网路重试、双击、浏览器预取、或恶意 replay。

对攻击者的成本: 零(合法 replay 很常见)。

缓解: 验证步骤 4 中的赎回缓存检查是承重的。缓存是 Redis、以 jti 为键、TTL 比 exp 长。第一次赎回原子性地设置键(SETNX);每个后续点击读取现有记录,并返回幂等的「已核准」响应,包括谁核准和何时。

幂等性不只是 UX 上的好处——它是让系统在重试下安全且不泄漏状态的属性。

4. 我们的签章密钥被入侵

会发生什么: HMAC secret 或 RSA 私钥外泄。

对攻击者的成本: 他们可以为任何核准铸造任意 token。

缓解: 四层。

  • 密钥隔离。 签章密钥住在我们的 KMS,不在应用程序记忆体。应用程序服务器调用 KMS 来签章和验证;它们从不拥有原始密钥素材。
  • 密钥轮换。 密钥按公布的时程轮换;旧密钥仅在等于最长已发行 exp 的宽限期内被接受用于验证。
  • 每个核准者范围的 token。 即使密钥被入侵,铸造一个给 alex@... 的 token 并点击它的攻击者会产生 Alex 身份下的审计条目,那(依威胁 1)是可侦测的。
  • 入侵响应。 如果我们侦测到密钥入侵,我们撤销密钥并使所有未完成 token 失效。客户看到「这个核准链接因安全原因已失效;请使用控制台解决」直到重发。

这是最难完全缓解的威胁,这也是为什么系统有后备:控制台核准队列永远可用。电子邮件按钮核准是一种增添,不是替代。

与 SSO + 2FA 的诚实取舍

电子邮件按钮核准严格来说不等于 SSO + 2FA。取舍:

属性SSO + 2FA签章动作链接
认证身份是(因素 + 因素)否——认证一个单一决定绑到一个身份
抵抗电子邮件转发是(转发用户无法通过 2FA)可侦测但不可预防
抵抗密钥入侵独立于我们基础架构(联邦 IdP)取决于我们的 KMS 卫生
每次核准的摩擦4 分钟5 秒以下
审计轨迹等价是(一样 approval_event 形状)

我们对客户清楚说明他们在选哪个取舍。建议的设定是:

  • 低风险、高量核准(时间记录、内容草稿、行事历暂停):默认电子邮件按钮
  • 高风险核准(超过阈值的金融交易、对外可见的承诺、影响合规的动作):要求控制台 + SSO;电子邮件按钮变成「提醒」
  • 每个账号的覆写:每个客户都可以为哪些动作得到电子邮件按钮 vs. 仅控制台设置阈值

阈值是一个 config 值。密码学一样。

审计日志等价

每次签章动作链接赎回都会发出跟控制台核准一样的 approval_event 形状。entry-point 字段写 email_button;其余相同:

{
  "event_type": "approval.resolved",
  "approval_id": "appr_2bF7kQpL9xR3",
  "approver_email": "alex@example-msp.com",
  "decision": "approve",
  "decision_at": "2026-04-29T14:32:18Z",
  "entry_point": "email_button",
  "client_ip": "203.0.113.42",
  "user_agent": "Mozilla/5.0 …",
  "jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R"
}

entry_point 是审计员需要知道它从电子邮件来的唯一东西。其他一切——谁、何时、什么、IP——都跟控制台核准证据相符。我们的 SOC 2 证据汇出格式一视同仁。审计员读一个 schema。

五个适合这个模式的其他工作流

一旦 JWT-赎回-缓存模式存在,我们发现它适合很多单一决定的工作流:

  1. 行事历暂停确认——AI 提案「周三下午 2 点封锁 30 分钟准备 Q3 检视」;操作员确认或移动它
  2. 内容发布暂停-而后-释放——通过自动检查但被标记为发送者确认的草稿
  3. 外送讯息暂停——AI 草拟一个客户回复;操作员从暂停队列释放它
  4. 敏感整合解锁——AI 请求使用一个位于每次使用核准闸门后的特定工具的权限
  5. 单次同意流程——影响客户资料的动作的对客户同意

每一个过去都需要造访控制台。每一个现在都有电子邮件按钮等价。密码学就是凭证。 操作员的一天因此缩短。

我们会跟另一个团队建造这个的人说什么

如果你在建造类似的东西,三件事倾向去做、三件事避免:

倾向:

  • 在任何字段 parse 之前验证 JWT 签章。 用一个在一个调用中做这件事的函式库。
  • jti 为键的赎回缓存幂等性。 这是让 retry-safe 的。
  • 不论进入点为何都有同样的审计事件形状。 审计员不应该关心点击从哪个路径来。

避免:

  • 为了「对用户好」而长 exp 30 天核准链接是 30 天攻击面。72 小时对人类核准者来说绰绰有余。
  • 把 token 存在 URL fragment 里希望浏览器不会记录。 它们会。把 URL 当成是「最终会被记录的东西」对待。
  • 静默失败模式。 验证失败时,渲染一个清楚的错误页面。合法核准者可能因为忽略电子邮件一周而拿着旧 token。

上一篇文章的口号是:信任没有变弱,它换到了摩擦更低的层。 签章动作链接是这句话的字面范例。信任从 SSO + 2FA + 密码管理员 + 浏览器 session 换到 管理良好的签章密钥 + 短时效 JWT + 幂等赎回缓存。摩擦从 4 分钟变到 5 秒以下。审计轨迹得到更多字段,不是更少。

免费开始 → — 或如果你经营 MSP 而上面的 4 分钟核准问题听起来很熟悉,预约 30 分钟简报,我们会在第一天就为你的账号设置好电子邮件按钮核准。

security approvals jwt operator-ux soc2 engineering
分享这篇文章

喜欢这篇文章吗?

在您的信箱中获取工作流程技巧、产品更新和自动化指南。

No spam. Unsubscribe anytime.