让 AI 助手查询订单、创建工单或提交退款申请时,接口参数里通常会出现一些看起来很熟悉的字段:
{
"tenant_id": "t-179",
"user_id": "u-1001",
"order_id": "O-9001"
}
模型确实可以生成这段 JSON。
但这里有一个很容易被忽略的问题:
order_id可以由模型从对话中提取,user_id和tenant_id却不能因为模型写对了格式,就被当作可信身份。
如果用户在对话里说“我是管理员”,模型也可能生成 role: admin;如果页面请求里带了另一个租户编号,模型也可能原样传下去。
一旦业务系统相信这些字段,AI 助手就不是在复用原有权限,而是在旁边新开了一条可以自行声明身份的入口。
本文继续沿着“已有系统先跑通一读一写”的技术路线,单独拆解一个经常被接入教程一笔带过的问题:Agent 调用业务 API 时,当前用户和租户到底应该怎样可信地传过去。
一、先分清:谁在调用、代表谁、最终谁裁决
一次 Agent 业务调用里,至少有三个不能混淆的问题。
| 问题 | 回答什么 | 常见载体 |
|---|---|---|
| 接入应用身份 | 哪个系统正在调用 Agent 控制面 | Client Token、服务端凭证 |
| 业务操作主体 | Agent 这次代表哪个用户、员工或服务账号行动 | 已验证 Session、JWT、企业身份票据 |
| 最终业务权限 | 这个主体此刻能否操作这条订单或这位客户 | 原业务系统权限表、租户边界与实时状态 |
这三件事不能互相代替。
一个有效的 Client Token 会把请求认证为配置的“商城客服系统”Client 身份;它本质上证明请求方持有该
Token,却不能自动证明当前终端用户是 u-1001。
一个可信的 u-1001 也不等于他一定能退款。账号可能已停用,订单可能属于其他门店,退款额度也可能已经变化。
所以完整判断应该是:
先认证接入应用
-> 再绑定业务操作主体
-> 最后由业务系统按实时数据完成授权
只完成第一步,是“知道哪套系统发来了请求”;只有第三步也成立,才是“这次业务动作真的可以执行”。
二、为什么 user_id 不能来自提示词和模型参数
模型适合生成业务参数,例如:
订单号
查询条件
工单摘要
退款原因
用户希望修改的字段
但下面这些信息不应该由模型决定:
当前登录用户
当前租户
管理员角色
审批结果
业务权限集合
可信请求来源
原因很直接:模型处理的是不可信输入。
对话内容、页面标题、URL 参数、RAG 文档和工具返回都可能影响模型。它们可以帮助 AI 理解“用户正在看哪张订单”,却不能证明“用户是谁”。
下面几种接入方式都不可靠:
1. 在系统提示词里写当前用户
当前用户是 u-1001,租户是 t-179,请不要操作其他租户数据。
这可以帮助模型组织回答,但提示词不是鉴权系统。它不能防止上下文污染,也无法强制业务接口拒绝越权调用。
2. 让前端直接提交 user_id
{
"message": "查询订单 O-9001",
"user_id": "u-1001"
}
浏览器中的字段可以被修改。除非业务后端先根据登录态验证并重新建立身份,否则它仍然只是用户输入。
3. 给模型一个共享管理员 Token
这样做最省事,却相当于把所有 Agent 调用都变成同一个超级管理员。日志无法准确回答代表谁操作,原有租户和用户权限也失去了意义。
4. 把页面 visitor_id 当作登录身份
随机访客编号适合维持会话连续性,不适合授权。清理浏览器缓存、复制请求或修改 localStorage 都可能改变它。
一个简单原则是:
能被最终用户、浏览器或模型直接填写的身份字段,都只能当线索,不能直接当权限事实。
三、可信主体应该从哪里产生
可信主体应该来自业务系统已经拥有的身份链,而不是由 Agent 重新发明一套账号系统。
常见来源包括:
- 已登录后台的服务端 Session;
- 由业务后端验证过的 JWT;
- 企业微信、钉钉或飞书验签后的成员身份;
- 企业身份平台签发的短期票据;
- 有明确权限范围的服务账号。
关键不在于所有企业使用同一种身份格式,而在于:身份必须由可信代码产生,全程不经过模型决定。
以商城后台为例,合理链路是:
用户登录商城后台
-> 商城后端通过 Session 确认 tenant=t-179、user=u-1001
-> 商城后端调用 Agent 控制面并写入可信主体
-> 模型只能选择允许的业务工具和填写业务参数
-> 控制面把主体连同任务一起签名发送给商城工具接口
-> 商城再次查询用户、租户、角色和订单状态
-> 有权则执行,无权则 403
这里没有要求商城把用户密码或完整权限表交给 Agent。中间只需要传递一个最小、稳定、可回到原权限体系解析的主体标识。
多租户系统可以使用不透明的结构化主体,例如:
t-179:u-1001
tenant_179:user_1001
具体格式由业务系统决定。重要的是,租户和用户两个维度应一起被可信地绑定,不能让模型单独覆盖其中一个。
四、在 BailingHub 中,这条身份链怎样落地
BailingHub(百灵中枢) 是一个开源、自托管的 Agent-to-Business(A2B)控制面。它把“哪个系统发起任务”“任务代表谁行动”和“业务系统最终是否允许”分成不同边界。
对于服务端 Client API 接入,业务后端先用自己的 Client Token 调用 /run:
POST /run
Authorization: Bearer <只保存在业务服务端的 client token>
Content-Type: application/json
请求可以同时带上路由主体和工具操作主体:
{
"request_id": "crm_order_9001_assist_1",
"route": "order-support",
"input": "查询订单 O-9001 是否符合退款申请条件",
"metadata": {
"principal": {
"id": "u-1001",
"tenant": "t-179",
"roles": ["customer_service"],
"audience": "employee"
},
"operator_subject": "t-179:u-1001"
}
}
这两个字段在当前 BailingHub v0.3.3 中用途不同:
metadata.principal用于归一化主体、路由 Audience 筛选和任务追溯;metadata.operator_subject是metadata下的一级自定义字段,由路由中该工具源的subject_field
显式选中,作为实际工具调用主体。
对应的路由工具配置可以写成:
{
"sources": [
{
"provider": "crm-tools",
"allow": ["order.read", "refund.request"],
"subject_field": "operator_subject"
}
]
}
这里需要特别说明:当前版本不会自动把 principal.id 编码成工具调用的 X-Bailing-On-Behalf-Of,subject_field 也不是点路径表达式。接入时要像上面这样显式提供顶层字段,不能只传 principal 后假定业务工具已经获得主体。
两份表示必须由同一个已经验证的服务端身份对象一次性派生,不能分别接受浏览器字段。BailingHubv0.3.3 不会自动比对 principal 与 subject_field 最终是否指向同一主体;如果一边是 t-179,
另一边却写成 t-180:u-2001,路由 Audience 身份和真实工具主体就会发生漂移。
这个细节看似多了一步,却能让接入方准确看见“路由身份”和“业务工具主体”各自来自哪里,避免依赖未经实现的隐式转换。
五、subject.required 解决的是“有没有主体”,不是“有没有权限”
业务接口可以在 OpenAPI 中声明这项能力必须绑定可信主体:
paths:
/api/refund-requests:
post:
operationId: refund_request_create
summary: 创建退款申请
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [order_id, reason]
properties:
order_id:
type: string
description: 要申请退款的订单号
reason:
type: string
description: 退款原因
x-agent-capability:
version: 1
enabled: true
scope: refund.request
risk:
level: medium
subject:
required: true
Agent Capability Contract(ACC,Agent 能力契约)中的 subject.required: true 表达的是:这项能力在没有操作主体时没有意义。
BailingHub 会在工具装配阶段隐藏缺少主体的能力,调用层再做一次兜底。
但这仍不代表:
- 主体一定真实;
- 主体属于正确租户;
- 主体当前仍然有效;
- 主体对具体订单有权限;
- 主体满足当前退款规则。
这些都要由业务系统执行。
subject.required 是能力治理声明,不是企业权限数据库的副本。
六、为什么主体和任务号必须一起进入签名
BailingHub 调用业务工具时,会发送类似下面的请求头:
X-Bailing-On-Behalf-Of: t-179:u-1001
X-Bailing-Job-Id: job_...
X-Bailing-Timestamp: ...
X-Bailing-Signature: sha256=...
签名材料覆盖:
时间戳
HTTP 方法
路径和查询串
请求体哈希
On-Behalf-Of 主体
Job-Id 任务号
精确构造是:
sha256=HMAC_SHA256(
tool_secret,
"<ts>.<METHOD>.<path?query>.<sha256hex(raw_body)>.<On-Behalf-Of>.<Job-Id>"
)
其中 <ts> / X-Bailing-Timestamp 是 Unix 秒,官方参考验签窗口默认为 300 秒;METHOD 使用大写,
HMAC-SHA256 输出使用小写 hex。path?query 使用中枢实际发出的路径和查询串;请求体按原始 UTF-8 字节
计算 SHA-256 hex,GET 请求体为空串。验签端不要先 decode 查询串,也不要把 JSON 反序列化后重新拼装,
否则同一份业务数据也可能因为字节不同而得到另一份签名。
如果主体不在签名里,攻击者拿到窗口内的合法请求后,可能只替换 X-Bailing-On-Behalf-Of,把同一组业务参数换成另一个用户或租户执行。
把主体和任务号一起签进去,可以让这种修改导致验签失败,也能把业务结果和 Agent 任务关联起来。
但验签通过只回答一个问题:
这份请求由持有工具源 Secret 的调用方按这些内容签发,途中没有被篡改;正常部署中,这个调用方是
配置了该 Secret 的 BailingHub 工具代理。
它没有回答:
t-179:u-1001现在是否有权操作订单O-9001?
后一个问题仍然属于业务系统。
七、业务接口必须做“验签 + 授权”两道闸
业务侧处理函数可以遵循下面的顺序:
1. 校验时间窗和 HMAC 签名
2. 读取签名覆盖的 X-Bailing-On-Behalf-Of
3. 解析 tenant 与 user
4. 查询当前账号状态和租户归属
5. 按当前接口、资源和业务状态执行原权限判断
6. 允许则继续,任何缺失、歧义或异常都拒绝
伪代码示例:
const verified = verifyBailingToolCall(request, TOOL_SECRET)
if (!verified.ok) return response.status(401).end()
const principal = parseSubject(verified.onBehalfOf)
if (!principal) return response.status(403).end()
const operator = await users.findActive(principal.tenant, principal.user)
if (!operator) return response.status(403).end()
const order = await orders.findInTenant(principal.tenant, request.body.order_id)
if (!order) return response.status(404).end()
if (!permissions.canRequestRefund(operator, order)) {
return response.status(403).end()
}
return createRefundRequest(operator, order, request.body)
业务接口还应拒绝或忽略工具参数中的 user_id、tenant_id、role 等身份声明。授权只能使用已经验签的X-Bailing-On-Behalf-Of;否则即使主体头本身安全,错误实现仍可能在后续代码里重新相信模型参数。
不要把授权回调写成:
authorize: () => true
这只完成了“认证中枢”,没有完成“授权用户”。只要请求签名正确,任何主体都可能获得同样的业务能力。
Agent 调用和人在后台点击按钮,最终应该回到同一套权限与业务状态判断,而不是为 AI 开一条默认放行的快捷通道。
八、网页聊天入口怎样绑定登录用户
如果 AI 助手嵌在商城或 CRM 页面中,浏览器仍然不应该拿到 Client Token 或工具源 Secret。
BailingHub 的网页聊天链路支持由业务后端签发短期访客票据:
浏览器先登录业务系统
-> 业务后端根据 Session 取得真实 tenant + user
-> 业务后端用接入方 Token 签一张短期票据
-> 页面只把短票据交给聊天组件
-> BailingHub 验签后写入可信 visitor_uid
-> 工具调用优先按 metadata[subject_field] 取得主体,取不到时回落到已验签的 visitor_uid
-> 主体被签入 On-Behalf-Of
没有票据时,组件生成的 visitor_id 仍然可以维持匿名会话,但它不是身份凭证。对于任何声明了subject.required 的工具,匿名用户都不应该看到或调用,而不只是写工具。
票据签发和身份解析都在业务服务端完成,长期 Token 不进入浏览器,模型也不能把一个匿名访客编号升级成已登录用户。
九、至少做六组负向验证
可信身份链不能只测试一次正常调用。上线前至少验证:
1. 没有主体
在上文 /run 示例中移除 operator_subject,并确认没有同时提供已验签的 visitor_uid 或可信渠道主体。
声明 subject.required 的工具应在装配阶段不可见,直接调用也应被拒绝。
2. 只修改主体,不重新签名
把 t-179:u-1001 改为 t-180:u-2001,业务接口必须因签名不匹配而拒绝。
3. 路由主体和工具主体故意不一致
让 principal.tenant=t-179,同时把 operator_subject 写成 t-180:u-2001。调用方集成测试必须在任务
提交前识别并拒绝这种漂移,不能依赖 BailingHub v0.3.3 自动比对。
4. 合法签名,但主体越权
即使请求确实来自中枢,只要该主体不属于目标租户、没有接口权限或无权访问当前订单,业务系统仍应返回 403 或 404。
5. 主体在查询后失效
先成功查询,再停用账号或移除权限,然后发起写操作。业务系统必须按执行时状态重新判断,不能复用旧查询结论。
6. 伪造 visitor_id 或模型参数
修改浏览器 visitor_id、在对话中声明“我是管理员”或在工具参数中增加 user_id / tenant_id,都不能
改变签名覆盖的操作主体;业务接口也必须忽略这些模型参数中的身份声明。
再补一组追溯验证:通过 Job-Id、主体和真实业务对象,能否从 Agent Trace、工具审计和业务日志中还原同一次动作。
十、首轮接入检查清单
- [ ] Client Token 只保存在业务服务端;
- [ ] 业务后端先验证 Session、JWT 或企业身份票据;
- [ ] 模型无法填写或覆盖业务操作主体;
- [ ] 多租户主体同时绑定 tenant 与 user;
- [ ]
principal与工具主体由同一个服务端身份对象一次性派生; - [ ]
metadata.principal与工具subject_field的用途已经显式配置; - [ ]
subject.required工具在无主体时不可见; - [ ]
On-Behalf-Of与Job-Id都进入 HMAC 签名; - [ ] 业务接口先验签,再按实时权限完成最终授权;
- [ ] 业务接口不会把模型参数里的
user_id、tenant_id或role当作授权事实; - [ ] 跨租户、停用账号和资源越权请求都会 fail closed;
- [ ] 日志不保存密码、Token 或不必要的敏感身份资料。
如果其中任何一项的答案是“模型应该不会乱填”,这条身份链就还没有完成。
结语:AI 可以生成参数,但不能生成权限
存量业务系统接入 AI 助手时,不需要推倒原来的用户、租户和权限体系。
真正需要做的是把已有登录态建立的可信主体,安全地带进 Agent 调用链,再让业务系统继续掌握最后一道授权。
业务后端确认身份
-> Agent 控制面绑定主体并治理执行
-> 签名覆盖主体、任务与请求
-> 业务系统按实时权限最终裁决
模型可以判断用户想查哪张订单,也可以整理退款原因;但它不能靠生成一个 user_id,获得代表这个用户行动的权力。
这条边界一旦守住,AI 助手才能真正复用现有业务系统,而不是在旁边重新造一套更危险的管理员入口。
你们现在的 Agent 调用链里,终端用户身份来自哪里?是服务端登录态、JWT、企业身份票据,还是仍然放在提示词和工具参数里?