若依框架和阿里AgentScope的权限封装
若依框架和阿里AgentScope的权限封装

一、背景
在若依体系中,后台接口通常使用 Spring Security 的 @PreAuthorize 进行权限控制:
@PreAuthorize("@ss.hasPermission('water-meter:order:query')")
在接入阿里 AgentScope Java 后,我们还可以使用 @Tool 将 Java 方法注册成大模型工具:
@Tool(
name = "get_water_order",
description = "按订单编号查询一笔水费订单",
readOnly = true
)
public String getWaterOrder(Long orderId) {
// 查询订单
}
但仅仅把 @PreAuthorize 和 @Tool 放在同一个方法上,并不能完整解决 Agent 工具权限问题。
@PreAuthorize 发生在工具真正执行时。在此之前,工具名称、描述和参数 JSON Schema 可能已经发送给大模型。无权限用户虽然无法成功执行工具,但仍然可能在模型上下文中看到工具能力。
本文要实现的目标是:
当前对话用户没有某个权限时,对应 Tool Schema 不得进入本轮模型上下文,而不只是等到工具执行时再拒绝。
同时还需要满足以下约束:
HarnessAgent是应用级单例。Toolkit在启动时一次性构建,不能按用户重复创建。- 同一个
(userId, sessionId)的请求由 AgentScope 串行处理,不同会话可以并行。 - 不同用户并发访问时,工具权限不能相互污染。
- 工具执行阶段仍然保留
@PreAuthorize作为第二道防线。
二、为什么不能只使用 @PreAuthorize
一次典型的 Agent 工具调用分为两个阶段。
1. 模型推理阶段
应用把 Tool Schema 发送给模型:
{
"name": "get_water_order",
"description": "按订单编号查询一笔水费订单",
"parameters": {
"type": "object",
"properties": {
"orderId": {
"type": "integer"
}
}
}
}
模型根据用户问题以及可见的 Tool Schema,决定是否生成工具调用。
2. 工具执行阶段
模型生成工具调用后,AgentScope 找到对应的 AgentTool 并执行 Java 方法。Spring Method Security 才会在这个阶段处理 @PreAuthorize。
因此:
Tool Schema 发送给模型
↓
模型选择工具
↓
执行 Java 方法
↓
@PreAuthorize 校验
如果只依赖 @PreAuthorize,权限校验发生得太晚。
三、为什么不使用 @Aspect + @Around
很多开发者看到“注解权限”后,第一反应是编写 Spring AOP:
@Pointcut("@annotation(AgentToolPermission)")
public void agentToolPermissionPointcut() {
}
@Around("agentToolPermissionPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 权限判断
return joinPoint.proceed();
}
这种实现仍然只能拦截 Java 方法执行。等环绕通知被触发时,Tool Schema 已经发送给模型,因此它只能实现“无权限时不允许执行”,无法实现“无权限时模型根本看不到工具”。
如果把切点放在 Controller:
@Around("execution(* com.example.agent.controller..*(..))")
虽然能够拦截对话接口,但此时 AgentScope 还没有组装 ReasoningInput.tools,切面也拿不到本轮即将发送给模型的 Tool Schema。
AgentScope 官方提供的 Middleware 才是正确扩展点。onReasoning 位于每轮 ReAct 推理过程,并且能够读取和替换:
ReasoningInput(
List<Msg> messages,
List<ToolSchema> tools,
GenerateOptions options
)
因此可以在模型调用前重新构造 ReasoningInput,只保留当前用户允许看到的工具。
四、总体设计
完整实现分为启动阶段和请求阶段。
1. 启动阶段
系统启动时:
- 单例
Toolkit注册全部业务工具。 - 扫描工具方法上的
@AgentToolPermission。 - 建立“权限标识 → 工具名称”的不可变索引。
- 创建单例
HarnessAgent和单例权限 Middleware。
例如:
water-meter:order:query
├── get_water_order
└── get_latest_water_order_by_meter_code
water-meter:order:settle-abnormal
└── settle_abnormal_water_order
这里建立的不是用户权限,而是权限标识与 Tool Schema 的静态映射。
2. 请求阶段
每次后台用户发起对话时:
- 在 HTTP 请求线程调用若依权限服务。
- 生成当前用户不可变的工具权限快照。
- 将快照放入本次调用独有的
RuntimeContext。 onReasoning从RuntimeContext读取快照。- 过滤
ReasoningInput.tools。 - 将过滤后的 Tool Schema 发送给模型。
- 工具真正执行时,再由
@PreAuthorize复核权限。
整体流程如下:
后台登录用户
↓
SecurityFrameworkService.hasPermission(...)
↓
AgentToolAccessContext 权限快照
↓
RuntimeContext(per-call)
↓
AgentToolPermissionMiddleware.onReasoning(...)
↓
过滤 ReasoningInput.tools
↓
模型只看到有权限的 Tool Schema
↓
工具执行时再次经过 @PreAuthorize
五、RuntimeContext 不是模型上下文
RuntimeContext 中的 “Context” 很容易被误认为大模型上下文窗口。
模型实际获得的输入主要是:
ReasoningInput
├── messages
├── tools
└── options
而 RuntimeContext 是 AgentScope 的服务端运行上下文:
RuntimeContext
├── userId
├── sessionId
├── AgentState 引用
└── extra 请求级数据
写入 RuntimeContext.extra 的 Java 对象不会自动进入 messages、system prompt 或 Tool Schema,也不会自动发送给模型。
权限信息放入 RuntimeContext 有三个原因:
HarnessAgent和 Middleware 是单例,不能把当前用户权限写进实例成员变量。- 模型调用和工具执行可能切换到 Reactor 工作线程,不能一直依赖原 HTTP 线程的
SecurityContextHolder。 RuntimeContext是每次call或streamEvents独立的,适合在本次调用的 Middleware 和 Tool 之间传递服务端数据。
六、声明工具权限注解
定义一个只负责保存权限标识的注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AgentToolPermission {
/**
* 与 @PreAuthorize 中使用的若依权限标识保持一致。
*/
String value();
}
该注解不是 AOP 通知,不会主动执行任何逻辑。它只是声明式元数据,由权限解析器在启动时通过反射读取。
七、定义请求级权限快照
public record AgentToolAccessContext(
List<String> activatedGroups,
Authentication authentication,
Long tenantId) {
public AgentToolAccessContext {
activatedGroups = activatedGroups == null
? List.of()
: List.copyOf(activatedGroups);
}
}
需要注意:
- 集合使用
List.copyOf创建不可变副本。 authentication只用于工具执行线程临时恢复 Spring Security 上下文。- 不保存 Bearer Token 和密码。
- 该对象不会主动序列化进 AgentState。
八、启动时建立权限和工具的映射
权限解析器扫描 Spring 工具 Bean 的目标类:
public String resolveRequiredPermission(Object toolCandidate) {
Class<?> targetClass = AopUtils.getTargetClass(toolCandidate);
Set<String> permissions = new LinkedHashSet<>();
for (Method method : targetClass.getMethods()) {
AgentToolPermission permission =
method.getAnnotation(AgentToolPermission.class);
if (permission != null) {
if (StrUtils.isBlank(permission.value())) {
throw new IllegalStateException(
"@AgentToolPermission 权限标识不能为空");
}
permissions.add(permission.value());
}
}
if (permissions.size() > 1) {
throw new IllegalStateException(
"同一个工具类混合了多个权限域,请按权限拆分工具类");
}
return permissions.stream().findFirst().orElse(null);
}
建议同一个工具类只属于一个权限域。例如:
WaterMeterOrderQueryTools
→ water-meter:order:query
WaterMeterOrderSettlementTools
→ water-meter:order:settle-abnormal
查询和写操作拆分后,可以避免只拥有查询权限的用户看到结算工具。
九、每次请求计算当前用户权限
public AgentToolAccessContext resolveCurrentUserAccessContext() {
List<String> activatedGroups = permissionGroupMappings.entrySet()
.stream()
.filter(entry ->
securityFrameworkService.hasPermission(entry.getKey()))
.map(Map.Entry::getValue)
.toList();
Authentication source =
SecurityContextHolder.getContext().getAuthentication();
Authentication snapshot = source == null
? null
: new UsernamePasswordAuthenticationToken(
source.getPrincipal(),
null,
source.getAuthorities());
return new AgentToolAccessContext(
activatedGroups,
snapshot,
TenantContextHolder.getTenantId());
}
这里复用了若依框架的:
SecurityFrameworkService.hasPermission(permission)
因此其权限语义与:
@PreAuthorize("@ss.hasPermission('xxx')")
保持一致。
用户权限不是在应用启动时缓存的,而是在每次对话请求进入时重新计算。
十、通过 Middleware 过滤 Tool Schema
@Slf4j
public class AgentToolPermissionMiddleware implements MiddlewareBase {
private final AgentToolPermissionResolver permissionResolver;
@Autowired
public AgentToolPermissionMiddleware(
AgentToolPermissionResolver permissionResolver) {
this.permissionResolver = permissionResolver;
}
@Override
public Flux<AgentEvent> onReasoning(
Agent agent,
RuntimeContext context,
ReasoningInput input,
Function<ReasoningInput, Flux<AgentEvent>> next) {
AgentToolAccessContext accessContext =
context.get(AgentToolAccessContext.class);
List<String> activatedGroups = accessContext == null
? List.of()
: accessContext.activatedGroups();
List<ToolSchema> visibleTools =
permissionResolver.filterVisibleToolSchemas(
input.tools(),
activatedGroups);
ReasoningInput filteredInput = new ReasoningInput(
input.messages(),
visibleTools,
input.options());
return next.apply(filteredInput);
}
}
缺少权限快照时使用空权限集合,只保留公共工具,这是一种 fail-closed 策略。
即使未来增加新的非 HTTP 调用入口,只要调用方忘记设置权限快照,也不会默认暴露全部工具。
十一、为什么不动态修改单例 Toolkit
不建议针对每个用户调用共享 Toolkit 的全局工具激活方法。
原因是:
HarnessAgent是单例。Toolkit也是该 Agent 的共享配置。- 不同
(userId, sessionId)可以并发运行。 - 如果把用户 A 的激活组写进共享 Toolkit,用户 B 可能覆盖该状态。
本文的实现不修改 Toolkit:
共享 Toolkit:始终保存完整工具定义
请求 A:生成 visibleTools(A)
请求 B:生成 visibleTools(B)
两份列表互不修改,也不写入共享状态
因此既保留了单例 HarnessAgent,又实现了请求级 Schema 隔离。
十二、在 Runner 中写入权限快照
public class WaterMeterHarnessAgentRunner {
private final HarnessAgent harnessAgent;
private final AgentToolPermissionResolver permissionResolver;
@Autowired
public WaterMeterHarnessAgentRunner(
HarnessAgent harnessAgent,
AgentToolPermissionResolver permissionResolver) {
this.harnessAgent = harnessAgent;
this.permissionResolver = permissionResolver;
}
public Flux<AgentEvent> streamEvents(
UserMessage userMessage,
RuntimeContext runtimeContext) {
AgentToolAccessContext accessContext =
permissionResolver.resolveCurrentUserAccessContext();
runtimeContext.put(
AgentToolAccessContext.class,
accessContext);
return harnessAgent.streamEvents(
userMessage,
runtimeContext);
}
}
权限快照必须在 HTTP 请求线程中生成。此时 Spring Security 登录上下文仍然有效。
十三、声明查询工具
@Component
public class WaterMeterOrderQueryTools {
@Autowired
private WaterOrderService waterOrderService;
@Tool(
name = "get_water_order",
description = "按订单编号查询一笔水费订单",
readOnly = true
)
@AgentToolPermission("water-meter:order:query")
@PreAuthorize(
"@ss.hasPermission('water-meter:order:query')")
public String getWaterOrder(
@ToolParam(
name = "orderId",
description = "水费订单编号")
Long orderId) {
if (orderId == null) {
return "订单编号不能为空。";
}
WaterOrderDO order =
waterOrderService.getAdminWaterOrder(orderId);
return order == null
? "未查询到该水费订单。"
: JsonUtils.toJsonString(order);
}
}
三个注解的职责分别是:
| 注解 | 使用方 | 作用 |
|---|---|---|
@Tool | AgentScope | 生成工具名称、描述和参数 Schema |
@AgentToolPermission | 自定义 Resolver/Middleware | 决定谁能在模型上下文中看到 Schema |
@PreAuthorize | Spring Method Security | 工具执行时再次检查权限 |
十四、Spring AOP 代理与 AgentScope 反射
当工具方法带有 @PreAuthorize 时,Spring 通常会为工具 Bean 创建 AOP 代理。
这里会出现一个兼容问题:
- AgentScope 需要扫描目标类上的
@Tool和@ToolParam生成 Schema。 - 工具执行又必须调用 Spring 代理,才能触发
@PreAuthorize。 - 如果直接扫描代理类,代理生成的方法不一定保留目标方法的
@Tool注解。 - 如果直接调用原始 target,又会绕过 Spring Method Security。
解决方案是自定义 AgentTool 适配器:
目标类方法
→ 用于生成 AgentScope Tool Schema
Spring 代理对象
→ 用于真正执行工具
→ 触发 @PreAuthorize、事务等 AOP
这可以概括为:
目标类产 Schema,Spring 代理做执行。
工具执行线程还需要短暂恢复请求中保存的认证快照和租户上下文,并在 finally 中清理,避免线程池身份串用。
十五、单例 HarnessAgent 配置
@Bean(destroyMethod = "close")
public HarnessAgent waterMeterHarnessAgent(
AgentRagProperties properties,
AgentToolPermissionResolver permissionResolver,
AgentToolPermissionMiddleware permissionMiddleware,
WaterMeterOrderQueryTools queryTools,
WaterMeterOrderSettlementTools settlementTools) {
Toolkit toolkit = new Toolkit();
// 启动时一次性注册全部工具。
registerPermissionTools(
toolkit,
permissionResolver,
queryTools,
settlementTools);
return HarnessAgent.builder()
.name("water-meter-agent")
.sysPrompt("你是智能水表后台助手。")
.model(buildModel(properties))
.toolkit(toolkit)
.middleware(permissionMiddleware)
.build();
}
整个应用生命周期中只创建一个 HarnessAgent。用户差异只存在于每次调用的 RuntimeContext 和过滤后的 ReasoningInput.tools。
十六、测试权限矩阵
可以准备三类后台角色:
| 角色 | 对话权限 | 订单查询权限 | 异常结算权限 | 模型可见业务工具 |
|---|---|---|---|---|
| 受限角色 | 有 | 无 | 无 | 0 |
| 查询角色 | 有 | 有 | 无 | 2 |
| 结算角色 | 有 | 有 | 有 | 3 |
测试时应验证:
- 受限角色的模型请求中不存在订单工具名称、描述和参数 Schema。
- 查询角色只能看到订单查询工具。
- 结算角色可以看到查询和结算工具。
- 查询角色能够真实执行查询工具并通过
@PreAuthorize。 - 受限角色和结算角色并发请求时,各自的 Tool Schema 不会串权。
- 未附加权限快照的内部调用只能看到公共工具。
十七、常见错误
1. 只在工具执行阶段拦截
这只能防止执行,不能防止 Schema 泄露给模型。
2. 为每个用户创建一套 HarnessAgent
这会破坏官方推荐的单例使用方式,增加模型、状态存储、工具和 Middleware 的生命周期管理成本。
3. 把当前用户权限保存到单例 Middleware 字段
不同用户并发时会相互覆盖,属于严重的越权风险。
4. 直接修改共享 Toolkit 激活状态
请求级权限不应该写入共享可变配置。
5. 删除 @PreAuthorize
Schema 隐藏不是执行授权的替代品。服务端必须保留执行期校验,避免模型伪造、历史 ToolCall 或程序错误绕过可见性控制。
6. 直接注册 Spring AOP 代理
可能导致 AgentScope 无法找到目标方法上的 @Tool 注解。需要同时兼顾目标类 Schema 和代理对象执行。
十八、总结
若依权限系统与 AgentScope Tool 的正确结合方式不是简单地把 @PreAuthorize 放到 @Tool 方法上,而是采用两层权限模型:
第一层:模型调用前
@AgentToolPermission
→ Resolver 建立权限与工具映射
→ Middleware 过滤 Tool Schema
第二层:工具执行时
@PreAuthorize
→ Spring Method Security 再次校验
最终可以同时实现:
- 无权限用户看不到工具名称。
- 无权限用户看不到工具描述。
- 无权限用户看不到参数 JSON Schema。
- 无权限用户无法执行工具。
HarnessAgent和Toolkit保持应用级单例。- 不同用户、不同会话并发时权限互不污染。
这种实现不仅适用于水表订单,也可以扩展到客户查询、财务审批、设备控制、报表导出等任意若依菜单权限场景。
参考资料
- AgentScope Java 快速开始:https://java.agentscope.io/v2/zh/docs/quickstart.html
- AgentScope Java Middleware:https://java.agentscope.io/v2/zh/docs/building-blocks/middleware.html
- AgentScope Java Tool:https://java.agentscope.io/v2/zh/docs/building-blocks/tool.html
- Spring Security Method Security:https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html
更多推荐


所有评论(0)