diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 7486c16..663fef6 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -731,11 +731,39 @@ Auto-configures the following beans (all `@ConditionalOnMissingBean`, so you can | `pushStore` | `PushNotificationConfigStore` | Push notification config storage | | `agentExecutorPool` | `ExecutorService` | Thread pool for agent execution (8 threads, daemon) | | `eventBusProcessor` | `MainEventBusProcessor` | Event bus processor | -| `requestHandler` | `RequestHandler` | Default request handler | +| `requestHandler` | `RequestHandler` | Default request handler; operation-level authorization is enabled automatically when a `TaskAuthorizationProvider` bean exists | | `restHandler` | `RestHandler` | REST protocol handler | | `a2aController` | `A2AController` | Spring MVC controller for message and task endpoints | | `a2aSlashActionAliasController` | `A2ASlashActionAliasController` | Optional public controller for slash-style action aliases | +### Server-side request authorization (TaskAuthorizationProvider) + +Declare a `TaskAuthorizationProvider` bean (`@Component` or `@Bean`) to inject business validation +(caller identity, request legality, ...) into every A2A operation **before** the agent executor runs: + +```java +@Component +public class WorkbenchRequestGuard implements TaskAuthorizationProvider { + @Override + public boolean checkCreate(ServerCallContext ctx, TaskOperation op) throws A2AError { + // message:send / message:stream (new task creation) arrive here + return ctx.getUser().isAuthenticated(); // false or an A2AError rejects the request + } + @Override public boolean checkRead(ServerCallContext ctx, String taskId, TaskOperation op) { ... } + @Override public boolean checkWrite(ServerCallContext ctx, String taskId, TaskOperation op) { ... } + @Override public boolean isTaskRecorded(String taskId) { ... } + @Override public void recordOwnership(ServerCallContext ctx, String taskId, TaskOperation op) { ... } +} +``` + +- `checkCreate` guards `message:send` / `message:stream`; `checkRead` guards task query/list; + `checkWrite` guards task cancel; `recordOwnership`/`isTaskRecorded` track task ownership. +- `ServerCallContext` exposes `getUser()` (authenticated user), `getRequestedExtensions()` + (the A2A-Extensions request header), and `getState()` (transport state). +- Rejections return a standard A2A error envelope to the caller (`checkCreate` returning false maps + to a task-not-found error; throwing a more specific `A2AError` subclass carries a custom reason). +- Without such a bean the server behaves exactly as before (no authorization decorator is wired). + ### A2AController Spring MVC controller that exposes A2A REST endpoints: diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index be4b1cb..9a6a2e7 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -684,11 +684,39 @@ a2at: | `pushStore` | `PushNotificationConfigStore` | 推送通知配置存储 | | `agentExecutorPool` | `ExecutorService` | 智能体执行线程池(8 线程,守护线程) | | `eventBusProcessor` | `MainEventBusProcessor` | 事件总线处理器 | -| `requestHandler` | `RequestHandler` | 默认请求处理器 | +| `requestHandler` | `RequestHandler` | 默认请求处理器;存在 `TaskAuthorizationProvider` Bean 时自动启用操作级鉴权 | | `restHandler` | `RestHandler` | REST 协议处理器 | | `a2aController` | `A2AController` | 消息和任务端点的 Spring MVC 控制器 | | `a2aSlashActionAliasController` | `A2ASlashActionAliasController` | 可选的斜杠动作别名公共控制器 | +### 服务端请求鉴权(TaskAuthorizationProvider) + +宿主声明一个 `TaskAuthorizationProvider` Bean(`@Component` 或 `@Bean`),即可在每个 A2A 操作进入 +智能体执行器**之前**注入业务校验(调用方身份、请求合法性等): + +```java +@Component +public class WorkbenchRequestGuard implements TaskAuthorizationProvider { + @Override + public boolean checkCreate(ServerCallContext ctx, TaskOperation op) throws A2AError { + // message:send / message:stream(新任务创建)走这里 + return ctx.getUser().isAuthenticated(); // 返回 false 或抛 A2AError 即拒绝 + } + @Override public boolean checkRead(ServerCallContext ctx, String taskId, TaskOperation op) { ... } + @Override public boolean checkWrite(ServerCallContext ctx, String taskId, TaskOperation op) { ... } + @Override public boolean isTaskRecorded(String taskId) { ... } + @Override public void recordOwnership(ServerCallContext ctx, String taskId, TaskOperation op) { ... } +} +``` + +- `checkCreate` 守卫 `message:send` / `message:stream`;`checkRead` 守卫任务查询/列表; + `checkWrite` 守卫任务取消;`recordOwnership`/`isTaskRecorded` 用于任务归属跟踪。 +- `ServerCallContext` 提供 `getUser()`(认证用户)、`getRequestedExtensions()`(A2A-Extensions 请求头) + 和 `getState()`(传输状态)。 +- 拒绝以标准 A2A 错误信封返回调用方(`checkCreate` 返回 false 时为任务不存在错误; + 抛出更具体的 `A2AError` 子类可携带自定义原因)。 +- 未声明该 Bean 时行为与之前完全一致(不装配鉴权装饰器)。 + ### A2AController 暴露 A2A REST 端点的 Spring MVC 控制器: diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java index c67a6aa..8041024 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java @@ -29,10 +29,12 @@ import java.util.concurrent.TimeUnit; import org.a2aproject.sdk.server.AgentCardCacheMetadata; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider; import org.a2aproject.sdk.server.config.A2AConfigProvider; import org.a2aproject.sdk.server.events.InMemoryQueueManager; import org.a2aproject.sdk.server.events.MainEventBus; import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.requesthandlers.AuthorizationRequestHandlerDecorator; import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.tasks.BasePushNotificationSender; @@ -44,6 +46,7 @@ import org.a2aproject.sdk.transport.rest.handler.RestHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -70,6 +73,12 @@ *
  • Optional slash-style aliases for gateways that cannot publish colon-style action paths * * + *

    Optional host-side request validation: declare a {@link TaskAuthorizationProvider} bean and + * every A2A operation is offered to it before the agent executor runs. {@code checkCreate} guards + * message send/stream (new task creation), {@code checkRead}/{@code checkWrite} guard task + * query/cancel, and rejection returns a standard A2A error envelope to the caller. Without such a + * bean the server behaves exactly as before. + * *

    The partner only needs to provide an {@link AgentExecutor} implementation as a * {@code @Component} or {@code @Bean}. Set {@code a2at.server.enabled=true} to enable the * server-side autoconfiguration. @@ -189,7 +198,8 @@ public RequestHandler requestHandler( InMemoryQueueManager qm, PushNotificationConfigStore pushStore, MainEventBusProcessor proc, - ExecutorService pool) { + ExecutorService pool, + ObjectProvider authorizationProvider) { RequestHandler delegate = DefaultRequestHandler.builder() .agentExecutor(executor) @@ -200,7 +210,19 @@ public RequestHandler requestHandler( .executor(pool) .eventConsumerExecutor(pool) .build(); - return new SpringCompatibleRequestHandler(delegate); + RequestHandler handler = new SpringCompatibleRequestHandler(delegate); + TaskAuthorizationProvider provider = authorizationProvider.getIfAvailable(); + if (provider != null) { + // The decorator carries CDI Instance fields like the delegate, so it also stays behind the + // Spring-compatible facade. Every A2A operation (message send/stream, task query, cancel, + // subscribe, push-notification config) is offered to the host provider before the agent + // executor runs; a rejection surfaces as a standard A2A error to the caller. + handler = + new SpringCompatibleRequestHandler( + new AuthorizationRequestHandlerDecorator(handler, provider)); + log.info("[A2A] TaskAuthorizationProvider detected; operation-level authorization enabled"); + } + return handler; } @Bean diff --git a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java index f722896..d5d9dc2 100644 --- a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java +++ b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java @@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -37,6 +38,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Flow; import java.util.concurrent.ThreadPoolExecutor; @@ -44,13 +48,18 @@ import java.util.concurrent.atomic.AtomicReference; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.auth.AuthenticatedUser; +import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider; import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.config.A2AConfigProvider; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.TaskNotFoundError; +import org.a2aproject.sdk.spec.TextPart; import org.a2aproject.sdk.transport.rest.handler.RestHandler; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -540,4 +549,77 @@ void whenEnabledTrueThenA2ABeansCreated() { && context.getStartupFailure().getMessage().contains("did not match")); }); } + + @Test + void taskAuthorizationProviderRejectionRunsBeforeTheAgentExecutor() { + AtomicInteger executorRuns = new AtomicInteger(); + AgentExecutor executor = mock(AgentExecutor.class); + doAnswer( + invocation -> { + executorRuns.incrementAndGet(); + return null; + }) + .when(executor) + .execute(any(), any()); + TaskAuthorizationProvider guard = + new TaskAuthorizationProvider() { + @Override + public boolean checkRead(ServerCallContext ctx, String taskId, TaskOperation op) { + return true; + } + + @Override + public boolean checkWrite(ServerCallContext ctx, String taskId, TaskOperation op) { + return true; + } + + @Override + public boolean checkCreate(ServerCallContext ctx, TaskOperation op) { + return false; + } + + @Override + public boolean isTaskRecorded(String taskId) { + return false; + } + + @Override + public void recordOwnership(ServerCallContext ctx, String taskId, TaskOperation op) {} + }; + + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) + .withBean(AgentCard.class, () -> mock(AgentCard.class)) + .withBean(AgentExecutor.class, () -> executor) + .withBean(TaskAuthorizationProvider.class, () -> guard) + .withPropertyValues("a2at.server.enabled=true") + .run( + context -> { + RequestHandler handler = context.getBean(RequestHandler.class); + MessageSendParams params = + MessageSendParams.builder() + .message( + Message.builder() + .messageId("m1") + .role(Message.Role.ROLE_USER) + .parts(List.of(new TextPart("diagnose"))) + .build()) + .build(); + ServerCallContext callContext = + new ServerCallContext(new AuthenticatedUser("waimo"), Map.of(), Set.of()); + assertThrows( + TaskNotFoundError.class, () -> handler.onMessageSend(params, callContext)); + assertEquals(0, executorRuns.get()); + }); + } + + @Test + void requestHandlerIsCreatedWithoutAnAuthorizationProvider() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) + .withBean(AgentCard.class, () -> mock(AgentCard.class)) + .withBean(AgentExecutor.class, () -> mock(AgentExecutor.class)) + .withPropertyValues("a2at.server.enabled=true") + .run(context -> assertTrue(context.containsBean("requestHandler"))); + } }