我们在做一个面向医疗场景的 AI 助手:医护人员在企业微信里跟它对话,Web 端是管理后台。 产品跑通之后,我们回头把权限体系完整梳理了一遍,结果发现——最危险的那条路径,恰恰是最后加进去的那个入口。
这篇复盘那次架构修复:企业微信入口直连智能体引擎,绕过了整套权限体系。 做 AI Agent 落地时,团队 90% 的精力花在"怎么让它更聪明",剩下 10% 才想起来问一句: "它凭什么有权限做这些事?"
一、问题的起点
我们有一个面向医疗场景的 AI 助手产品:医护人员在企业微信里跟它对话,Web 端是管理后台(管理用户绑定、权限配置、数据查看)。
最初的部署拓扑是这样的:
Cloudflare
│
Nginx
┌────────────┼─────────────┐
│ │ │
/api/* /wecom-app /*
:4021 :18789 :4020
NestJS 智能体引擎 Next.js
(RBAC 在此) (管理后台)
看起来挺清晰。但当我们把权限体系完整梳理一遍之后,发现一个致命问题:
/wecom-app这条路径直连智能体引擎,完全绕过了 NestJS 的 RBAC 体系和数据范围控制。
具体意味着什么:
- 一个只应看到本科室患者的账号,通过企微入口可以问出全院数据
- 智能体根本不知道"现在是谁在问",自然也无法施加任何与身份相关的权限
- 整套在 Web 侧精心建好的
RBAC + DataScope,在企业微信这条路上一步都没走
这不是某个函数的疏漏,是架构层面少了一层。
二、为什么会变成这样
复盘下来,这个漏洞是"自然演进"的产物,不是谁写错了代码。
演进路径
第 1 步 先做 Web 平台,权限体系围绕 Web 请求建立
第 2 步 接入智能体引擎,它自带一套 Runtime 和对话入口
第 3 步 为了减少改造,直接在 Nginx 上给智能体开一条独立路径
第 4 步 把这条路径暴露给企业微信 —— 于是身份在入口处就丢了
每一步都合理,合起来就是个洞。
根因
智能体引擎是一个"独立系统",它有自己的一套身份概念(或者说没有身份概念)。
当请求直接打到它身上时:
- 它不知道调用者是谁
- 它不知道调用者能看什么数据
- 它更不知道"这次工具调用该不该被允许"
而这些恰恰是需要在业务侧才能回答的问题——因为权限规则是由业务定义的,不是由智能体框架定义的。
三、核心思路:把「决策」和「执行」拆开
修复的核心,是引入一个在安全架构里很经典、但在 AI Agent 场景里经常被忽略的划分:
PDP Policy Decision Point 策略决策点 —— 判断"能不能做"
PEP Policy Enforcement Point 策略执行点 —— 执行"允许的操作"
我们把这条原则固化成了产品的一条不变量:
平台是 PDP;智能体引擎只执行。签名校验 fail-closed。
翻译成架构语言:
| 谁 | 职责 | 绝对不做的事 |
|---|---|---|
| 平台(NestJS) | 识别身份、判定数据范围、决定这次调用是否放行 | — |
| 智能体引擎 | 拿到已授权的指令,执行对话/工具调用 | 不判断权限(它没有判断依据) |
| 传输层 | 签名校验,验不过就拒绝 | 不放行任何未签名请求 |
修复后的请求路径
企业微信用户发消息
│
▼
平台(PDP)
├─ 解析身份:这个人是谁、属于哪个租户
├─ 施加数据范围:他能看哪些数据
├─ 判断动作:这次工具调用是否被允许
└─ 签名后转发 ──────────► 智能体引擎(PEP)
│
只执行,不决策
关键点:智能体引擎永远拿不到"未经过平台判定的原始请求"。
这样一来:
- 权限逻辑只有一份(在平台),不会出现"Web 侧严、企微侧松"
- 智能体引擎即使被替换(换成另一个框架),权限体系不用重做
- 新增入口(比如钉钉、飞书)时,只要走平台,权限自动生效
四、数据层的隔离:为什么不共用一张库
权限收口只解决了"谁能发起调用"。但还有一个问题:智能体引擎自己的运行态数据,该和业务数据放一起吗?
我们的结论是:独立数据库,但部署在同一个 PostgreSQL 实例上。
PostgreSQL 实例(同一台服务器)
├── agent_core ← 智能体引擎独占
│ ├── 工作流定义
│ ├── Agent 状态
│ └── 工具调用记录
│
└── followup_web ← 业务平台独占
├── 业务主数据
├── 任务与记录
├── 组织与人员
└── 审计日志
交互方式:API 协议,绝不跨库直读对方表
为什么不共用同一个 database
四条理由,按重要性排序:
① Schema 主权冲突
智能体引擎升级时可能自动执行 migration。如果和应用表混在同一个 database,升级动作有可能误删或冲突——这是"数据被第三方工具改坏"的典型场景。
② 安全边界必须能差异化
业务数据需要字段级加密(AES-256),而智能体的运行态数据(工作流定义、执行日志)不需要这个级别。混在一起,加密策略就无法差异化落地。
③ 可替换性
未来如果要更换智能体引擎(不同框架的能力差异很大),只需要切换 agent_core 这一个库,业务数据完全不动。
④ 备份策略不同
| 数据类型 | 备份要求 |
|---|---|
| 业务数据(随访记录等) | 30 天增量备份 + 日志归档 |
| 智能体运行态数据 | 7 天即可 |
为什么不干脆拆成两台服务器
这是个阶段性问题,不是原则问题:
- 早期阶段两台 PG 服务器是过度投入,运维复杂度不划算
- 同一实例内的不同 database 已经有权限边界——
agent_core的数据库用户无权访问业务库的表 - 两个系统之间没有跨库事务需求(通过 API 异步通信)
换句话说:隔离的粒度先做到"库级",等业务量上来再做到"实例级"。但"永不跨库直读"这条从第一天就守住。
唯一的数据交换通道
业务平台 → POST /api/mcp/call → 智能体(发起操作)
智能体 → POST /api/webhooks/callback → 业务平台(回写结果)
业务平台永远不 SELECT 智能体库的表,智能体也永远不直接 INSERT 业务库的记录。
代价是清晰的:无法用一条 SQL 做跨域 JOIN。但这就是设计意图——要跨域,就得走接口,走接口就会留下权限校验和审计记录。
五、传输层:签名必须 fail-closed
那条"唯一通道"如果签名能被绕过,前面所有隔离都白做。
我们的做法是两条:
① 签名校验 fail-closed
签名缺失、无效、或密钥未配置 → 一律拒绝,不存在"校验失败就放行"的降级路径。
这一条看似显然,但在工程实践中特别容易破功:开发期为了方便会加"如果没配密钥就跳过校验",然后这个分支就跟着上线了。所以我们在生产环境做了硬约束:
生产环境(NODE_ENV=production)下,
若签名密钥缺失、为空、或等于文档化的开发默认值 → 拒绝签发与校验
② 签名密钥按租户隔离
多租户场景下,所有租户共用一个签名密钥是个隐患:一个租户的密钥泄漏,等于所有租户的通道都可伪造。
所以每个租户可以配置专用签名密钥,验签方根据请求头里的租户标识选择对应密钥:
每租户专用密钥(缺失时回退全局密钥)
→ 签名方与验签方共用同一套解析逻辑
→ 密钥只存在环境变量里,不入库
这里有个细节值得单独说:"密钥不入库"是一条明令禁止项。 密钥一旦落库,数据库备份、只读副本、审计查询……每一个环节都成了泄漏面。
六、顺带修掉的几个"同源问题"
排查这个漏洞时,我们发现它其实是一类问题的表现。同一批修完的还有:
| 问题 | 修法 |
|---|---|
| 查询条件字段可被客户端任意指定 | 字段白名单,白名单外一律拒绝 |
| 生产可能回退到开发用密钥 | 生产环境硬性 fail-closed |
| 敏感操作没有审计记录 | 登录成功/失败/改密/刷新/登出全部落审计事件 |
| 部署样例里带真实密钥 | 样例文件只允许占位值,进 CI 校验 |
| 边缘代理暴露版本信息 | server_tokens off + HSTS + 安全响应头基线 |
它们共享同一个设计原则:
安全相关的分支,失败时要走向"拒绝",不能走向"放行"。
七、沉淀成四条可复用的判断
做完这次修复,我们把经验收敛成四条判断。它们和具体技术栈无关,可以直接迁移到任何 Agent 项目:
① Agent 不该拥有权限判断能力
它是个执行者。权限判断必须发生在业务侧,因为只有业务侧才知道"谁能看什么"。
如果你发现权限逻辑被写进了 Agent 的 prompt 或工具定义里——那就是走错了方向。
② 每个新入口都要回答"身份从哪来"
每加一个入口(Web、企微、钉钉、API),都要能回答:
这个入口进来的请求,身份在哪里被解析?
数据范围在哪里被施加?
回答不出来的入口,就是新的越权通道。
③ 隔离粒度可以分阶段,但"永不直读"不能妥协
库级隔离 → 实例级隔离可以随业务量升级;但"两个系统之间永不跨库直读"必须从第一天守住——因为这条一旦破,后面所有权限设计都会漏。
④ 安全分支要 fail-closed,且要在生产环境强制
"没配密钥就跳过校验"这类便利分支,必须在生产环境被硬性拒绝,不能只靠开发者自觉。
八、一句话总结
AI Agent 的能力上限由模型决定,但它的权限边界必须由架构决定。
大多数关于 Agent 的讨论都在讲"怎么让它做更多事",而落地到真实业务系统时,更难也更重要的其实是——怎么确保它做不了不该做的事。
作者:阿良 · 苏州畅达软件 —— 后续会写多租户数据隔离与 Agent 操作审计的具体实现,有类似场景欢迎交流。
