跳到正文
博客园· 李福春·· 2 小时前AI 评分60

Discord 能下令,谁敢放权限:DeepSeek Harness 适配器的授权边界设计

技术思考问题29:Discord能下令,谁敢放权限? - 李福春

AI 导读

作者李福春复盘团队把 DeepSeek Harness 接入 Discord 后出现的事故,包括误触清理 staging 容器、重复提交任务跑两遍、超时重试导致删除执行两次,以及跨租户构建日志泄露。

正文

01、痛点

+-------------------+        +-------------------+        +-------------------+
|   Discord 用户    |  --->  |   适配器进程      |  --->  |  DeepSeek Harness |
|  (聊天窗口)     |        |  (消息转发)     |        |  (执行引擎)     |
+-------------------+        +-------------------+        +-------------------+
        |                            |                            |
        |  一条 thread 很顺手        |  消息能到达 = 有权限?     |  部署/删除/跨项目读
        |                            |                            |
        v                            v                            v
   “再点一下就好”              “先转发再说”                “未知结果当失败重试”

我是老李。前阵子团队把 DeepSeek Harness 接进了 Discord,初衷很朴素:让研发在聊天窗口里就能提交任务、看日志、触发部署。上线第一天,大家反馈出奇地好——“太顺手了”。

但顺手这件事,很快就变成了事故的温床。

有人在 thread 里 @ 了一下机器人,说“把 staging 的旧容器清一下”,适配器照单全收;有人重复点了两次提交,任务跑了两遍;还有一次,适配器在调用后端超时后自动重试,结果一个删除操作被执行了两次。最惊险的一次,是某个频道的成员误触了一个跨项目查询,返回了另一个租户的构建日志。

这些问题的共同点不是“代码写错了”,而是我们把聊天工具的便利性,误当成了业务平台的授权能力。消息能到达,不代表发言者有权限部署、删除或读取跨项目数据。适配器更不能把“未知结果”当成“可以重试”。

为什么现在要讲这件事?因为越来越多的团队正在把执行引擎、运维平台、数据管道接进 IM。入口越顺手,越需要有人把责任边界画清楚。这篇文章不讲怎么“接进去”,只讲接进去之后,怎么不把自己坑死。

02、是什么

+-------------------------------------------------------------+
|                    芒格双模型:适配器的两道闸门              |
+-------------------------------------------------------------+
|                                                             |
|   [激励机制]                                                |
|   便利性 --> 更多操作 --> 更大暴露面                        |
|   越顺手,越需要责任约束                                    |
|                                                             |
|   [安全边际]                                                |
|   风险动作 --> 最小权限 --> 审批 --> 审计                   |
|   让每一次“危险动作”都经过减速带                            |
|                                                             |
+-------------------------------------------------------------+

先把这个话题里的两个概念说清楚。

激励机制:便利性会诱导更多操作。Discord 的 thread、@ 提及、斜杠命令,把“提交任务”的成本降到了几乎为零。成本越低,尝试越多;尝试越多,误操作和越权尝试的概率就越高。这不是用户的问题,是激励结构的问题——你设计了一个“点一下就行”的入口,就必须配套一个“点一下也要负责”的约束。

安全边际:让风险动作经过最小权限、审批和审计。它不是给系统加一堆弹窗,而是给高风险动作设计减速带:默认拒绝、白名单、发起者确认、超时拒绝、幂等键、审计事件。安全边际的本质是“留出容错空间”,让一次误触不至于变成一次生产事故。

把这两个模型合起来,就是我们给适配器定的核心结论:Discord 是便捷入口,不是天然授权中心。身份、任务归属和高风险审批,必须在适配层或服务端独立验证。

一条 thread 对应一段会话,确实有助于隔离上下文;但上下文隔离不等于权限隔离。消息能到达,不意味着发言者有权限部署、删除或读取跨项目数据。适配器更不能把“未知结果”当成可重试——超时、网络中断、后端 5xx,这些“不知道成没成”的状态,必须先去查,而不是直接再发一次。

03、为什么

   便利性曲线                          风险暴露曲线
      /|                                  /|
     / |                                 / |
    /  |                                /  |
   /   |                               /   |
  /    |                              /    |
 /     |                             /     |
+------+------------------->        +------+------------------->
  操作成本下降                       越权/误操作概率上升

        两条曲线交叉的地方,就是事故开始的地方

为什么非要在适配层做这些验证?因为聊天工具和业务平台的责任模型根本不同。

第一,身份模型不同。Discord 的用户身份是“频道成员”,业务平台的身份是“项目租户下的角色”。一个用户在 Discord 里是管理员,不代表他在某个项目里有部署权限;反过来,一个项目 Owner 也可能不在某个频道里。把 Discord 身份直接映射成业务权限,是最常见的越权来源。

第二,会话模型不同。thread 是聊天上下文,不是任务所有权凭证。一条 thread 里可能有多个人发言,消息的“发送者”和“任务发起者”必须分开记录。否则 A 发起的任务,B 在同一个 thread 里说一句“继续”,适配器就可能用 A 的凭证去执行 B 的意图。

第三,失败语义不同。聊天消息可以重发,业务操作不能重放。部署、删除、跨项目读取,这些动作天然不幂等。适配器如果沿用“消息没回执就重试”的聊天思维,就会把“未知结果”变成“重复执行”。

第四,审计要求不同。聊天记录不是审计日志。谁发起的、以什么身份、经过谁审批、执行结果如何,这些必须由业务平台记录,而不是靠翻 thread。

所以,不是我们想把架构搞复杂,而是入口的便利性把风险放大了,必须用安全边际把它压回去。这就是为什么“能接进去”和“能放心用”之间,隔着一整套授权与审计设计。

04、架构

+-----------+     +---------------------+     +----------------------+
| Discord   |     |  适配层 Adapter     |     |  业务平台 Harness    |
| 用户/频道 |---->|                     |---->|                      |
+-----------+     | 1. 身份绑定校验     |     | 4. 任务状态机        |
                  | 2. thread<->session |     | 5. 最小权限凭证      |
                  | 3. 幂等键 + 审计    |     | 6. 审批与超时拒绝    |
                  +---------------------+     +----------------------+
                           |                            |
                           v                            v
                  +---------------------+     +----------------------+
                  | 默认拒绝 / 白名单   |     | 审计事件存储         |
                  +---------------------+     +----------------------+

整体分三层:入口层(Discord)、适配层(Adapter)、业务层(Harness)。核心原则是:适配层只做“翻译和拦截”,不做“授权和放行”。授权判断必须回到业务平台,用业务平台的身份和权限模型来裁决。

第一层:身份绑定。 把 Discord 用户和频道,与项目租户身份做显式绑定。绑定关系持久化,采用默认拒绝 + 服务器白名单。没有绑定关系的用户,适配器直接拒绝,不进入业务层。频道也要绑定到租户,避免“一个频道能操作所有项目”。

第二层:会话映射。 thread 与后端 session 一对一映射并持久化。每条消息进入适配器时,先查这个 thread 对应的 session 和租户,再校验“当前发言者”是否拥有该 session 的操作所有权。注意,是每次操作都校验,而不是建 thread 时校验一次。

第三层:风险动作审批。 生产变更类操作,设置发起者二次确认、超时拒绝和最小权限凭证。适配器拿到的凭证只覆盖本次任务所需的最小范围,任务结束即失效。审批不通过或超时,一律拒绝,不做“默认放行”。

第四层:幂等与审计。 提交任务时附幂等键,失败重试前先查任务状态,而不是直接重发。所有关键动作记录审计事件:谁、在哪个 thread、以什么身份、发起了什么、审批人是谁、结果如何。

这套架构的关键不是组件多,而是每个高风险动作都有独立的验证点。消息能到达只是起点,能不能执行,由后面的验证点说了算。

05、实战

  消息进入
     |
     v
 [1] 用户/频道绑定? --否--> 拒绝 + 审计
     |是
     v
 [2] thread<->session 存在? --否--> 拒绝 + 审计
     |是
     v
 [3] 发言者拥有该 session? --否--> 拒绝 + 审计
     |是
     v
 [4] 高风险动作? --是--> 发起者确认 + 超时拒绝 + 最小权限凭证
     |否                          |
     v                            v
 [5] 幂等键查重 --> 已存在? --> 返回已有任务状态
     |否
     v
 [6] 提交任务 + 记录审计事件
     |
     v
 [7] 失败重试前先查状态,不盲目重发

下面按四块讲落地细节,都是我们在适配器里真实做过的。

第一块:身份绑定与默认拒绝。

我们维护两张表:discord_user_binding 和 discord_channel_binding。前者记录 Discord 用户 ID 到平台用户 ID、租户 ID 的映射;后者记录频道 ID 到租户 ID、项目 ID 的映射。适配器收到消息后,第一步就是查这两张表,任何一张查不到,直接拒绝并写审计事件。

这里有个容易忽略的点:服务器白名单。Discord 机器人可能被拉进多个服务器,默认应该只服务白名单内的服务器。不在白名单的服务器,连绑定查询都不做,直接丢弃。这就是“默认拒绝”的工程含义——不是配置一个黑名单,而是配置一个白名单,其余全部拒绝。

第二块:thread 与 session 一对一映射。

每条 thread 创建时,适配器在数据库里写一条 thread_session 记录:thread_id、session_id、tenant_id、owner_user_id、created_at。之后这条 thread 里的每一条消息,都要先查这条记录,再校验发言者是否等于 owner,或者是否被显式授权为协作者。

为什么要持久化?因为适配器可能重启,内存里的映射会丢。持久化之后,重启恢复也能继续校验所有权。另外,session 要有明确的生命周期:thread 归档或超时后,session 置为失效,后续消息一律拒绝,避免“僵尸会话”被继续利用。

第三块:生产变更的审批与最小权限。

我们把动作分成两类:只读类和变更类。只读类(查日志、看状态)走轻量校验;变更类(部署、删除、扩缩容)必须走审批。审批流程是:适配器发出确认请求,只有发起者本人确认才有效,其他人确认一律无效;确认有超时时间,超时即拒绝;审批通过后,适配器向业务平台申请一个最小权限、短时效的凭证,只覆盖本次任务。

这里的关键是“发起者确认”。同一个 thread 里其他人说“同意”不算数,必须是发起者本人。这听起来有点繁琐,但正是安全边际的体现——高风险动作必须由责任人自己踩一脚刹车。

第四块:幂等键与审计事件。

每次提交任务,适配器生成一个幂等键,规则可以是 tenant_id + thread_id + 消息ID + 动作类型。业务平台侧对幂等键做唯一约束,重复提交直接返回已有任务状态。失败重试时,适配器先调用任务状态查询接口,确认任务确实没成功,才用同一个幂等键重新提交;如果状态是“执行中”或“未知”,一律不重试,转人工确认。

审计事件我们记录了这些字段:事件时间、Discord 用户 ID、平台用户 ID、租户 ID、thread ID、session ID、动作类型、幂等键、审批结果、执行结果。审计事件写入独立存储,不依赖聊天记录。这样出问题时,能快速回答“谁在什么时候以什么身份做了什么”。

边界与验证。

必须说明:以上是我们适配器的设计,不等于所有部署都已接受安全审计。设计正确不代表实现正确,实现正确不代表运行正确。我们给自己定了几个验证指标:未授权调用拦截率、跨租户数据零泄露、重复提交率、审批超时处理正确性、故障恢复后会话映射的正确性。这些指标需要持续跑,而不是上线前测一次。

06、洞见

+--------------------------------------------------------------+
|  聊天工具负责说话,业务平台负责授权和负责。                  |
+--------------------------------------------------------------+
|                                                              |
|  便利性  -->  更多操作  -->  需要更强约束                    |
|  入口      !=   授权中心                                     |
|  消息到达  !=   有权限执行                                   |
|  未知结果  !=   可以重试                                     |
|                                                              |
+--------------------------------------------------------------+

做完这个适配器,我最大的体会是:便利性和安全性不是对立面,而是需要被分开设计的两个维度。入口可以极简,但授权必须极严。把两者混在一起,就会出现“因为入口方便,所以默认它有权限”的错觉。

第二个体会是,“未知结果”是分布式系统里最危险的状态。聊天工具的心智模型是“没收到回复就再发一次”,但业务操作的心智模型必须是“不知道成没成,先查再说”。适配器作为两者之间的翻译层,最重要的职责之一,就是把前者的重试冲动,翻译成后者的状态查询。

第三个体会是,审计不是事后补救,而是设计的一部分。如果一开始没记录“谁发起的、谁审批的、用的什么凭证”,出事之后翻聊天记录是翻不出责任链的。审计事件应该在设计阶段就定好字段,而不是上线后补。

最后一个洞见关于边界:我们展示的是适配器的设计,不是安全认证。任何把执行引擎接进 IM 的方案,都应该明确说清楚“哪些验证做了、哪些没做、验证指标是什么”。承认边界,比宣称安全更有价值。

07、行动

  今天就能做的五件事
  ------------------
  [ ] 1. 建用户/频道绑定表,默认拒绝 + 服务器白名单
  [ ] 2. thread 与 session 一对一持久化,每次操作校验所有权
  [ ] 3. 变更类动作加发起者确认 + 超时拒绝 + 最小权限凭证
  [ ] 4. 提交任务附幂等键,重试前先查状态
  [ ] 5. 定义审计事件字段并落库,不依赖聊天记录

如果你也在把执行引擎接进聊天工具,建议按这个顺序推进:

第一步,先做身份绑定和默认拒绝。这是成本最低、收益最高的一步。没有绑定关系的用户和频道,一律拒绝,先把最大的口子堵上。

第二步,做 thread 与 session 的持久化映射,并把所有权校验放到每次操作上。不要在建 thread 时校验一次就完事,要在每条消息进入时都校验。

第三步,把动作分级,变更类动作加审批。发起者确认、超时拒绝、最小权限凭证,这三件事缺一不可。

第四步,加幂等键和状态查询。所有提交类接口都要支持幂等键,所有重试逻辑都要先查状态。

第五步,定义审计事件并落库。字段至少覆盖:谁、什么身份、哪个租户、哪个会话、什么动作、审批结果、执行结果。

最后,给自己定一组验证指标,定期跑:未授权调用拦截率、跨租户数据泄露为零、重复提交率、审批超时处理正确性、恢复正确性。指标比口号更能说明问题。

08、小结

+--------------------------------------------------------------+
|                                                              |
|   聊天工具负责说话,业务平台负责授权和负责。                 |
|                                                              |
|   入口越顺手,越要把刹车装在业务侧。                         |
|                                                              |
+--------------------------------------------------------------+

便利性会诱导更多操作,这是激励机制;让风险动作经过最小权限、审批和审计,这是安全边际。Discord 是便捷入口,不是天然授权中心。身份、任务归属和高风险审批,必须在适配层或服务端独立验证。消息能到达,不意味着发言者有权限部署、删除或读取跨项目数据;适配器更不能把“未知结果”当成可重试。

把这两句话记住,适配器的设计就不会跑偏:聊天工具负责说话,业务平台负责授权和负责。

09、链接与概念说明

  概念澄清
  --------
  激励机制:便利性 -> 更多操作 -> 需要更强责任约束
  安全边际:风险动作 -> 最小权限 -> 审批 -> 审计
  默认拒绝:白名单之外一律拒绝,而非黑名单之外一律放行
  幂等键:同一业务意图的唯一标识,用于去重和状态查询
  未知结果:超时/中断/5xx 等无法判定成败的状态,禁止直接重试

概念澄清:

  • 激励机制:本文指便利性会诱导更多操作,越顺手越需要责任约束。它不是心理学概念,而是系统设计中的行为引导问题。
  • 安全边际:本文指让风险动作经过最小权限、审批和审计,为误操作留出容错空间。
  • 默认拒绝:不是配置黑名单,而是配置白名单,白名单之外全部拒绝。
  • 幂等键:同一业务意图的唯一标识,用于重复提交去重和失败重试前的状态查询。
  • 未知结果:超时、网络中断、后端 5xx 等无法判定成败的状态,禁止直接重试,必须先查状态。

延伸阅读:

以上链接为本文参考来源,具体实现以各仓库和文档为准。

来源:博客园 · cnblogs.com