Repository navigation
.NET: Scope OpenAI hosting storage by isolation key #8146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
SergeyMenshykh
merged 4 commits into
microsoft:main
from
SergeyMenshykh:sergeymenshykh-openai-storage-scoping
Sep 8, 2026
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c68716c
.NET: Scope OpenAI storage by isolation key
SergeyMenshykh cd55760
.NET: Address OpenAI storage review feedback
SergeyMenshykh 25b4a85
.NET: Bound conversation index cache entries
SergeyMenshykh e9ff458
.NET: Test claims-based OpenAI isolation
SergeyMenshykh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
57 changes: 57 additions & 0 deletions
57
...rosoft.Agents.AI.Hosting.OpenAI/Conversations/IsolationKeyScopedAgentConversationIndex.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using System; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Agents.AI.Hosting.OpenAI.Models; | ||
|
|
||
| namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations; | ||
|
|
||
| /// <summary> | ||
| /// A delegating <see cref="IAgentConversationIndex"/> that scopes the index by the caller's isolation key, | ||
| /// so that listing conversations for an agent returns only the caller's own conversations. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// The agent identifier forms the index key and is therefore scoped; the conversation identifiers held in | ||
| /// the index remain bare, so they can be passed straight back into <see cref="IConversationStorage"/>. | ||
| /// </remarks> | ||
| internal sealed class IsolationKeyScopedAgentConversationIndex : IAgentConversationIndex | ||
| { | ||
| private readonly IAgentConversationIndex _innerIndex; | ||
| private readonly IsolationKeyResolver _resolver; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="IsolationKeyScopedAgentConversationIndex"/> class. | ||
| /// </summary> | ||
| /// <param name="innerIndex">The underlying index to delegate to.</param> | ||
| /// <param name="resolver">The resolver used to scope agent identifiers.</param> | ||
| public IsolationKeyScopedAgentConversationIndex(IAgentConversationIndex innerIndex, IsolationKeyResolver resolver) | ||
| { | ||
| this._innerIndex = innerIndex ?? throw new ArgumentNullException(nameof(innerIndex)); | ||
| this._resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task AddConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default) | ||
| { | ||
| string scopedAgentId = await this._resolver.ScopeIdAsync(agentId, cancellationToken).ConfigureAwait(false); | ||
|
SergeyMenshykh marked this conversation as resolved.
Outdated
|
||
|
|
||
| await this._innerIndex.AddConversationAsync(scopedAgentId, conversationId, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task RemoveConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default) | ||
| { | ||
| string scopedAgentId = await this._resolver.ScopeIdAsync(agentId, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| await this._innerIndex.RemoveConversationAsync(scopedAgentId, conversationId, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<ListResponse<string>> GetConversationIdsAsync(string agentId, CancellationToken cancellationToken = default) | ||
| { | ||
| string scopedAgentId = await this._resolver.ScopeIdAsync(agentId, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return await this._innerIndex.GetConversationIdsAsync(scopedAgentId, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } | ||
116 changes: 116 additions & 0 deletions
116
...Microsoft.Agents.AI.Hosting.OpenAI/Conversations/IsolationKeyScopedConversationStorage.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models; | ||
| using Microsoft.Agents.AI.Hosting.OpenAI.Models; | ||
| using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; | ||
|
|
||
| namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations; | ||
|
|
||
| /// <summary> | ||
| /// A delegating <see cref="IConversationStorage"/> that scopes conversation keys by the caller's isolation | ||
| /// key, so that a conversation can only be resolved by the caller that created it. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Only the storage key is scoped. The <see cref="Conversation.Id"/> observed by callers is always the bare | ||
| /// identifier, so the wire format of the OpenAI Conversations API is unchanged. | ||
| /// </remarks> | ||
| internal sealed class IsolationKeyScopedConversationStorage : IConversationStorage | ||
| { | ||
| private readonly IConversationStorage _innerStorage; | ||
| private readonly IsolationKeyResolver _resolver; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="IsolationKeyScopedConversationStorage"/> class. | ||
| /// </summary> | ||
| /// <param name="innerStorage">The underlying storage to delegate to.</param> | ||
| /// <param name="resolver">The resolver used to scope conversation identifiers.</param> | ||
| public IsolationKeyScopedConversationStorage(IConversationStorage innerStorage, IsolationKeyResolver resolver) | ||
| { | ||
| this._innerStorage = innerStorage ?? throw new ArgumentNullException(nameof(innerStorage)); | ||
| this._resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); | ||
|
SergeyMenshykh marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<Conversation> CreateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(conversation); | ||
|
SergeyMenshykh marked this conversation as resolved.
Outdated
|
||
|
|
||
| var key = await this._resolver.GetKeyAsync(cancellationToken).ConfigureAwait(false); | ||
|
|
||
| var created = await this._innerStorage.CreateConversationAsync(ScopeConversation(conversation, key), cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return UnscopeConversation(created, key); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<Conversation?> GetConversationAsync(string conversationId, CancellationToken cancellationToken = default) | ||
| { | ||
| var key = await this._resolver.GetKeyAsync(cancellationToken).ConfigureAwait(false); | ||
|
|
||
| var conversation = await this._innerStorage.GetConversationAsync(IsolationKeyResolver.ScopeId(conversationId, key), cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return conversation is null ? null : UnscopeConversation(conversation, key); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<Conversation?> UpdateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(conversation); | ||
|
|
||
| var key = await this._resolver.GetKeyAsync(cancellationToken).ConfigureAwait(false); | ||
|
|
||
| var updated = await this._innerStorage.UpdateConversationAsync(ScopeConversation(conversation, key), cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return updated is null ? null : UnscopeConversation(updated, key); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<bool> DeleteConversationAsync(string conversationId, CancellationToken cancellationToken = default) | ||
| { | ||
| var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return await this._innerStorage.DeleteConversationAsync(scopedId, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task AddItemsAsync(string conversationId, IEnumerable<ItemResource> items, CancellationToken cancellationToken = default) | ||
| { | ||
| var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| await this._innerStorage.AddItemsAsync(scopedId, items, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<ItemResource?> GetItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default) | ||
| { | ||
| var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return await this._innerStorage.GetItemAsync(scopedId, itemId, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<ListResponse<ItemResource>> ListItemsAsync(string conversationId, int? limit = null, SortOrder? order = null, string? after = null, CancellationToken cancellationToken = default) | ||
| { | ||
| var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return await this._innerStorage.ListItemsAsync(scopedId, limit, order, after, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<bool> DeleteItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default) | ||
| { | ||
| var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return await this._innerStorage.DeleteItemAsync(scopedId, itemId, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| private static Conversation ScopeConversation(Conversation conversation, string? key) | ||
| => key is null ? conversation : conversation with { Id = IsolationKeyResolver.ScopeId(conversation.Id, key) }; | ||
|
|
||
| private static Conversation UnscopeConversation(Conversation conversation, string? key) | ||
| => key is null ? conversation : conversation with { Id = IsolationKeyResolver.UnscopeId(conversation.Id, key) }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.