Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,15 @@ public void cancel(RequestContext context, AgentEmitter agentEmitter) throws A2A

// Create v1.0 DefaultRequestHandler
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler v10Handler =
DefaultRequestHandler.create(
agentExecutor, taskStore, queueManager, pushConfigStore,
mainEventBusProcessor, internalExecutor, internalExecutor);
DefaultRequestHandler.builder()
.agentExecutor(agentExecutor)
.taskStore(taskStore)
.queueManager(queueManager)
.pushConfigStore(pushConfigStore)
.mainEventBusProcessor(mainEventBusProcessor)
.executor(internalExecutor)
.eventConsumerExecutor(internalExecutor)
.build();

// Wrap in v0.3 conversion handler
convert03To10Handler = new Convert_v0_3_To10RequestHandler(v10Handler);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -858,9 +858,14 @@ public void testDeletePushNotificationConfig() {
public void testOnGetPushNotificationNoPushNotifierConfig() {
// Create v1.0 request handler without push config store
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler v10Handler =
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler.create(
agentExecutor, taskStore, queueManager, null, mainEventBusProcessor,
internalExecutor, internalExecutor);
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler.builder()
.agentExecutor(agentExecutor)
.taskStore(taskStore)
.queueManager(queueManager)
.mainEventBusProcessor(mainEventBusProcessor)
.executor(internalExecutor)
.eventConsumerExecutor(internalExecutor)
.build();

// Wrap in v0.3 conversion handler
Convert_v0_3_To10RequestHandler handlerWithoutPushConfig = new Convert_v0_3_To10RequestHandler(v10Handler);
Expand All @@ -885,9 +890,14 @@ public void testOnGetPushNotificationNoPushNotifierConfig() {
public void testOnSetPushNotificationNoPushNotifierConfig() {
// Create v1.0 request handler without push config store
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler v10Handler =
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler.create(
agentExecutor, taskStore, queueManager, null, mainEventBusProcessor,
internalExecutor, internalExecutor);
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler.builder()
.agentExecutor(agentExecutor)
.taskStore(taskStore)
.queueManager(queueManager)
.mainEventBusProcessor(mainEventBusProcessor)
.executor(internalExecutor)
.eventConsumerExecutor(internalExecutor)
.build();

// Wrap in v0.3 conversion handler
Convert_v0_3_To10RequestHandler handlerWithoutPushConfig = new Convert_v0_3_To10RequestHandler(v10Handler);
Expand Down Expand Up @@ -952,9 +962,14 @@ public void testDeletePushNotificationConfigNotSupported() {
public void testDeletePushNotificationConfigNoPushConfigStore() {
// Create v1.0 request handler without push config store
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler v10Handler =
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler.create(
agentExecutor, taskStore, queueManager, null, mainEventBusProcessor,
internalExecutor, internalExecutor);
org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler.builder()
.agentExecutor(agentExecutor)
.taskStore(taskStore)
.queueManager(queueManager)
.mainEventBusProcessor(mainEventBusProcessor)
.executor(internalExecutor)
.eventConsumerExecutor(internalExecutor)
.build();

// Wrap in v0.3 conversion handler
Convert_v0_3_To10RequestHandler handlerWithoutPushConfig = new Convert_v0_3_To10RequestHandler(v10Handler);
Expand Down
9 changes: 9 additions & 0 deletions docs/content/dev/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ a2a.blocking.consumption.timeout.seconds=5
a2a.blocking.reconciliation.timeout.seconds=1
```

### Request Context

```properties
# Load referenced tasks from the TaskStore and enforce authorization checks (default: false)
a2a.request-context.populate-referred-tasks=false
```

When enabled, task IDs referenced in incoming messages are looked up in the `TaskStore` and made available to the `AgentExecutor` via `RequestContext.getRelatedTasks()`. This is useful for multi-task conversations where the agent needs access to state from related tasks. Disabled by default to avoid extra `TaskStore` lookups when not needed.

### Tuning Guidelines

- **Streaming Performance**: The executor handles streaming subscriptions. Too few threads can cause timeouts under concurrent load.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,8 @@ public void onDeleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigP
}

@Override
public void validateRequestedTask(@Nullable String requestedTaskId) throws A2AError {
delegate.validateRequestedTask(requestedTaskId);
public void authorizeTaskAccess(@Nullable String requestedTaskId, ServerCallContext context) throws A2AError {
delegate.authorizeTaskAccess(requestedTaskId, context);
}

private boolean extractRequest() {
Expand Down
5 changes: 5 additions & 0 deletions extras/task-store-database-jpa/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.transaction</groupId>
<artifactId>jakarta.transaction-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult;
import org.a2aproject.sdk.server.ServerCallContext;
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.tasks.TaskStateProvider;
import org.a2aproject.sdk.server.tasks.TaskStore;
Expand Down Expand Up @@ -292,7 +291,12 @@ public ListTasksResult list(ListTasksParams params, @Nullable ServerCallContext
boolean hasMore;
int totalSize;

if (authorizationProvider != null && context != null) {
if (authorizationProvider != null && context == null) {
LOGGER.warn("Authorization provider is configured but no ServerCallContext available — "
+ "returning empty result (fail-closed)");
return new ListTasksResult(List.of(), 0, 0, null);
}
if (authorizationProvider != null) {
// Iterative fetch: accumulate pageSize authorized results across DB pages
tasks = new ArrayList<>(pageSize);
PageToken cursor = PageToken.fromString(params.pageToken());
Expand All @@ -314,7 +318,7 @@ public ListTasksResult list(ListTasksParams params, @Nullable ServerCallContext
for (JpaTask jpaTask : batch) {
processedCount++;
Task task = deserializeTask(jpaTask);
if (authorizationProvider.checkRead(context, task.id(), TaskOperation.LIST_TASKS)) {
if (isReadAuthorized(authorizationProvider, context, task.id())) {
tasks.add(task);
if (tasks.size() == pageSize) {
break;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.a2aproject.sdk.extras.taskstore.database.jpa;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import jakarta.enterprise.inject.Instance;

import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult;
import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider;
import org.a2aproject.sdk.spec.ListTasksParams;
import org.junit.jupiter.api.Test;

class JpaDatabaseTaskStoreAuthorizationTest {

@Test
@SuppressWarnings("unchecked")
void listFailsClosedWhenContextIsNull() {
TaskAuthorizationProvider authProvider = mock(TaskAuthorizationProvider.class);
Instance<TaskAuthorizationProvider> instance = mock(Instance.class);
when(instance.isResolvable()).thenReturn(true);
when(instance.get()).thenReturn(authProvider);

JpaDatabaseTaskStore store = new JpaDatabaseTaskStore(instance);

ListTasksResult result = store.list(new ListTasksParams(), null);

assertNotNull(result);
assertEquals(0, result.tasks().size());
assertEquals(0, result.totalSize());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -484,9 +484,9 @@ private A2AResponse<?> processNonStreamingRequest(NonStreamingJSONRPCRequest<?>
private Multi<? extends A2AResponse<?>> processStreamingRequest(
A2ARequest<?> request, ServerCallContext context) throws A2AError {
if (request instanceof SendStreamingMessageRequest req) {
jsonRpcHandler.validateRequestedTask(req.getParams().message().taskId());
jsonRpcHandler.authorizeTaskAccess(req.getParams().message().taskId(), context);
} else if (request instanceof SubscribeToTaskRequest req) {
jsonRpcHandler.validateRequestedTask(req.getParams().id());
jsonRpcHandler.authorizeTaskAccess(req.getParams().id(), context);
}
try {
Flow.Publisher<? extends A2AResponse<?>> publisher;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@ public Builder setServerCallContext(@Nullable ServerCallContext serverCallContex
return task;
}

/**
* Returns the server call context set on this builder.
*
* @return the server call context, or null if not set
*/
@Nullable ServerCallContext getServerCallContext() {
return serverCallContext;
}

/**
* Builds the RequestContext with ID generation and validation.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,28 @@
import java.util.ArrayList;
import java.util.List;

import org.a2aproject.sdk.server.ServerCallContext;
import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider;
import org.a2aproject.sdk.server.auth.TaskOperation;
import org.a2aproject.sdk.server.tasks.TaskStore;
import org.a2aproject.sdk.spec.Task;
import org.a2aproject.sdk.spec.TaskNotFoundError;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SimpleRequestContextBuilder extends RequestContext.Builder {
private static final Logger LOGGER = LoggerFactory.getLogger(SimpleRequestContextBuilder.class);

private final TaskStore taskStore;
private final boolean shouldPopulateReferredTasks;
private final @Nullable TaskAuthorizationProvider authorizationProvider;

public SimpleRequestContextBuilder(TaskStore taskStore, boolean shouldPopulateReferredTasks) {
public SimpleRequestContextBuilder(TaskStore taskStore, boolean shouldPopulateReferredTasks,
@Nullable TaskAuthorizationProvider authorizationProvider) {
this.taskStore = taskStore;
this.shouldPopulateReferredTasks = shouldPopulateReferredTasks;
this.authorizationProvider = authorizationProvider;
}

@Override
Expand All @@ -21,10 +33,19 @@ public RequestContext build() {
if (taskStore != null && shouldPopulateReferredTasks && getParams() != null
&& getParams().message().referenceTaskIds() != null) {
relatedTasks = new ArrayList<>();
ServerCallContext callContext = getServerCallContext();
for (String taskId : getParams().message().referenceTaskIds()) {
if (authorizationProvider != null) {
if (callContext == null
|| !authorizationProvider.checkRead(callContext, taskId, TaskOperation.MESSAGE_SEND)) {
throw new TaskNotFoundError();
}
}
Task task = taskStore.get(taskId);
if (task != null) {
relatedTasks.add(task);
} else {
LOGGER.warn("Referenced task '{}' not found in TaskStore", taskId);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.a2aproject.sdk.server.ServerCallContext;
import org.a2aproject.sdk.spec.A2AError;
import org.jspecify.annotations.Nullable;

/**
* SPI for per-user task authorization.
Expand Down Expand Up @@ -93,7 +94,7 @@
* so the first writer wins and the second is a harmless no-op.</li>
* <li><b>CDI injection requirement:</b> When task authorization is required, always obtain
* {@code RequestHandler} through CDI injection. Manual instantiation via
* {@code DefaultRequestHandler.create()} bypasses the
* {@code DefaultRequestHandler.builder().build()} bypasses the
* {@code AuthorizationRequestHandlerDecorator}.</li>
* </ul>
*
Expand Down Expand Up @@ -156,4 +157,28 @@ public interface TaskAuthorizationProvider {
* @throws A2AError if recording fails
*/
void recordOwnership(ServerCallContext context, String taskId, TaskOperation operation) throws A2AError;

/**
* Fail-closed read-access check that handles absent provider and missing call context.
* <p>
* Returns {@code true} (allow) when no provider is configured.
* Returns {@code false} (deny) when a provider is configured but no call context is available.
* Otherwise delegates to {@link #checkRead}.
*
* @param provider the authorization provider, or {@code null} if authorization is disabled
* @param context the server call context, or {@code null} if unavailable
* @param taskId the task being accessed
* @param operation which RequestHandler method triggered the check
* @return {@code true} to allow, {@code false} to deny
*/
static boolean checkReadAccess(@Nullable TaskAuthorizationProvider provider,
@Nullable ServerCallContext context, String taskId, TaskOperation operation) {
if (provider == null) {
return true;
}
if (context == null) {
return false;
}
return provider.checkRead(context, taskId, operation);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,10 @@ public void onDeleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigP
}

@Override
public void validateRequestedTask(@Nullable String requestedTaskId) throws A2AError {
delegate.validateRequestedTask(requestedTaskId);
public void authorizeTaskAccess(@Nullable String requestedTaskId, ServerCallContext context) throws A2AError {
if (requestedTaskId != null) {
enforceRead(context, requestedTaskId, TaskOperation.GET_TASK);
}
delegate.authorizeTaskAccess(requestedTaskId, context);
}
}
Loading
Loading