From 6c00d618fe8abeccaa9e17723a76d88240aa991a Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 4 Aug 2026 14:43:24 +0100 Subject: [PATCH 1/2] .NET: Add tenant-scoped task store isolation for A2A hosting Wrap ITaskStore with IsolationKeyScopedTaskStore when a SessionIsolationKeyProvider is registered, mirroring the existing session store isolation pattern. This ensures task operations are scoped per tenant in multi-user deployments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: adc30d6c-ce66-40bb-933e-9801c2156cda --- .../A2AClientServer/A2AServer/Program.cs | 4 +- .../AgentWebChat.AgentHost/Program.cs | 8 +- .../A2AServerServiceCollectionExtensions.cs | 68 ++-- .../IsolationKeyScopedTaskStore.cs | 217 ++++++++++++ ...AServerServiceCollectionExtensionsTests.cs | 79 +++++ .../IsolationKeyScopedTaskStoreTests.cs | 335 ++++++++++++++++++ 6 files changed, 675 insertions(+), 36 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/IsolationKeyScopedTaskStore.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/IsolationKeyScopedTaskStoreTests.cs diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index cb8c57f377f..a76e779461f 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -101,8 +101,8 @@ You specialize in handling queries related to logistics. throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided"); } -// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller. -// Without this, contextId alone is the session key — any caller who knows a contextId can access that session. +// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions and tasks by authenticated caller. +// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data. // Example using claims-based identity: // builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs index c54efda7a5e..8fd96ef3bdd 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -28,8 +28,8 @@ builder.AddOpenAIChatCompletions(); builder.AddOpenAIResponses(); -// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller. -// Without this, contextId alone is the session key — any caller who knows a contextId can access that session. +// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions and tasks by authenticated caller. +// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data. // Example using claims-based identity: // builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); @@ -157,8 +157,8 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te pirateAgentBuilder.AddA2AServer(); knightsKnavesAgentBuilder.AddA2AServer(); -// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller. -// Without this, contextId alone is the session key — any caller who knows a contextId can access that session. +// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions and tasks by authenticated caller. +// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data. // Example using claims-based identity: // builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index ad07155387e..a11cd6ba86c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -30,19 +30,20 @@ public static class A2AServerServiceCollectionExtensions /// The for chaining. /// /// - /// Trust model. The A2A contextId arrives from the wire - /// and is treated as a chain-resume identifier — not as an authorization - /// token. The contract carries no principal/owner - /// dimension, so when a persistent store is registered any caller who knows or - /// guesses another caller's contextId can resume that other caller's - /// persisted thread. Hosts that serve more than one user must compose a principal - /// dimension into the lookup key — typically by calling - /// UseClaimsBasedSessionIsolation(...) from + /// Trust model. The A2A contextId and taskId arrive + /// from the wire and are treated as chain-resume identifiers — not as + /// authorization tokens. Both the and + /// contracts carry no principal/owner dimension by default, + /// so when a persistent store is registered any caller who knows or guesses another + /// caller's contextId or taskId can access that other caller's data. + /// Hosts that serve more than one user must compose a principal dimension into the + /// lookup key — typically by calling UseClaimsBasedSessionIsolation(...) from /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom - /// ). When no isolation provider is - /// registered, behavior is unchanged — the bare contextId is used as the - /// conversation identifier, which is appropriate for first-run / single-user / - /// prototyping scenarios but unsafe for multi-user hosts. + /// ). When a + /// is registered, both the session store and the task store are automatically wrapped + /// with tenant-scoped isolation. When no isolation provider is registered, behavior + /// is unchanged — the bare identifiers are used directly, which is appropriate for + /// first-run / single-user / prototyping scenarios but unsafe for multi-user hosts. /// /// public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action? configureOptions = null) @@ -65,10 +66,10 @@ public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBui /// The for chaining. /// /// See the trust-model remarks on - /// for guidance on multi-user hosts (the wire contextId is a chain-resume - /// identifier, not an authorization token; multi-user hosts must compose a - /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom - /// ). + /// for guidance on multi-user hosts (the wire contextId and taskId + /// are chain-resume identifiers, not authorization tokens; multi-user hosts must + /// compose a principal dimension via UseClaimsBasedSessionIsolation(...) or + /// a custom ). /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null) { @@ -91,10 +92,10 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// The for chaining. /// /// See the trust-model remarks on - /// for guidance on multi-user hosts (the wire contextId is a chain-resume - /// identifier, not an authorization token; multi-user hosts must compose a - /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom - /// ). + /// for guidance on multi-user hosts (the wire contextId and taskId + /// are chain-resume identifiers, not authorization tokens; multi-user hosts must + /// compose a principal dimension via UseClaimsBasedSessionIsolation(...) or + /// a custom ). /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null) { @@ -116,10 +117,10 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// The for chaining. /// /// See the trust-model remarks on - /// for guidance on multi-user hosts (the wire contextId is a chain-resume - /// identifier, not an authorization token; multi-user hosts must compose a - /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom - /// ). + /// for guidance on multi-user hosts (the wire contextId and taskId + /// are chain-resume identifiers, not authorization tokens; multi-user hosts must + /// compose a principal dimension via UseClaimsBasedSessionIsolation(...) or + /// a custom ). /// public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) { @@ -154,10 +155,10 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, /// The for chaining. /// /// See the trust-model remarks on - /// for guidance on multi-user hosts (the wire contextId is a chain-resume - /// identifier, not an authorization token; multi-user hosts must compose a - /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom - /// ). + /// for guidance on multi-user hosts (the wire contextId and taskId + /// are chain-resume identifiers, not authorization tokens; multi-user hosts must + /// compose a principal dimension via UseClaimsBasedSessionIsolation(...) or + /// a custom ). /// public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null) { @@ -179,6 +180,8 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options) { + var isolationKeyProvider = serviceProvider.GetService(); + var agentHandler = serviceProvider.GetKeyedService(agent.Name); if (agentHandler is null) { @@ -186,7 +189,6 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground; // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. - var isolationKeyProvider = serviceProvider.GetService(); if (agentSessionStore?.GetService() is null) { agentSessionStore ??= new NoopAgentSessionStore(); @@ -201,7 +203,13 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge } var loggerFactory = serviceProvider.GetService() ?? NullLoggerFactory.Instance; - var taskStore = serviceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); + ITaskStore taskStore = serviceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); + + // Wrap the task store with isolation key scoping, same as the session store above. + if (taskStore is not IsolationKeyScopedTaskStore) + { + taskStore = new IsolationKeyScopedTaskStore(taskStore, isolationKeyProvider, strict: isolationKeyProvider != null); + } return new A2AServer( agentHandler, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/IsolationKeyScopedTaskStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/IsolationKeyScopedTaskStore.cs new file mode 100644 index 00000000000..d70cfe0fd44 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/IsolationKeyScopedTaskStore.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using A2A; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// A delegating that scopes task keys by an isolation key +/// provided by a , ensuring that tasks are isolated +/// per logical partition (e.g., user, tenant, or composite key). +/// +/// +/// +/// This class mirrors the isolation pattern of +/// but applies it to the A2A task store, preventing cross-tenant task access in multi-tenant deployments. +/// +/// +/// Both the store key and the persisted are scoped with the isolation +/// key. Scoping the persisted context is what allows list queries to be constrained to the calling +/// tenant, because list results are matched against the task body rather than the store key. Scoped +/// values are stripped again before being returned, so callers only ever observe bare identifiers. +/// +/// +public sealed class IsolationKeyScopedTaskStore : ITaskStore +{ + private readonly ITaskStore _innerStore; + private readonly SessionIsolationKeyProvider? _keyProvider; + private readonly bool _strict; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying to delegate to. + /// + /// The used to retrieve the isolation key for the current context. + /// + /// + /// When , an is thrown if the isolation key + /// cannot be determined. When , the task ID is passed through unmodified. + /// + /// is . + public IsolationKeyScopedTaskStore( + ITaskStore innerStore, + SessionIsolationKeyProvider? keyProvider, + bool strict) + { + ArgumentNullException.ThrowIfNull(innerStore); + + this._innerStore = innerStore; + this._keyProvider = keyProvider; + this._strict = strict; + } + + /// + public async Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + + var task = await this._innerStore.GetTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false); + + return task is null ? null : UnscopeTask(task, key); + } + + /// + public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default) + { + string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + + await this._innerStore.SaveTaskAsync(ScopeId(taskId, key), ScopeTask(task, key), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + + await this._innerStore.DeleteTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// is reported by the inner store. When no + /// filter is supplied it therefore counts tasks across all + /// isolation keys, because a wrapper cannot narrow the count without enumerating the whole store. + /// The returned tasks themselves are always constrained to the current isolation key. + /// + public async Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + + if (key is not null && !string.IsNullOrEmpty(request.ContextId)) + { + // Clone the request to avoid mutating the caller's object. + request = CloneRequestWithContextId(request, ScopeId(request.ContextId!, key)); + } + + var response = await this._innerStore.ListTasksAsync(request, cancellationToken).ConfigureAwait(false); + + if (key is null) + { + return response; + } + + // Tasks are persisted with a scoped ContextId, so any entry that does not carry the current + // isolation key belongs to another tenant and must not be returned. + var scopedTasks = new List(response.Tasks.Count); + foreach (var task in response.Tasks) + { + if (IsInScope(task, key)) + { + scopedTasks.Add(UnscopeTask(task, key)); + } + } + + response.Tasks = scopedTasks; + response.PageSize = scopedTasks.Count; + + return response; + } + + /// + /// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode. + /// + private async ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken) + { + string? key = this._keyProvider != null + ? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false) + : null; + + if (this._strict && key == null) + { + throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider."); + } + + return key; + } + + /// + /// Escapes special characters in the isolation key to ensure unambiguous scoped identifiers. + /// + private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:"); + + /// + /// Prefixes a bare identifier with the escaped isolation key, or returns it unchanged when no key applies. + /// + private static string ScopeId(string id, string? key) + => key is null ? id : $"{EscapeIsolationKey(key)}::{id}"; + + /// + /// Strips the isolation key prefix from a scoped identifier, or returns it unchanged when the prefix is absent. + /// + private static string UnscopeId(string scopedId, string? key) + { + if (key is null) + { + return scopedId; + } + + string prefix = $"{EscapeIsolationKey(key)}::"; + + return scopedId.StartsWith(prefix, StringComparison.Ordinal) + ? scopedId.Substring(prefix.Length) + : scopedId; + } + + /// + /// Determines whether a persisted task carries the supplied isolation key. + /// + private static bool IsInScope(AgentTask task, string key) + => task.ContextId?.StartsWith($"{EscapeIsolationKey(key)}::", StringComparison.Ordinal) == true; + + /// + /// Creates a copy of the task whose is scoped by the isolation key. + /// + /// + /// The task instance is copied rather than mutated because the A2A server reuses it for live event + /// notification after persisting; mutating it would surface the scoped context on the wire. + /// + private static AgentTask ScopeTask(AgentTask task, string? key) + => key is null ? task : CloneTaskWithContextId(task, ScopeId(task.ContextId, key)); + + /// + /// Creates a copy of the task whose has the isolation key removed. + /// + private static AgentTask UnscopeTask(AgentTask task, string? key) + => key is null ? task : CloneTaskWithContextId(task, UnscopeId(task.ContextId, key)); + + private static AgentTask CloneTaskWithContextId(AgentTask task, string contextId) + => new() + { + Id = task.Id, + ContextId = contextId, + Status = task.Status, + History = task.History, + Artifacts = task.Artifacts, + Metadata = task.Metadata, + }; + + private static ListTasksRequest CloneRequestWithContextId(ListTasksRequest request, string contextId) + => new() + { + ContextId = contextId, + Tenant = request.Tenant, + Status = request.Status, + PageSize = request.PageSize, + PageToken = request.PageToken, + HistoryLength = request.HistoryLength, + StatusTimestampAfter = request.StatusTimestampAfter, + IncludeArtifacts = request.IncludeArtifacts, + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index 11ad8574d32..bc78cd906ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -105,6 +105,85 @@ public async Task AddA2AServer_WithCustomTaskStore_ResolvesSuccessfullyAsync() Assert.NotNull(server); } + /// + /// Verifies that when a SessionIsolationKeyProvider is registered, task operations + /// use scoped identifiers (DI wiring test). + /// + [Fact] + public async Task AddA2AServer_WithIsolationKeyProvider_TaskStoreReceivesScopedIdsAsync() + { + // Arrange + const string AgentName = "isolation-wiring-agent"; + const string TaskId = "task-1"; + const string IsolationKey = "alice"; + + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + var mockTaskStore = new Mock(); + mockTaskStore + .Setup(s => s.GetTaskAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AgentTask { Id = TaskId, ContextId = $"{IsolationKey}::ctx-1", Status = new global::A2A.TaskStatus { State = TaskState.Completed } }); + services.AddKeyedSingleton(AgentName, mockTaskStore.Object); + + var mockKeyProvider = new Mock(); + mockKeyProvider + .Setup(p => p.GetSessionIsolationKeyAsync(It.IsAny())) + .ReturnsAsync(IsolationKey); + services.AddSingleton(mockKeyProvider.Object); + + services.AddA2AServer(AgentName); + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredKeyedService(AgentName); + + // Act + var result = await server.GetTaskAsync(new GetTaskRequest { Id = TaskId }); + + // Assert - the inner store received the scoped task ID + mockTaskStore.Verify(s => s.GetTaskAsync($"{IsolationKey}::{TaskId}", It.IsAny()), Times.Once); + } + + /// + /// Verifies that when an already-wrapped IsolationKeyScopedTaskStore is registered, + /// it is not double-wrapped (no double scoping of task IDs). + /// + [Fact] + public async Task AddA2AServer_WithAlreadyWrappedTaskStore_DoesNotDoubleWrapAsync() + { + // Arrange + const string AgentName = "no-double-wrap-agent"; + const string TaskId = "task-1"; + const string IsolationKey = "alice"; + + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + var mockInnerStore = new Mock(); + mockInnerStore + .Setup(s => s.GetTaskAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AgentTask { Id = TaskId, ContextId = $"{IsolationKey}::ctx-1", Status = new global::A2A.TaskStatus { State = TaskState.Completed } }); + + var mockKeyProvider = new Mock(); + mockKeyProvider + .Setup(p => p.GetSessionIsolationKeyAsync(It.IsAny())) + .ReturnsAsync(IsolationKey); + + // Pre-wrap the task store + var wrappedStore = new IsolationKeyScopedTaskStore(mockInnerStore.Object, mockKeyProvider.Object, strict: true); + services.AddKeyedSingleton(AgentName, wrappedStore); + services.AddSingleton(mockKeyProvider.Object); + + services.AddA2AServer(AgentName); + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredKeyedService(AgentName); + + // Act + var result = await server.GetTaskAsync(new GetTaskRequest { Id = TaskId }); + + // Assert - only single scoping occurred (not alice::alice::task-1) + mockInnerStore.Verify(s => s.GetTaskAsync($"{IsolationKey}::{TaskId}", It.IsAny()), Times.Once); + } + /// /// Verifies that when a custom AgentSessionStore is registered, AddA2AServer uses it /// instead of the default InMemoryAgentSessionStore. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/IsolationKeyScopedTaskStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/IsolationKeyScopedTaskStoreTests.cs new file mode 100644 index 00000000000..ed7e3c530d8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/IsolationKeyScopedTaskStoreTests.cs @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class IsolationKeyScopedTaskStoreTests +{ + private const string AliceKey = "alice"; + private const string BobKey = "bob"; + private const string TaskId = "task-001"; + + /// + /// Verifies that GetTaskAsync scopes the task ID with the isolation key. + /// + [Fact] + public async Task GetTaskAsync_ScopesTaskIdWithIsolationKeyAsync() + { + // Arrange + var innerStore = new Mock(); + var keyProvider = CreateKeyProvider(AliceKey); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + + // Act + await store.GetTaskAsync(TaskId); + + // Assert + innerStore.Verify(s => s.GetTaskAsync($"{AliceKey}::{TaskId}", It.IsAny()), Times.Once); + } + + /// + /// Verifies that SaveTaskAsync scopes the task ID and the persisted ContextId with the isolation key, + /// without mutating the caller's task instance. + /// + [Fact] + public async Task SaveTaskAsync_ScopesTaskIdAndContextIdWithIsolationKeyAsync() + { + // Arrange + var innerStore = new Mock(); + var keyProvider = CreateKeyProvider(AliceKey); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + var task = new AgentTask { Id = TaskId, ContextId = "ctx-1" }; + + // Act + await store.SaveTaskAsync(TaskId, task); + + // Assert - both the store key and the persisted ContextId are scoped + innerStore.Verify(s => s.SaveTaskAsync( + $"{AliceKey}::{TaskId}", + It.Is(t => t.ContextId == $"{AliceKey}::ctx-1"), + It.IsAny()), Times.Once); + + // Assert - the caller's instance is untouched + Assert.Equal("ctx-1", task.ContextId); + } + + /// + /// Verifies that GetTaskAsync strips the isolation key from the returned task's ContextId. + /// + [Fact] + public async Task GetTaskAsync_UnscopesContextIdOnReadAsync() + { + // Arrange + var innerStore = new InMemoryTaskStore(); + var store = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true); + + await store.SaveTaskAsync(TaskId, new AgentTask { Id = TaskId, ContextId = "ctx-1" }); + + // Act + var result = await store.GetTaskAsync(TaskId); + + // Assert - the caller observes the bare ContextId + Assert.NotNull(result); + Assert.Equal("ctx-1", result.ContextId); + } + + /// + /// Verifies that DeleteTaskAsync scopes the task ID with the isolation key. + /// + [Fact] + public async Task DeleteTaskAsync_ScopesTaskIdWithIsolationKeyAsync() + { + // Arrange + var innerStore = new Mock(); + var keyProvider = CreateKeyProvider(AliceKey); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + + // Act + await store.DeleteTaskAsync(TaskId); + + // Assert + innerStore.Verify(s => s.DeleteTaskAsync($"{AliceKey}::{TaskId}", It.IsAny()), Times.Once); + } + + /// + /// Verifies that different isolation keys produce different scoped task IDs, + /// preventing cross-tenant task access. + /// + [Fact] + public async Task GetTaskAsync_DifferentTenantsGetDifferentScopedIdsAsync() + { + // Arrange + var innerStore = new InMemoryTaskStore(); + var aliceStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true); + var bobStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(BobKey), strict: true); + + var aliceTask = new AgentTask { Id = TaskId, ContextId = "ctx-1" }; + + // Act - Alice saves a task + await aliceStore.SaveTaskAsync(TaskId, aliceTask); + + // Assert - Alice can read it + var aliceResult = await aliceStore.GetTaskAsync(TaskId); + Assert.NotNull(aliceResult); + + // Assert - Bob cannot read it (different isolation key → different scoped ID) + var bobResult = await bobStore.GetTaskAsync(TaskId); + Assert.Null(bobResult); + } + + /// + /// Verifies that ListTasksAsync scopes the ContextId filter with the isolation key. + /// + [Fact] + public async Task ListTasksAsync_ScopesContextIdFilterAsync() + { + // Arrange + var innerStore = new Mock(); + innerStore + .Setup(s => s.ListTasksAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ListTasksResponse()); + + var keyProvider = CreateKeyProvider(AliceKey); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + + var request = new ListTasksRequest { ContextId = "ctx-1" }; + + // Act + await store.ListTasksAsync(request); + + // Assert - ContextId was scoped with the isolation key + innerStore.Verify(s => s.ListTasksAsync( + It.Is(r => r.ContextId == $"{AliceKey}::ctx-1"), + It.IsAny()), Times.Once); + + // Assert - original request was not mutated + Assert.Equal("ctx-1", request.ContextId); + } + + /// + /// Verifies that ListTasksAsync does not modify the ContextId filter when it is null. + /// + [Fact] + public async Task ListTasksAsync_NullContextId_DoesNotScopeFilterAsync() + { + // Arrange + var innerStore = new Mock(); + innerStore + .Setup(s => s.ListTasksAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ListTasksResponse()); + + var keyProvider = CreateKeyProvider(AliceKey); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + + var request = new ListTasksRequest { ContextId = null }; + + // Act + await store.ListTasksAsync(request); + + // Assert - ContextId was not modified + innerStore.Verify(s => s.ListTasksAsync( + It.Is(r => r.ContextId == null), + It.IsAny()), Times.Once); + } + + /// + /// Verifies that an unfiltered ListTasksAsync only returns tasks belonging to the calling tenant. + /// + [Fact] + public async Task ListTasksAsync_NoContextIdFilter_ExcludesOtherTenantsTasksAsync() + { + // Arrange + var innerStore = new InMemoryTaskStore(); + var aliceStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true); + var bobStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(BobKey), strict: true); + + await aliceStore.SaveTaskAsync("alice-task", new AgentTask { Id = "alice-task", ContextId = "ctx-1" }); + await bobStore.SaveTaskAsync("bob-task", new AgentTask { Id = "bob-task", ContextId = "ctx-2" }); + + // Act - Bob lists without any filter + var response = await bobStore.ListTasksAsync(new ListTasksRequest()); + + // Assert - only Bob's task is returned, with a bare ContextId + var task = Assert.Single(response.Tasks); + Assert.Equal("bob-task", task.Id); + Assert.Equal("ctx-2", task.ContextId); + Assert.Equal(1, response.PageSize); + } + + /// + /// Verifies that filtering by ContextId returns the caller's own tasks, since the persisted + /// ContextId is scoped by the same isolation key as the filter. + /// + [Fact] + public async Task ListTasksAsync_WithContextIdFilter_ReturnsOwnTasksAsync() + { + // Arrange + var innerStore = new InMemoryTaskStore(); + var aliceStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(AliceKey), strict: true); + var bobStore = new IsolationKeyScopedTaskStore(innerStore, CreateKeyProvider(BobKey), strict: true); + + await aliceStore.SaveTaskAsync(TaskId, new AgentTask { Id = TaskId, ContextId = "ctx-1" }); + + // Act + var aliceResponse = await aliceStore.ListTasksAsync(new ListTasksRequest { ContextId = "ctx-1" }); + var bobResponse = await bobStore.ListTasksAsync(new ListTasksRequest { ContextId = "ctx-1" }); + + // Assert - Alice sees her task; Bob sees nothing for the same bare context + var task = Assert.Single(aliceResponse.Tasks); + Assert.Equal("ctx-1", task.ContextId); + Assert.Empty(bobResponse.Tasks); + } + + /// + /// Verifies that strict mode throws when the isolation key provider returns null. + /// + [Fact] + public async Task GetTaskAsync_StrictMode_NullKey_ThrowsAsync() + { + // Arrange + var innerStore = new Mock(); + var keyProvider = CreateKeyProvider(null); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + + // Act & Assert + await Assert.ThrowsAsync(() => store.GetTaskAsync(TaskId)); + } + + /// + /// Verifies that non-strict mode passes through the bare task ID when the key is null. + /// + [Fact] + public async Task GetTaskAsync_NonStrictMode_NullKey_PassesThroughAsync() + { + // Arrange + var innerStore = new Mock(); + var keyProvider = CreateKeyProvider(null); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: false); + + // Act + await store.GetTaskAsync(TaskId); + + // Assert - bare task ID was used (no scoping) + innerStore.Verify(s => s.GetTaskAsync(TaskId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that when no key provider is registered and strict is false, + /// the bare task ID is passed through. + /// + [Fact] + public async Task GetTaskAsync_NoKeyProvider_NonStrict_PassesThroughAsync() + { + // Arrange + var innerStore = new Mock(); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider: null, strict: false); + + // Act + await store.GetTaskAsync(TaskId); + + // Assert - bare task ID was used + innerStore.Verify(s => s.GetTaskAsync(TaskId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that colons in the isolation key are escaped to prevent ID ambiguity. + /// + [Fact] + public async Task GetTaskAsync_EscapesColonsInIsolationKeyAsync() + { + // Arrange + var innerStore = new Mock(); + var keyProvider = CreateKeyProvider("tenant:sub"); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + + // Act + await store.GetTaskAsync(TaskId); + + // Assert - colons are escaped + innerStore.Verify(s => s.GetTaskAsync(@"tenant\:sub::" + TaskId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that backslashes in the isolation key are escaped to prevent ID ambiguity. + /// + [Fact] + public async Task GetTaskAsync_EscapesBackslashesInIsolationKeyAsync() + { + // Arrange + var innerStore = new Mock(); + var keyProvider = CreateKeyProvider(@"domain\user"); + var store = new IsolationKeyScopedTaskStore(innerStore.Object, keyProvider, strict: true); + + // Act + await store.GetTaskAsync(TaskId); + + // Assert - backslashes are escaped + innerStore.Verify(s => s.GetTaskAsync(@"domain\\user::" + TaskId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that the constructor throws when the inner store is null. + /// + [Fact] + public void Constructor_NullInnerStore_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new IsolationKeyScopedTaskStore(null!, null, strict: false)); + } + + private static SessionIsolationKeyProvider CreateKeyProvider(string? key) + { + var mock = new Mock(); + mock.Setup(p => p.GetSessionIsolationKeyAsync(It.IsAny())) + .ReturnsAsync(key); + return mock.Object; + } +} From 41a8528809f7c1ca19341e85eeb6c1f143de52df Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 4 Aug 2026 15:30:15 +0100 Subject: [PATCH 2/2] fix formatting issue --- .../A2AServerServiceCollectionExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index bc78cd906ac..21581e79f7c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -124,7 +124,7 @@ public async Task AddA2AServer_WithIsolationKeyProvider_TaskStoreReceivesScopedI mockTaskStore .Setup(s => s.GetTaskAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new AgentTask { Id = TaskId, ContextId = $"{IsolationKey}::ctx-1", Status = new global::A2A.TaskStatus { State = TaskState.Completed } }); - services.AddKeyedSingleton(AgentName, mockTaskStore.Object); + services.AddKeyedSingleton(AgentName, mockTaskStore.Object); var mockKeyProvider = new Mock(); mockKeyProvider