若依框架和阿里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 不得进入本轮模型上下文,而不只是等到工具执行时再拒绝。

同时还需要满足以下约束:

  1. HarnessAgent 是应用级单例。
  2. Toolkit 在启动时一次性构建,不能按用户重复创建。
  3. 同一个 (userId, sessionId) 的请求由 AgentScope 串行处理,不同会话可以并行。
  4. 不同用户并发访问时,工具权限不能相互污染。
  5. 工具执行阶段仍然保留 @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. 启动阶段

系统启动时:

  1. 单例 Toolkit 注册全部业务工具。
  2. 扫描工具方法上的 @AgentToolPermission。
  3. 建立“权限标识 → 工具名称”的不可变索引。
  4. 创建单例 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. 请求阶段

每次后台用户发起对话时:

  1. 在 HTTP 请求线程调用若依权限服务。
  2. 生成当前用户不可变的工具权限快照。
  3. 将快照放入本次调用独有的 RuntimeContext。
  4. onReasoning 从 RuntimeContext 读取快照。
  5. 过滤 ReasoningInput.tools。
  6. 将过滤后的 Tool Schema 发送给模型。
  7. 工具真正执行时,再由 @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 有三个原因:

  1. HarnessAgent 和 Middleware 是单例,不能把当前用户权限写进实例成员变量。
  2. 模型调用和工具执行可能切换到 Reactor 工作线程,不能一直依赖原 HTTP 线程的 SecurityContextHolder。
  3. 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);
    }

}

需要注意:

  1. 集合使用 List.copyOf 创建不可变副本。
  2. authentication 只用于工具执行线程临时恢复 Spring Security 上下文。
  3. 不保存 Bearer Token 和密码。
  4. 该对象不会主动序列化进 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 的全局工具激活方法。

原因是:

  1. HarnessAgent 是单例。
  2. Toolkit 也是该 Agent 的共享配置。
  3. 不同 (userId, sessionId) 可以并发运行。
  4. 如果把用户 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);
    }

}

三个注解的职责分别是:

注解使用方作用
@ToolAgentScope生成工具名称、描述和参数 Schema
@AgentToolPermission自定义 Resolver/Middleware决定谁能在模型上下文中看到 Schema
@PreAuthorizeSpring Method Security工具执行时再次检查权限

十四、Spring AOP 代理与 AgentScope 反射

当工具方法带有 @PreAuthorize 时,Spring 通常会为工具 Bean 创建 AOP 代理。

这里会出现一个兼容问题:

  1. AgentScope 需要扫描目标类上的 @Tool 和 @ToolParam 生成 Schema。
  2. 工具执行又必须调用 Spring 代理,才能触发 @PreAuthorize。
  3. 如果直接扫描代理类,代理生成的方法不一定保留目标方法的 @Tool 注解。
  4. 如果直接调用原始 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

测试时应验证:

  1. 受限角色的模型请求中不存在订单工具名称、描述和参数 Schema。
  2. 查询角色只能看到订单查询工具。
  3. 结算角色可以看到查询和结算工具。
  4. 查询角色能够真实执行查询工具并通过 @PreAuthorize。
  5. 受限角色和结算角色并发请求时,各自的 Tool Schema 不会串权。
  6. 未附加权限快照的内部调用只能看到公共工具。

十七、常见错误

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 再次校验

最终可以同时实现:

  1. 无权限用户看不到工具名称。
  2. 无权限用户看不到工具描述。
  3. 无权限用户看不到参数 JSON Schema。
  4. 无权限用户无法执行工具。
  5. HarnessAgent 和 Toolkit 保持应用级单例。
  6. 不同用户、不同会话并发时权限互不污染。

这种实现不仅适用于水表订单,也可以扩展到客户查询、财务审批、设备控制、报表导出等任意若依菜单权限场景。

参考资料

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐