若依框架和阿里AgentScope的权限封装
一、背景
在若依体系中,后台接口通常使用 Spring Security 的@PreAuthorize进行权限控制:
@PreAuthorize("@ss.hasPermission('water-meter:order:query')")在接入阿里 AgentScope Java 后,我们还可以使用@Tool将 Java 方法注册成大模型工具:
@Tool(name="get_water_order",description="按订单编号查询一笔水费订单",readOnly=true)publicStringgetWaterOrder(LongorderId){// 查询订单}但仅仅把@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)")publicvoidagentToolPermissionPointcut(){}@Around("agentToolPermissionPointcut()")publicObjectaround(ProceedingJoinPointjoinPoint)throwsThrowable{// 权限判断returnjoinPoint.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,GenerateOptionsoptions)因此可以在模型调用前重新构造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@interfaceAgentToolPermission{/** * 与 @PreAuthorize 中使用的若依权限标识保持一致。 */Stringvalue();}该注解不是 AOP 通知,不会主动执行任何逻辑。它只是声明式元数据,由权限解析器在启动时通过反射读取。
七、定义请求级权限快照
publicrecordAgentToolAccessContext(List<String>activatedGroups,Authenticationauthentication,LongtenantId){publicAgentToolAccessContext{activatedGroups=activatedGroups==null?List.of():List.copyOf(activatedGroups);}}需要注意:
- 集合使用
List.copyOf创建不可变副本。 authentication只用于工具执行线程临时恢复 Spring Security 上下文。- 不保存 Bearer Token 和密码。
- 该对象不会主动序列化进 AgentState。
八、启动时建立权限和工具的映射
权限解析器扫描 Spring 工具 Bean 的目标类:
publicStringresolveRequiredPermission(ObjecttoolCandidate){Class<?>targetClass=AopUtils.getTargetClass(toolCandidate);Set<String>permissions=newLinkedHashSet<>();for(Methodmethod:targetClass.getMethods()){AgentToolPermissionpermission=method.getAnnotation(AgentToolPermission.class);if(permission!=null){if(StrUtils.isBlank(permission.value())){thrownewIllegalStateException("@AgentToolPermission 权限标识不能为空");}permissions.add(permission.value());}}if(permissions.size()>1){thrownewIllegalStateException("同一个工具类混合了多个权限域,请按权限拆分工具类");}returnpermissions.stream().findFirst().orElse(null);}建议同一个工具类只属于一个权限域。例如:
WaterMeterOrderQueryTools → water-meter:order:query WaterMeterOrderSettlementTools → water-meter:order:settle-abnormal查询和写操作拆分后,可以避免只拥有查询权限的用户看到结算工具。
九、每次请求计算当前用户权限
publicAgentToolAccessContextresolveCurrentUserAccessContext(){List<String>activatedGroups=permissionGroupMappings.entrySet().stream().filter(entry->securityFrameworkService.hasPermission(entry.getKey())).map(Map.Entry::getValue).toList();Authenticationsource=SecurityContextHolder.getContext().getAuthentication();Authenticationsnapshot=source==null?null:newUsernamePasswordAuthenticationToken(source.getPrincipal(),null,source.getAuthorities());returnnewAgentToolAccessContext(activatedGroups,snapshot,TenantContextHolder.getTenantId());}这里复用了若依框架的:
SecurityFrameworkService.hasPermission(permission)因此其权限语义与:
@PreAuthorize("@ss.hasPermission('xxx')")保持一致。
用户权限不是在应用启动时缓存的,而是在每次对话请求进入时重新计算。
十、通过 Middleware 过滤 Tool Schema
@Slf4jpublicclassAgentToolPermissionMiddlewareimplementsMiddlewareBase{privatefinalAgentToolPermissionResolverpermissionResolver;@AutowiredpublicAgentToolPermissionMiddleware(AgentToolPermissionResolverpermissionResolver){this.permissionResolver=permissionResolver;}@OverridepublicFlux<AgentEvent>onReasoning(Agentagent,RuntimeContextcontext,ReasoningInputinput,Function<ReasoningInput,Flux<AgentEvent>>next){AgentToolAccessContextaccessContext=context.get(AgentToolAccessContext.class);List<String>activatedGroups=accessContext==null?List.of():accessContext.activatedGroups();List<ToolSchema>visibleTools=permissionResolver.filterVisibleToolSchemas(input.tools(),activatedGroups);ReasoningInputfilteredInput=newReasoningInput(input.messages(),visibleTools,input.options());returnnext.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 中写入权限快照
publicclassWaterMeterHarnessAgentRunner{privatefinalHarnessAgentharnessAgent;privatefinalAgentToolPermissionResolverpermissionResolver;@AutowiredpublicWaterMeterHarnessAgentRunner(HarnessAgentharnessAgent,AgentToolPermissionResolverpermissionResolver){this.harnessAgent=harnessAgent;this.permissionResolver=permissionResolver;}publicFlux<AgentEvent>streamEvents(UserMessageuserMessage,RuntimeContextruntimeContext){AgentToolAccessContextaccessContext=permissionResolver.resolveCurrentUserAccessContext();runtimeContext.put(AgentToolAccessContext.class,accessContext);returnharnessAgent.streamEvents(userMessage,runtimeContext);}}权限快照必须在 HTTP 请求线程中生成。此时 Spring Security 登录上下文仍然有效。
十三、声明查询工具
@ComponentpublicclassWaterMeterOrderQueryTools{@AutowiredprivateWaterOrderServicewaterOrderService;@Tool(name="get_water_order",description="按订单编号查询一笔水费订单",readOnly=true)@AgentToolPermission("water-meter:order:query")@PreAuthorize("@ss.hasPermission('water-meter:order:query')")publicStringgetWaterOrder(@ToolParam(name="orderId",description="水费订单编号")LongorderId){if(orderId==null){return"订单编号不能为空。";}WaterOrderDOorder=waterOrderService.getAdminWaterOrder(orderId);returnorder==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")publicHarnessAgentwaterMeterHarnessAgent(AgentRagPropertiesproperties,AgentToolPermissionResolverpermissionResolver,AgentToolPermissionMiddlewarepermissionMiddleware,WaterMeterOrderQueryToolsqueryTools,WaterMeterOrderSettlementToolssettlementTools){Toolkittoolkit=newToolkit();// 启动时一次性注册全部工具。registerPermissionTools(toolkit,permissionResolver,queryTools,settlementTools);returnHarnessAgent.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