Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion docs/en/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion docs/zh/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 控制器:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -70,6 +73,12 @@
* <li>Optional slash-style aliases for gateways that cannot publish colon-style action paths
* </ul>
*
* <p>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.
*
* <p>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.
Expand Down Expand Up @@ -189,7 +198,8 @@ public RequestHandler requestHandler(
InMemoryQueueManager qm,
PushNotificationConfigStore pushStore,
MainEventBusProcessor proc,
ExecutorService pool) {
ExecutorService pool,
ObjectProvider<TaskAuthorizationProvider> authorizationProvider) {
RequestHandler delegate =
DefaultRequestHandler.builder()
.agentExecutor(executor)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,20 +38,28 @@
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;
import java.util.concurrent.atomic.AtomicInteger;
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;
Expand Down Expand Up @@ -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")));
}
}