diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c7af228299..588dff618a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -91,6 +91,9 @@ + + + @@ -310,6 +313,7 @@ + @@ -341,6 +345,7 @@ + diff --git a/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj new file mode 100644 index 0000000000..8dc509efed --- /dev/null +++ b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/Purview/AgentWithPurview/Program.cs b/dotnet/samples/Purview/AgentWithPurview/Program.cs new file mode 100644 index 0000000000..842917b427 --- /dev/null +++ b/dotnet/samples/Purview/AgentWithPurview/Program.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent with Purview integration. +// It uses Azure OpenAI as the backend, but any IChatClient can be used. +// Authentication to Purview is done using an InteractiveBrowserCredential. +// Any TokenCredential with Purview API permissions can be used here. + +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Purview; +using Microsoft.Extensions.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID") ?? throw new InvalidOperationException("PURVIEW_CLIENT_APP_ID is not set."); + +// This will get a user token for an entra app configured to call the Purview API. +// Any TokenCredential with permissions to call the Purview API can be used here. +TokenCredential browserCredential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialOptions + { + ClientId = purviewClientAppId + }); + +using IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); + +Console.WriteLine("Enter a prompt to send to the client:"); +string? promptText = Console.ReadLine(); + +if (!string.IsNullOrEmpty(promptText)) +{ + // Invoke the agent and output the text result. + Console.WriteLine(await client.GetResponseAsync(promptText)); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs new file mode 100644 index 0000000000..d55c5a6a66 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Service that runs jobs in background threads. +/// +internal sealed class BackgroundJobRunner +{ + private readonly IChannelHandler _channelHandler; + private readonly IPurviewClient _purviewClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The channel handler used to manage job channels. + /// The Purview client used to send requests to Purview. + /// The logger used to log information about background jobs. + /// The settings used to configure Purview client behavior. + public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings) + { + this._channelHandler = channelHandler; + this._purviewClient = purviewClient; + this._logger = logger; + + for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++) + { + this._channelHandler.AddRunner(async (Channel channel) => + { + await foreach (BackgroundJobBase job in channel.Reader.ReadAllAsync().ConfigureAwait(false)) + { + try + { + await this.RunJobAsync(job).ConfigureAwait(false); + } + catch (Exception e) when ( + !(e is OperationCanceledException) && + !(e is SystemException)) + { + this._logger.LogError(e, "Error running background job {BackgroundJobError}.", e.Message); + } + } + }); + } + } + + /// + /// Runs a job. + /// + /// The job to run. + /// A task representing the job. + private async Task RunJobAsync(BackgroundJobBase job) + { + switch (job) + { + case ProcessContentJob processContentJob: + _ = await this._purviewClient.ProcessContentAsync(processContentJob.Request, CancellationToken.None).ConfigureAwait(false); + break; + case ContentActivityJob contentActivityJob: + _ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false); + break; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs new file mode 100644 index 0000000000..472b53c50b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/CacheProvider.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Manages caching of values. +/// +internal sealed class CacheProvider : ICacheProvider +{ + private readonly IDistributedCache _cache; + private readonly PurviewSettings _purviewSettings; + + /// + /// Create a new instance of the class. + /// + /// The cache where the data is stored. + /// The purview integration settings. + public CacheProvider(IDistributedCache cache, PurviewSettings purviewSettings) + { + this._cache = cache; + this._purviewSettings = purviewSettings; + } + + /// + /// Get a value from the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to look up in the cache. + /// A cancellation token for the async operation. + /// The value in the cache. Null or default if no value is present. + public async Task GetAsync(TKey key, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + byte[]? data = await this._cache.GetAsync(serializedKey, cancellationToken).ConfigureAwait(false); + if (data == null) + { + return default; + } + + JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue)); + + return JsonSerializer.Deserialize(data, valueTypeInfo); + } + + /// + /// Set a value in the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to identify the cache entry. + /// The value to cache. + /// A cancellation token for the async operation. + /// A task for the async operation. + public Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + JsonTypeInfo valueTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue)); + byte[] serializedValue = JsonSerializer.SerializeToUtf8Bytes(value, valueTypeInfo); + + DistributedCacheEntryOptions cacheOptions = new() { AbsoluteExpirationRelativeToNow = this._purviewSettings.CacheTTL }; + + return this._cache.SetAsync(serializedKey, serializedValue, cacheOptions, cancellationToken); + } + + /// + /// Removes a value from the cache. + /// + /// The type of the key. + /// The key to identify the cache entry. + /// The cancellation token for the async operation. + /// A task for the async operation. + public Task RemoveAsync(TKey key, CancellationToken cancellationToken) + { + JsonTypeInfo keyTypeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey)); + string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo); + + return this._cache.RemoveAsync(serializedKey, cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs new file mode 100644 index 0000000000..ed3111fb3f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Handler class for background job management. +/// +internal class ChannelHandler : IChannelHandler +{ + private readonly Channel _jobChannel; + private readonly List _channelListeners; + private readonly ILogger _logger; + private readonly PurviewSettings _purviewSettings; + + /// + /// Creates a new instance of JobHandler. + /// + /// The purview integration settings. + /// The logger used for logging job information. + /// The job channel used for queuing and reading background jobs. + public ChannelHandler(PurviewSettings purviewSettings, ILogger logger, Channel jobChannel) + { + this._purviewSettings = purviewSettings; + this._logger = logger; + this._jobChannel = jobChannel; + + this._channelListeners = new List(this._purviewSettings.MaxConcurrentJobConsumers); + } + + /// + public void QueueJob(BackgroundJobBase job) + { + try + { + if (job == null) + { + throw new PurviewJobException("Cannot queue null job."); + } + + if (this._channelListeners.Count == 0) + { + this._logger.LogWarning("No listeners are available to process the job."); + throw new PurviewJobException("No listeners are available to process the job."); + } + + bool canQueue = this._jobChannel.Writer.TryWrite(job); + + if (!canQueue) + { + int jobCount = this._jobChannel.Reader.Count; + this._logger.LogError("Could not queue a job for background processing."); + + if (this._jobChannel.Reader.Completion.IsCompleted) + { + throw new PurviewJobException("Job channel is closed or completed. Cannot queue job."); + } + else if (jobCount >= this._purviewSettings.PendingBackgroundJobLimit) + { + throw new PurviewJobLimitExceededException($"Job queue is full. Current pending jobs: {jobCount}. Maximum number of queued jobs: {this._purviewSettings.PendingBackgroundJobLimit}"); + } + else + { + throw new PurviewJobException("Could not queue job for background processing."); + } + } + } + catch (Exception e) + { + if (this._purviewSettings.IgnoreExceptions) + { + this._logger.LogError(e, "Error queuing job: {ExceptionMessage}", e.Message); + } + else + { + throw; + } + } + } + + /// + public void AddRunner(Func, Task> runnerTask) + { + this._channelListeners.Add(Task.Run(async () => await runnerTask(this._jobChannel).ConfigureAwait(false))); + } + + /// + public async Task StopAndWaitForCompletionAsync() + { + this._jobChannel.Writer.Complete(); + await this._jobChannel.Reader.Completion.ConfigureAwait(false); + await Task.WhenAll(this._channelListeners).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs new file mode 100644 index 0000000000..610f0748bc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Constants.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Shared constants for the Purview service. +/// +internal static class Constants +{ + /// + /// The odata type property name used in requests and responses. + /// + public const string ODataTypePropertyName = "@odata.type"; + + /// + /// The OData Graph namespace used for odata types. + /// + public const string ODataGraphNamespace = "microsoft.graph"; + + /// + /// The name of the property that contains the conversation id. + /// + public const string ConversationId = "conversationId"; + + /// + /// The name of the property that contains the user id. + /// + public const string UserId = "userId"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs new file mode 100644 index 0000000000..83f80f3eb8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewAuthenticationException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for authentication errors related to Purview. +/// +public class PurviewAuthenticationException : PurviewException +{ + /// + public PurviewAuthenticationException(string message) + : base(message) + { + } + + /// + public PurviewAuthenticationException() : base() + { + } + + /// + public PurviewAuthenticationException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs new file mode 100644 index 0000000000..36c859d9b1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// General base exception type for Purview service errors. +/// +public class PurviewException : Exception +{ + /// + public PurviewException(string message) + : base(message) + { + } + + /// + public PurviewException() : base() + { + } + + /// + public PurviewException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs new file mode 100644 index 0000000000..1737b70f1f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents errors that occur during the execution of a Purview job. +/// +/// This exception is thrown when a Purview job encounters an error that prevents it from completing successfully. +internal class PurviewJobException : PurviewException +{ + /// + public PurviewJobException(string message) : base(message) + { + } + + /// + public PurviewJobException() : base() + { + } + + /// + public PurviewJobException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs new file mode 100644 index 0000000000..7560000a55 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewJobLimitExceededException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents an exception that is thrown when the maximum number of concurrent Purview jobs has been exceeded. +/// +/// This exception indicates that the Purview service has reached its limit for concurrent job executions. +internal class PurviewJobLimitExceededException : PurviewJobException +{ + /// + public PurviewJobLimitExceededException(string message) : base(message) + { + } + + /// + public PurviewJobLimitExceededException() : base() + { + } + + /// + public PurviewJobLimitExceededException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs new file mode 100644 index 0000000000..28a6c70323 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewPaymentRequiredException.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for payment required errors related to Purview. +/// +public class PurviewPaymentRequiredException : PurviewException +{ + /// + public PurviewPaymentRequiredException(string message) : base(message) + { + } + + /// + public PurviewPaymentRequiredException() : base() + { + } + + /// + public PurviewPaymentRequiredException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs new file mode 100644 index 0000000000..71483886d2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRateLimitException.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for rate limit exceeded errors from Purview service. +/// +public class PurviewRateLimitException : PurviewException +{ + /// + public PurviewRateLimitException(string message) + : base(message) + { + } + + /// + public PurviewRateLimitException() : base() + { + } + + /// + public PurviewRateLimitException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs new file mode 100644 index 0000000000..a34fad6ce4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Exceptions/PurviewRequestException.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Exception for general http request errors from Purview. +/// +public class PurviewRequestException : PurviewException +{ + /// + /// HTTP status code returned by the Purview service. + /// + public HttpStatusCode StatusCode { get; } + + /// + public PurviewRequestException(HttpStatusCode statusCode, string endpointName) + : base($"Failed to call {endpointName}. Status code: {statusCode}") + { + this.StatusCode = statusCode; + } + + /// + public PurviewRequestException(string message) + : base(message) + { + } + + /// + public PurviewRequestException() : base() + { + } + + /// + public PurviewRequestException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs new file mode 100644 index 0000000000..6d6dad527c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ICacheProvider.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Manages caching of values. +/// +internal interface ICacheProvider +{ + /// + /// Get a value from the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to look up in the cache. + /// A cancellation token for the async operation. + /// The value in the cache. Null or default if no value is present. + Task GetAsync(TKey key, CancellationToken cancellationToken); + + /// + /// Set a value in the cache. + /// + /// The type of the key in the cache. Used for serialization. + /// The type of the value in the cache. Used for serialization. + /// The key to identify the cache entry. + /// The value to cache. + /// A cancellation token for the async operation. + /// A task for the async operation. + Task SetAsync(TKey key, TValue value, CancellationToken cancellationToken); + + /// + /// Removes a value from the cache. + /// + /// The type of the key. + /// The key to identify the cache entry. + /// The cancellation token for the async operation. + /// A task for the async operation. + Task RemoveAsync(TKey key, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs new file mode 100644 index 0000000000..d8593abd48 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IChannelHandler.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Jobs; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Interface for a class that controls background job processing. +/// +internal interface IChannelHandler +{ + /// + /// Queue a job for background processing. + /// + /// The job queued for background processing. + void QueueJob(BackgroundJobBase job); + + /// + /// Add a runner to the channel handler. + /// + /// The runner task used to process jobs. + void AddRunner(Func, Task> runnerTask); + + /// + /// Stop the channel and wait for all runners to complete + /// + /// A task representing the job. + Task StopAndWaitForCompletionAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs new file mode 100644 index 0000000000..00de9051ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IPurviewClient.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Defines methods for interacting with the Purview service, including content processing, +/// protection scope management, and activity tracking. +/// +/// This interface provides methods to interact with various Purview APIs. It includes processing content, managing protection +/// scopes, and sending content activity data. Implementations of this interface are expected to handle communication +/// with the Purview service and manage any necessary authentication or error handling. +internal interface IPurviewClient +{ + /// + /// Get user info from auth token. + /// + /// The cancellation token used to cancel async processing. + /// The default tenant id used to retrieve the token and its info. + /// The token info from the token. + /// Throw if the token was invalid or could not be retrieved. + Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default); + + /// + /// Call ProcessContent API. + /// + /// The request containing the content to process. + /// The cancellation token used to cancel async processing. + /// The response from the Purview API. + /// Thrown for validation, auth, and network errors. + Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken); + + /// + /// Call user ProtectionScope API. + /// + /// The request containing the protection scopes metadata. + /// The cancellation token used to cancel async processing. + /// The protection scopes that apply to the data sent in the request. + /// Thrown for validation, auth, and network errors. + Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken); + + /// + /// Call contentActivities API. + /// + /// The request containing the content metadata. Used to generate interaction records. + /// The cancellation token used to cancel async processing. + /// The response from the Purview API. + /// Thrown for validation, auth, and network errors. + Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs new file mode 100644 index 0000000000..059e7c4d2d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/IScopedContentProcessor.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Orchestrates the processing of scoped content by combining protection scope, process content, and content activities operations. +/// +internal interface IScopedContentProcessor +{ + /// + /// Process a list of messages. + /// The list of messages should be a prompt or response. + /// + /// A list of objects sent to the agent or received from the agent.. + /// The thread where the messages were sent. + /// An activity to indicate prompt or response. + /// Purview settings containing tenant id, app name, etc. + /// The user who sent the prompt or is receiving the response. + /// Cancellation token. + /// A bool indicating if the request should be blocked and the user id of the user who made the request. + Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj new file mode 100644 index 0000000000..20eca86359 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj @@ -0,0 +1,43 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + true + true + true + + + + + + + + + + + + + + + + + + Microsoft.Agents.AI.Purview + Tools to connect generative AI apps to Microsoft Purview. + + + + + + + + + $(NoWarn);CA1812 + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs new file mode 100644 index 0000000000..15c1fbab00 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIAgentInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Info about an AI agent associated with the content. +/// +internal sealed class AIAgentInfo +{ + /// + /// Gets or sets agent id. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Gets or sets agent name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets agent version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs new file mode 100644 index 0000000000..d9b56f3911 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AIInteractionPlugin.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a plugin used in an AI interaction within the Purview SDK. +/// +internal sealed class AIInteractionPlugin +{ + /// + /// Gets or sets Plugin id. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Gets or sets Plugin Name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets Plugin Version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs new file mode 100644 index 0000000000..e9a18543c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/AccessedResourceDetails.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Information about a resource accessed during a conversation. +/// +internal sealed class AccessedResourceDetails +{ + /// + /// Resource ID. + /// + [JsonPropertyName("identifier")] + public string? Identifier { get; set; } + + /// + /// Resource name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Resource URL. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Sensitivity label id detected on the resource. + /// + [JsonPropertyName("labelId")] + public string? LabelId { get; set; } + + /// + /// Access type performed on the resource. + /// + [JsonPropertyName("accessType")] + public ResourceAccessType AccessType { get; set; } + + /// + /// Status of the access operation. + /// + [JsonPropertyName("status")] + public ResourceAccessStatus Status { get; set; } + + /// + /// Indicates if cross prompt injection was detected. + /// + [JsonPropertyName("isCrossPromptInjectionDetected")] + public bool? IsCrossPromptInjectionDetected { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs new file mode 100644 index 0000000000..5f9fdeb9d7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Activity.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Activity definitions +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum Activity : int +{ + /// + /// Unknown activity + /// + [EnumMember(Value = "unknown")] + Unknown = 0, + + /// + /// Upload text + /// + [EnumMember(Value = "uploadText")] + UploadText = 1, + + /// + /// Upload file + /// + [EnumMember(Value = "uploadFile")] + UploadFile = 2, + + /// + /// Download text + /// + [EnumMember(Value = "downloadText")] + DownloadText = 3, + + /// + /// Download file + /// + [EnumMember(Value = "downloadFile")] + DownloadFile = 4, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs new file mode 100644 index 0000000000..deefc24560 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ActivityMetadata.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request for metadata information +/// +[DataContract] +internal sealed class ActivityMetadata +{ + /// + /// Initializes a new instance of the class. + /// + /// The activity performed with the content. + public ActivityMetadata(Activity activity) + { + this.Activity = activity; + } + + /// + /// The activity performed with the content. + /// + [DataMember] + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("activity")] + public Activity Activity { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs new file mode 100644 index 0000000000..e52bf9ebb4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationErrorBase.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base error contract returned when some exception occurs. +/// +[JsonDerivedType(typeof(ProcessingError))] +internal class ClassificationErrorBase +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets the message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Gets or sets target of error. + /// + [JsonPropertyName("target")] + public string? Target { get; set; } + + /// + /// Gets or sets an object containing more specific information than the current object about the error. + /// It can't be a Dictionary because OData will make ClassificationErrorBase open type. It's not expected behavior. + /// + [JsonPropertyName("innerError")] + public ClassificationInnerError? InnerError { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs new file mode 100644 index 0000000000..1133529188 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ClassificationInnerError.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Inner classification error. +/// +internal sealed class ClassificationInnerError +{ + /// + /// Gets or sets date of error. + /// + [JsonPropertyName("date")] + public DateTime? Date { get; set; } + + /// + /// Gets or sets error code. + /// + [JsonPropertyName("code")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets client request ID. + /// + [JsonPropertyName("clientRequestId")] + public string? ClientRequestId { get; set; } + + /// + /// Gets or sets Activity ID. + /// + [JsonPropertyName("activityId")] + public string? ActivityId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs new file mode 100644 index 0000000000..9619d27fc8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for content items to be processed by the Purview SDK. +/// +[JsonDerivedType(typeof(PurviewTextContent))] +[JsonDerivedType(typeof(PurviewBinaryContent))] +internal abstract class ContentBase : GraphDataTypeBase +{ + /// + /// Creates a new instance of the class. + /// + /// The graph data type of the content. + public ContentBase(string dataType) : base(dataType) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs new file mode 100644 index 0000000000..3d57a02aee --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentProcessingErrorType.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Type of error that occurred during content processing. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ContentProcessingErrorType +{ + /// + /// Error is transient. + /// + Transient, + + /// + /// Error is permanent. + /// + Permanent, + + /// + /// Unknown future value placeholder. + /// + UnknownFutureValue +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs new file mode 100644 index 0000000000..9e2e5824f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentToProcess.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Content to be processed by process content. +/// +internal sealed class ContentToProcess +{ + /// + /// Creates a new instance of ContentToProcess. + /// + /// The content to send and its associated ids. + /// Metadata about the activity performed with the content. + /// Metadata about the device that produced the content. + /// Metadata about the application integrating with Purview. + /// Metadata about the application being protected by Purview. + public ContentToProcess( + List contentEntries, + ActivityMetadata activityMetadata, + DeviceMetadata deviceMetadata, + IntegratedAppMetadata integratedAppMetadata, + ProtectedAppMetadata protectedAppMetadata) + { + this.ContentEntries = contentEntries; + this.ActivityMetadata = activityMetadata; + this.DeviceMetadata = deviceMetadata; + this.IntegratedAppMetadata = integratedAppMetadata; + this.ProtectedAppMetadata = protectedAppMetadata; + } + + /// + /// Gets or sets the content entries. + /// List of activities supported by caller. It is used to trim response to activities interesting to the caller. + /// + [JsonPropertyName("contentEntries")] + public List ContentEntries { get; set; } + + /// + /// Activity metadata + /// + [DataMember] + [JsonPropertyName("activityMetadata")] + public ActivityMetadata ActivityMetadata { get; set; } + + /// + /// Device metadata + /// + [DataMember] + [JsonPropertyName("deviceMetadata")] + public DeviceMetadata DeviceMetadata { get; set; } + + /// + /// Integrated app metadata + /// + [DataMember] + [JsonPropertyName("integratedAppMetadata")] + public IntegratedAppMetadata IntegratedAppMetadata { get; set; } + + /// + /// Protected app metadata + /// + [DataMember] + [JsonPropertyName("protectedAppMetadata")] + public ProtectedAppMetadata ProtectedAppMetadata { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs new file mode 100644 index 0000000000..3a60686be3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DeviceMetadata.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Endpoint device Metdata +/// +internal sealed class DeviceMetadata +{ + /// + /// Device type + /// + [JsonPropertyName("deviceType")] + public string? DeviceType { get; set; } + + /// + /// The ip address of the device. + /// + [JsonPropertyName("ipAddress")] + public string? IpAddress { get; set; } + + /// + /// OS specifications + /// + [JsonPropertyName("operatingSystemSpecifications")] + public OperatingSystemSpecifications? OperatingSystemSpecifications { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs new file mode 100644 index 0000000000..8eda013588 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpAction.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Defines all the actions for DLP. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum DlpAction +{ + /// + /// The DLP action to notify user. + /// + NotifyUser, + + /// + /// The DLP action is block. + /// + BlockAccess, + + /// + /// The DLP action to apply restrictions on device. + /// + DeviceRestriction, + + /// + /// The DLP action to apply restrictions on browsers. + /// + BrowserRestriction, + + /// + /// The DLP action to generate an alert + /// + GenerateAlert, + + /// + /// The DLP action to generate an incident report + /// + GenerateIncidentReportAction, + + /// + /// The DLP action to block anonymous link access in SPO + /// + SPBlockAnonymousAccess, + + /// + /// DLP Action to disallow guest access in SPO + /// + SPRuntimeAccessControl, + + /// + /// DLP No Op action for NotifyUser. Used in Block Access V2 rule + /// + SPSharingNotifyUser, + + /// + /// DLP No Op action for GIR. Used in Block Access V2 rule + /// + SPSharingGenerateIncidentReport, + + /// + /// Restrict access action for data in motion scenarios. + /// Advanced version of BlockAccess which can take both enforced restriction mode (Audit, Block, etc.) + /// and action triggers (Print, SaveToLocal, etc.) as parameters. + /// + RestrictAccess, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs new file mode 100644 index 0000000000..a5846acadc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/DlpActionInfo.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class to define DLP Actions. +/// +internal sealed class DlpActionInfo +{ + /// + /// Gets or sets the type of the DLP action. + /// + [JsonPropertyName("action")] + public DlpAction Action { get; set; } + + /// + /// The type of restriction action to take. + /// + [JsonPropertyName("restrictionAction")] + public RestrictionAction? RestrictionAction { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs new file mode 100644 index 0000000000..dd79ee13ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ErrorDetails.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents the details of an error. +/// +internal sealed class ErrorDetails +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// Gets or sets the error message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs new file mode 100644 index 0000000000..3fecfbb3f4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ExecutionMode.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request execution mode +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ExecutionMode : int +{ + /// + /// Evaluate inline. + /// + EvaluateInline = 1, + + /// + /// Evaluate offline. + /// + EvaluateOffline = 2, + + /// + /// Unknown future value. + /// + UnknownFutureValue = 3 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs new file mode 100644 index 0000000000..b4334fdb43 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for all graph data types used in the Purview SDK. +/// +internal abstract class GraphDataTypeBase +{ + /// + /// Create a new instance of the class. + /// + /// The data type of the graph object. + public GraphDataTypeBase(string dataType) + { + this.DataType = dataType; + } + + /// + /// The @odata.type property name used in the JSON representation of the object. + /// + [JsonPropertyName(Constants.ODataTypePropertyName)] + public string DataType { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs new file mode 100644 index 0000000000..1a5e8b5e13 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/IntegratedAppMetadata.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Request for metadata information +/// +[JsonDerivedType(typeof(ProtectedAppMetadata))] +internal class IntegratedAppMetadata +{ + /// + /// Application name + /// + [DataMember] + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Application version + /// + [DataMember] + [JsonPropertyName("version")] + public string? Version { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs new file mode 100644 index 0000000000..3ea8837177 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/OperatingSystemSpecifications.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Operating System Specifications +/// +internal sealed class OperatingSystemSpecifications +{ + /// + /// OS platform + /// + [JsonPropertyName("operatingSystemPlatform")] + public string? OperatingSystemPlatform { get; set; } + + /// + /// OS version + /// + [JsonPropertyName("operatingSystemVersion")] + public string? OperatingSystemVersion { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs new file mode 100644 index 0000000000..9898f62e01 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyBinding.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents user scoping information, i.e. which users are affected by the policy. +/// +internal sealed class PolicyBinding +{ + /// + /// Gets or sets the users to be included. + /// + [JsonPropertyName("inclusions")] + public ICollection? Inclusions { get; set; } + + /// + /// Gets or sets the users to be excluded. + /// Exclusions may not be present in the response, thus this property is nullable. + /// + [JsonPropertyName("exclusions")] + public ICollection? Exclusions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs new file mode 100644 index 0000000000..c0a40974e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyLocation.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a location to which policy is applicable. +/// +internal sealed class PolicyLocation : GraphDataTypeBase +{ + /// + /// Creates a new instance of the class. + /// + /// The graph data type of the PolicyLocation object. + /// THe value of the policy location: app id, domain, etc. + public PolicyLocation(string dataType, string value) : base(dataType) + { + this.Value = value; + } + + /// + /// Gets or sets the applicable value for location. + /// + [JsonPropertyName("value")] + public string Value { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs new file mode 100644 index 0000000000..d56a374842 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyPivotProperty.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Property for policy scoping response to aggregate on +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum PolicyPivotProperty : int +{ + /// + /// Unknown activity + /// + [EnumMember] + [JsonPropertyName("none")] + None = 0, + + /// + /// Pivot on Activity + /// + [EnumMember] + [JsonPropertyName("activity")] + Activity = 1, + + /// + /// Pivot on location + /// + [EnumMember] + [JsonPropertyName("location")] + Location = 2, + + /// + /// Pivot on location + /// + [EnumMember] + [JsonPropertyName("unknownFutureValue")] + UnknownFutureValue = 3, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs new file mode 100644 index 0000000000..f00e941d35 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PolicyScope.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a scope for policy protection. +/// +internal sealed class PolicyScopeBase +{ + /// + /// Gets or sets the locations to be protected, e.g. domains or URLs. + /// + [JsonPropertyName("locations")] + public ICollection? Locations { get; set; } + + /// + /// Gets or sets the activities to be protected, e.g. uploadText, downloadText. + /// + [JsonPropertyName("activities")] + public ProtectionScopeActivities Activities { get; set; } + + /// + /// Gets or sets how policy should be executed - fire-and-forget or wait for completion. + /// + [JsonPropertyName("executionMode")] + public ExecutionMode ExecutionMode { get; set; } + + /// + /// Gets or sets the enforcement actions to be taken on activities and locations from this scope. + /// There may be no actions in the response. + /// + [JsonPropertyName("policyActions")] + public ICollection? PolicyActions { get; set; } + + /// + /// Gets or sets information about policy applicability to a specific user. + /// + [JsonPropertyName("policyScope")] + public PolicyBinding? PolicyScope { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs new file mode 100644 index 0000000000..51f4936e82 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Base class for process content metadata. +/// +[JsonDerivedType(typeof(ProcessConversationMetadata))] +[JsonDerivedType(typeof(ProcessFileMetadata))] +internal abstract class ProcessContentMetadataBase : GraphDataTypeBase +{ + private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata"; + + /// + /// Creates a new instance of ProcessContentMetadataBase. + /// + /// The content that will be processed. + /// The unique identifier for the content. + /// Indicates if the content is truncated. + /// The name of the content. + public ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType) + { + this.Identifier = identifier; + this.IsTruncated = isTruncated; + this.Content = content; + this.Name = name; + } + + /// + /// Gets or sets the identifier. + /// Unique id for the content. It is specific to the enforcement plane. Path is used as item unique identifier, e.g., guid of a message in the conversation, file URL, storage file path, message ID, etc. + /// + [JsonPropertyName("identifier")] + public string Identifier { get; set; } + + /// + /// Gets or sets the content. + /// The content to be processed. + /// + [JsonPropertyName("content")] + public ContentBase Content { get; set; } + + /// + /// Gets or sets the name. + /// Name of the content, e.g., file name or web page title. + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Gets or sets the correlationId. + /// Identifier to group multiple contents. + /// + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; set; } + + /// + /// Gets or sets the sequenceNumber. + /// Sequence in which the content was originally generated. + /// + [JsonPropertyName("sequenceNumber")] + public long? SequenceNumber { get; set; } + + /// + /// Gets or sets the length. + /// Content length in bytes. + /// + [JsonPropertyName("length")] + public long? Length { get; set; } + + /// + /// Gets or sets the isTruncated. + /// Indicates if the original content has been truncated, e.g., to meet text or file size limits. + /// + [JsonPropertyName("isTruncated")] + public bool IsTruncated { get; set; } + + /// + /// Gets or sets the createdDateTime. + /// When the content was created. E.g., file created time or the time when a message was sent. + /// + [JsonPropertyName("createdDateTime")] + public DateTimeOffset CreatedDateTime { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets the modifiedDateTime. + /// When the content was last modified. E.g., file last modified time. For content created on the fly, such as messaging, whenModified and whenCreated are expected to be the same. + /// + [JsonPropertyName("modifiedDateTime")] + public DateTimeOffset? ModifiedDateTime { get; set; } = DateTime.UtcNow; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs new file mode 100644 index 0000000000..86bedb9248 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for conversation content to be processed by the Purview SDK. +/// +internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase +{ + private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata"; + + /// + /// Initializes a new instance of the class. + /// + public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + { + this.DataType = ProcessConversationMetadataDataType; + } + + /// + /// Gets or sets the parent message ID for nested conversations. + /// + [JsonPropertyName("parentMessageId")] + public string? ParentMessageId { get; set; } + + /// + /// Gets or sets the accessed resources during message generation for bot messages. + /// + [JsonPropertyName("accessedResources_v2")] + public List? AccessedResources { get; set; } + + /// + /// Gets or sets the plugins used during message generation for bot messages. + /// + [JsonPropertyName("plugins")] + public List? Plugins { get; set; } + + /// + /// Gets or sets the collection of AI agent information. + /// + [JsonPropertyName("agents")] + public List? Agents { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs new file mode 100644 index 0000000000..a9f1749bed --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for a file content to be processed by the Purview SDK. +/// +internal sealed class ProcessFileMetadata : ProcessContentMetadataBase +{ + private const string ProcessFileMetadataDataType = Constants.ODataGraphNamespace + ".processFileMetadata"; + + /// + /// Initializes a new instance of the class. + /// + public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + { + this.DataType = ProcessFileMetadataDataType; + } + + /// + /// Gets or sets the owner ID. + /// + [JsonPropertyName("ownerId")] + public string? OwnerId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs new file mode 100644 index 0000000000..4852d5ca8a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessingError.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Contains information about a processing error. +/// +internal sealed class ProcessingError : ClassificationErrorBase +{ + /// + /// Details about the error. + /// + [JsonPropertyName("details")] + public List? Details { get; set; } + + /// + /// Gets or sets the error type. + /// + [JsonPropertyName("type")] + public ContentProcessingErrorType? Type { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs new file mode 100644 index 0000000000..984a4168e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectedAppMetadata.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents metadata for a protected application that is integrated with Purview. +/// +internal sealed class ProtectedAppMetadata : IntegratedAppMetadata +{ + /// + /// Creates a new instance of the class. + /// + /// The location information of the protected app's data. + public ProtectedAppMetadata(PolicyLocation applicationLocation) + { + this.ApplicationLocation = applicationLocation; + } + + /// + /// The location of the application. + /// + [JsonPropertyName("applicationLocation")] + public PolicyLocation ApplicationLocation { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs new file mode 100644 index 0000000000..6c93a76124 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeActivities.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Activities that can be protected by the Purview Protection Scopes API. +/// +[Flags] +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ProtectionScopeActivities +{ + /// + /// None. + /// + [EnumMember(Value = "none")] + None = 0, + + /// + /// Upload text activity. + /// + [EnumMember(Value = "uploadText")] + UploadText = 1, + + /// + /// Upload file activity. + /// + [EnumMember(Value = "uploadFile")] + UploadFile = 2, + + /// + /// Download text activity. + /// + [EnumMember(Value = "downloadText")] + DownloadText = 4, + + /// + /// Download file activity. + /// + [EnumMember(Value = "downloadFile")] + DownloadFile = 8, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 16 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs new file mode 100644 index 0000000000..8fc7a534ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopeState.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Indicates status of protection scope changes. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ProtectionScopeState +{ + /// + /// Scope state hasn't changed. + /// + NotModified = 0, + + /// + /// Scope state has changed. + /// + Modified = 1, + + /// + /// Unknown value placeholder for future use. + /// + UnknownFutureValue = 2 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs new file mode 100644 index 0000000000..2c772cbcb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProtectionScopesCacheKey.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// A cache key for storing protection scope responses. +/// +internal sealed class ProtectionScopesCacheKey +{ + /// + /// Creates a new instance of . + /// + /// The entra id of the user who made the interaction. + /// The tenant id of the user who made the interaction. + /// The activity performed with the data. + /// The location where the data came from. + /// The property to pivot on. + /// Metadata about the device that made the interaction. + /// Metadata about the app that is integrating with Purview. + public ProtectionScopesCacheKey( + string userId, + string tenantId, + ProtectionScopeActivities activities, + PolicyLocation? location, + PolicyPivotProperty? pivotOn, + DeviceMetadata? deviceMetadata, + IntegratedAppMetadata? integratedAppMetadata) + { + this.UserId = userId; + this.TenantId = tenantId; + this.Activities = activities; + this.Location = location; + this.PivotOn = pivotOn; + this.DeviceMetadata = deviceMetadata; + this.IntegratedAppMetadata = integratedAppMetadata; + } + + /// + /// Creates a mew instance of . + /// + /// A protection scopes request. + public ProtectionScopesCacheKey( + ProtectionScopesRequest request) : this( + request.UserId, + request.TenantId, + request.Activities, + request.Locations.FirstOrDefault(), + request.PivotOn, + request.DeviceMetadata, + request.IntegratedAppMetadata) + { + } + + /// + /// The id of the user making the request. + /// + public string UserId { get; set; } + + /// + /// The id of the tenant containing the user making the request. + /// + public string TenantId { get; set; } + + /// + /// The activity performed with the content. + /// + public ProtectionScopeActivities Activities { get; set; } + + /// + /// The location of the application. + /// + public PolicyLocation? Location { get; set; } + + /// + /// The property used to pivot the policy evaluation. + /// + public PolicyPivotProperty? PivotOn { get; set; } + + /// + /// Metadata about the device used to access the content. + /// + public DeviceMetadata? DeviceMetadata { get; set; } + + /// + /// Metadata about the integrated app used to access the content. + /// + public IntegratedAppMetadata? IntegratedAppMetadata { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs new file mode 100644 index 0000000000..0d65ac341d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewBinaryContent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a binary content item to be processed. +/// +internal sealed class PurviewBinaryContent : ContentBase +{ + private const string BinaryContentDataType = Constants.ODataGraphNamespace + ".binaryContent"; + + /// + /// Initializes a new instance of the class. + /// + /// The binary content in byte array format. + public PurviewBinaryContent(byte[] data) : base(BinaryContentDataType) + { + this.Data = data; + } + + /// + /// Gets or sets the binary data. + /// + [JsonPropertyName("data")] + public byte[] Data { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs new file mode 100644 index 0000000000..cfd03ae6ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PurviewTextContent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents a text content item to be processed. +/// +internal sealed class PurviewTextContent : ContentBase +{ + private const string TextContentDataType = Constants.ODataGraphNamespace + ".textContent"; + + /// + /// Initializes a new instance of the class. + /// + /// The text content in string format. + public PurviewTextContent(string data) : base(TextContentDataType) + { + this.Data = data; + } + + /// + /// Gets or sets the text data. + /// + [JsonPropertyName("data")] + public string Data { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs new file mode 100644 index 0000000000..623f138e8b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessStatus.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Status of the access operation. +/// +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ResourceAccessStatus +{ + /// + /// Represents failed access to the resource. + /// + [EnumMember(Value = "failure")] + Failure = 0, + + /// + /// Represents successful access to the resource. + /// + [EnumMember(Value = "success")] + Success = 1, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 2 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs new file mode 100644 index 0000000000..cb4e3b0cab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ResourceAccessType.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Access type performed on the resource. +/// +[Flags] +[DataContract] +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum ResourceAccessType : long +{ + /// + /// No access type. + /// + [EnumMember(Value = "none")] + None = 0, + + /// + /// Read access. + /// + [EnumMember(Value = "read")] + Read = 1 << 0, + + /// + /// Write access. + /// + [EnumMember(Value = "write")] + Write = 1 << 1, + + /// + /// Create access. + /// + [EnumMember(Value = "create")] + Create = 1 << 2, + + /// + /// Unknown future value. + /// + [EnumMember(Value = "unknownFutureValue")] + UnknownFutureValue = 1 << 3 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs new file mode 100644 index 0000000000..ea13ec36a6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/RestrictionAction.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Restriction actions for devices. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum RestrictionAction +{ + /// + /// Warn Action. + /// + Warn, + + /// + /// Audit action. + /// + Audit, + + /// + /// Block action. + /// + Block, + + /// + /// Allow action + /// + Allow +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs new file mode 100644 index 0000000000..9fc4de38fe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/Scope.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Represents tenant/user/group scopes. +/// +internal sealed class Scope +{ + /// + /// The odata type of the scope used to identify what type of scope was returned. + /// + [JsonPropertyName("@odata.type")] + public string? ODataType { get; set; } + + /// + /// Gets or sets the scope identifier. + /// + [JsonPropertyName("identity")] + public string? Identity { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs new file mode 100644 index 0000000000..bd1338dd64 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/TokenInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Info pulled from an auth token. +/// +internal sealed class TokenInfo +{ + /// + /// The entra id of the authenticated user. This is null if the auth token is not a user token. + /// + public string? UserId { get; set; } + + /// + /// The tenant id of the auth token. + /// + public string? TenantId { get; set; } + + /// + /// The client id of the auth token. + /// + public string? ClientId { get; set; } + + /// + /// Gets a value indicating whether the token is associated with a user. + /// + public bool IsUserToken => !string.IsNullOrEmpty(this.UserId); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs new file mode 100644 index 0000000000..ab8cc8a588 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Abstract base class for background jobs. +/// +internal abstract class BackgroundJobBase +{ +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs new file mode 100644 index 0000000000..513af7f331 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ContentActivityJob.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Class representing a job to send content activities to the Purview service. +/// +internal sealed class ContentActivityJob : BackgroundJobBase +{ + /// + /// Create a new instance of the class. + /// + /// The content activities request to be sent in the background. + public ContentActivityJob(ContentActivitiesRequest request) + { + this.Request = request; + } + + /// + /// The request to send to the Purview service. + /// + public ContentActivitiesRequest Request { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs new file mode 100644 index 0000000000..768588f9d7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ProcessContentJob.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Class representing a job to process content. +/// +internal sealed class ProcessContentJob : BackgroundJobBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The process content request to be sent in the background. + public ProcessContentJob(ProcessContentRequest request) + { + this.Request = request; + } + + /// + /// The request to process content. + /// + public ProcessContentRequest Request { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs new file mode 100644 index 0000000000..a754a5a56f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ContentActivitiesRequest.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// A request class used for contentActivity requests. +/// +internal sealed class ContentActivitiesRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The entra id of the user who performed the activity. + /// The tenant id of the user who performed the activity. + /// The metadata about the content that was sent. + /// The correlation id of the request. + /// The scope identifier of the protection scopes associated with this request. + public ContentActivitiesRequest(string userId, string tenantId, ContentToProcess contentMetadata, Guid correlationId = default, string? scopeIdentifier = null) + { + this.UserId = userId ?? throw new ArgumentNullException(nameof(userId)); + this.TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + this.ContentMetadata = contentMetadata ?? throw new ArgumentNullException(nameof(contentMetadata)); + this.CorrelationId = correlationId == default ? Guid.NewGuid() : correlationId; + this.ScopeIdentifier = scopeIdentifier; + } + + /// + /// Gets or sets the ID of the signal. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the user ID of the content that is generating the signal. + /// + [JsonPropertyName("userId")] + public string UserId { get; set; } + + /// + /// Gets or sets the scope identifier for the signal. + /// + [JsonPropertyName("scopeIdentifier")] + public string? ScopeIdentifier { get; set; } + + /// + /// Gets or sets the content and associated content metadata for the content used to generate the signal. + /// + [JsonPropertyName("contentMetadata")] + public ContentToProcess ContentMetadata { get; set; } + + /// + /// Gets or sets the correlation ID for the signal. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } + + /// + /// Gets or sets the tenant id for the signal. + /// + [JsonIgnore] + public string TenantId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs new file mode 100644 index 0000000000..f8e9602cef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// Request for ProcessContent API +/// +internal sealed class ProcessContentRequest +{ + /// + /// Creates a new instance of ProcessContentRequest. + /// + /// The content and its metadata that will be processed. + /// The entra user id of the user making the request. + /// The tenant id of the user making the request. + public ProcessContentRequest(ContentToProcess contentToProcess, string userId, string tenantId) + { + this.ContentToProcess = contentToProcess; + this.UserId = userId; + this.TenantId = tenantId; + } + + /// + /// The content to process. + /// + [JsonPropertyName("contentToProcess")] + public ContentToProcess ContentToProcess { get; set; } + + /// + /// The user id of the user making the request. + /// + [JsonIgnore] + public string UserId { get; set; } + + /// + /// The correlation id of the request. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } = Guid.NewGuid(); + + /// + /// The tenant id of the user making the request. + /// + [JsonIgnore] + public string TenantId { get; set; } + + /// + /// The identifier of the cached protection scopes. + /// + [JsonIgnore] + internal string? ScopeIdentifier { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs new file mode 100644 index 0000000000..04aba59aff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProtectionScopesRequest.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Requests; + +/// +/// Request model for user protection scopes requests. +/// +[DataContract] +internal sealed class ProtectionScopesRequest +{ + /// + /// Creates a new instance of ProtectionScopesRequest. + /// + /// The entra id of the user who made the interaction. + /// The tenant id of the user who made the interaction. + public ProtectionScopesRequest(string userId, string tenantId) + { + this.UserId = userId; + this.TenantId = tenantId; + } + + /// + /// Activities to include in the scope + /// + [DataMember] + [JsonPropertyName("activities")] + public ProtectionScopeActivities Activities { get; set; } + + /// + /// Gets or sets the locations to compute protection scopes for. + /// + [JsonPropertyName("locations")] + public ICollection Locations { get; set; } = Array.Empty(); + + /// + /// Response aggregation pivot + /// + [DataMember] + [JsonPropertyName("pivotOn")] + public PolicyPivotProperty? PivotOn { get; set; } + + /// + /// Device metadata + /// + [DataMember] + [JsonPropertyName("deviceMetadata")] + public DeviceMetadata? DeviceMetadata { get; set; } + + /// + /// Integrated app metadata + /// + [DataMember] + [JsonPropertyName("integratedAppMetadata")] + public IntegratedAppMetadata? IntegratedAppMetadata { get; set; } + + /// + /// The correlation id of the request. + /// + [JsonIgnore] + public Guid CorrelationId { get; set; } = Guid.NewGuid(); + + /// + /// Scope ID, used to detect stale client scoping information + /// + [DataMember] + [JsonIgnore] + public string ScopeIdentifier { get; set; } = string.Empty; + + /// + /// The id of the user making the request. + /// + [JsonIgnore] + public string UserId { get; set; } + + /// + /// The tenant id of the user making the request. + /// + [JsonIgnore] + public string TenantId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs new file mode 100644 index 0000000000..afdc21618e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ContentActivitiesResponse.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// Represents the response for content activities requests. +/// +internal sealed class ContentActivitiesResponse +{ + /// + /// Gets or sets the HTTP status code associated with the response. + /// + [JsonIgnore] + public HttpStatusCode StatusCode { get; set; } + + /// + /// Details about any errors returned by the request. + /// + [JsonPropertyName("error")] + public ErrorDetails? Error { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs new file mode 100644 index 0000000000..c685c7786f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProcessContentResponse.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// The response of a process content evaluation. +/// +internal sealed class ProcessContentResponse +{ + /// + /// Gets or sets the evaluation id. + /// + [Key] + public string? Id { get; set; } + + /// + /// Gets or sets the status of protection scope changes. + /// + [DataMember] + [JsonPropertyName("protectionScopeState")] + public ProtectionScopeState? ProtectionScopeState { get; set; } + + /// + /// Gets or sets the policy actions to take. + /// + [DataMember] + [JsonPropertyName("policyActions")] + public IReadOnlyList? PolicyActions { get; set; } + + /// + /// Gets or sets error information about the evaluation. + /// + [DataMember] + [JsonPropertyName("processingErrors")] + public IReadOnlyList? ProcessingErrors { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs new file mode 100644 index 0000000000..fb9b0603d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Responses/ProtectionScopesResponse.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview.Models.Responses; + +/// +/// A response object containing protection scopes for a tenant. +/// +internal sealed class ProtectionScopesResponse +{ + /// + /// The identifier used for caching the user protection scopes. + /// + public string? ScopeIdentifier { get; set; } + + /// + /// The user protection scopes. + /// + [JsonPropertyName("value")] + public IReadOnlyCollection? Scopes { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs new file mode 100644 index 0000000000..fd2a1950e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A middleware agent that connects to Microsoft Purview. +/// +internal class PurviewAgent : AIAgent, IDisposable +{ + private readonly AIAgent _innerAgent; + private readonly PurviewWrapper _purviewWrapper; + + /// + /// Initializes a new instance of the class. + /// + /// The agent-framework agent that the middleware wraps. + /// The purview wrapper used to interact with the Purview service. + public PurviewAgent(AIAgent innerAgent, PurviewWrapper purviewWrapper) + { + this._innerAgent = innerAgent; + this._purviewWrapper = purviewWrapper; + } + + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + return this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions); + } + + /// + public override AgentThread GetNewThread() + { + return this._innerAgent.GetNewThread(); + } + + /// + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken); + } + + /// + public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var response = await this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken).ConfigureAwait(false); + foreach (var update in response.ToAgentRunResponseUpdates()) + { + yield return update; + } + } + + /// + public void Dispose() + { + if (this._innerAgent is IDisposable disposableAgent) + { + disposableAgent.Dispose(); + } + + this._purviewWrapper.Dispose(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs new file mode 100644 index 0000000000..5e5d7af96f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAppLocation.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Purview.Models.Common; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// An identifier representing the app's location for Purview policy evaluation. +/// +public class PurviewAppLocation +{ + /// + /// Creates a new instance of . + /// + /// The type of location. + /// The value of the location. + public PurviewAppLocation(PurviewLocationType locationType, string locationValue) + { + this.LocationType = locationType; + this.LocationValue = locationValue; + } + + /// + /// The type of location. + /// + public PurviewLocationType LocationType { get; set; } + + /// + /// The location value. + /// + public string LocationValue { get; set; } + + /// + /// Returns the model for this . + /// + /// PolicyLocation request model. + /// Thrown when an invalid location type is provided. + internal PolicyLocation GetPolicyLocation() + { + switch (this.LocationType) + { + case PurviewLocationType.Application: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationApplication", this.LocationValue); + case PurviewLocationType.Uri: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationUrl", this.LocationValue); + case PurviewLocationType.Domain: + return new PolicyLocation($"{Constants.ODataGraphNamespace}.policyLocationDomain", this.LocationValue); + default: + throw new InvalidOperationException("Invalid location type."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs new file mode 100644 index 0000000000..fded26c0ae --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewChatClient.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A middleware chat client that connects to Microsoft Purview. +/// +internal class PurviewChatClient : IChatClient +{ + private readonly IChatClient _innerChatClient; + private readonly PurviewWrapper _purviewWrapper; + + /// + /// Initializes a new instance of the class. + /// + /// The inner chat client to wrap. + /// The purview wrapper used to interact with the Purview service. + public PurviewChatClient(IChatClient innerChatClient, PurviewWrapper purviewWrapper) + { + this._innerChatClient = innerChatClient; + this._purviewWrapper = purviewWrapper; + } + + /// + public void Dispose() + { + this._purviewWrapper.Dispose(); + this._innerChatClient.Dispose(); + } + + /// + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + return this._innerChatClient.GetService(serviceType, serviceKey); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Task responseTask = this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken); + + foreach (var update in (await responseTask.ConfigureAwait(false)).ToChatResponseUpdates()) + { + yield return update; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs new file mode 100644 index 0000000000..7fade4eabb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs @@ -0,0 +1,311 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Client for calling Purview APIs. +/// +internal sealed class PurviewClient : IPurviewClient +{ + private readonly TokenCredential _tokenCredential; + private readonly HttpClient _httpClient; + private readonly string[] _scopes; + private readonly string _graphUri; + private readonly ILogger _logger; + + private static PurviewException CreateExceptionForStatusCode(HttpStatusCode statusCode, string endpointName) + { + // .net framework does not support TooManyRequests, so we have to convert to an int. + switch ((int)statusCode) + { + case 429: + return new PurviewRateLimitException($"Rate limit exceeded for {endpointName}."); + case 401: + case 403: + return new PurviewAuthenticationException($"Unauthorized access to {endpointName}. Status code: {statusCode}"); + case 402: + return new PurviewPaymentRequiredException($"Payment required for {endpointName}. Status code: {statusCode}"); + default: + return new PurviewRequestException(statusCode, endpointName); + } + } + + /// + /// Creates a new instance. + /// + /// The token credential used to authenticate with Purview. + /// The settings used for purview requests. + /// The HttpClient used to make network requests to Purview. + /// The logger used to log information from the middleware. + public PurviewClient(TokenCredential tokenCredential, PurviewSettings purviewSettings, HttpClient httpClient, ILogger logger) + { + this._tokenCredential = tokenCredential; + this._httpClient = httpClient; + + this._scopes = new string[] { $"https://{purviewSettings.GraphBaseUri.Host}/.default" }; + this._graphUri = purviewSettings.GraphBaseUri.ToString().TrimEnd('/'); + this._logger = logger ?? NullLogger.Instance; + } + + private static TokenInfo ExtractTokenInfo(string tokenString) + { + // Split JWT and decode payload + string[] parts = tokenString.Split('.'); + if (parts.Length < 2) + { + throw new PurviewRequestException("Invalid JWT access token format."); + } + + string payload = parts[1]; + // Pad base64 string if needed + int mod4 = payload.Length % 4; + if (mod4 > 0) + { + payload += new string('=', 4 - mod4); + } + + byte[] bytes = Convert.FromBase64String(payload.Replace('-', '+').Replace('_', '/')); + string json = Encoding.UTF8.GetString(bytes); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + string? objectId = root.TryGetProperty("oid", out var oidProp) ? oidProp.GetString() : null; + string? idType = root.TryGetProperty("idtyp", out var idtypProp) ? idtypProp.GetString() : null; + string? tenant = root.TryGetProperty("tid", out var tidProp) ? tidProp.GetString() : null; + string? clientId = root.TryGetProperty("appid", out var appidProp) ? appidProp.GetString() : null; + + string? userId = idType == "user" ? objectId : null; + + return new TokenInfo + { + UserId = userId, + TenantId = tenant, + ClientId = clientId + }; + } + + /// + public async Task GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default) + { + TokenRequestContext tokenRequestContext = tenantId == null ? new(this._scopes) : new(this._scopes, tenantId: tenantId); + AccessToken token = await this._tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + + string tokenString = token.Token; + + return ExtractTokenInfo(tokenString); + } + + /// + public async Task ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes, tenantId: request.TenantId), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/processContent"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + + if (request.ScopeIdentifier != null) + { + message.Headers.Add("If-None-Match", request.ScopeIdentifier); + } + + string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest))); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + HttpResponseMessage response; + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while processing content."); + throw new PurviewRequestException("Http error occurred while processing content.", e); + } + +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Accepted) + { + ProcessContentResponse? deserializedResponse; + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ProcessContent response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ProcessContent response. Response was null."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to process content. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "processContent"); + } + } + + /// + public async Task GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/protectionScopes/compute"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + + var typeinfo = PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesRequest)); + string content = JsonSerializer.Serialize(request, typeinfo); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + HttpResponseMessage response; + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while retrieving protection scopes."); + throw new PurviewRequestException("Http error occurred while retrieving protection scopes.", e); + } + + if (response.StatusCode == HttpStatusCode.OK) + { +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + ProtectionScopesResponse? deserializedResponse; + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ProtectionScopes response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + deserializedResponse.ScopeIdentifier = response.Headers.ETag?.Tag; + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ProtectionScopes response."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to retrieve protection scopes. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "protectionScopes/compute"); + } + } + + /// + public async Task SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken) + { + var token = await this._tokenCredential.GetTokenAsync(new TokenRequestContext(this._scopes), cancellationToken).ConfigureAwait(false); + string userId = request.UserId; + + string uri = $"{this._graphUri}/{userId}/dataSecurityAndGovernance/activities/contentActivities"; + + using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) + { + message.Headers.Add("Authorization", $"Bearer {token.Token}"); + message.Headers.Add("User-Agent", "agent-framework-dotnet"); + string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesRequest))); + message.Content = new StringContent(content, Encoding.UTF8, "application/json"); + HttpResponseMessage response; + + try + { + response = await this._httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + this._logger.LogError(e, "Http error while creating content activities."); + throw new PurviewRequestException("Http error occurred while creating content activities.", e); + } + + if (response.StatusCode == HttpStatusCode.Created) + { +#if NET5_0_OR_GREATER + // Pass the cancellation token if that method is available. + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + ContentActivitiesResponse? deserializedResponse; + + try + { + JsonTypeInfo typeInfo = (JsonTypeInfo)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse)); + deserializedResponse = JsonSerializer.Deserialize(responseContent, typeInfo); + } + catch (JsonException jsonException) + { + const string DeserializeExceptionError = "Failed to deserialize ContentActivities response."; + this._logger.LogError(jsonException, DeserializeExceptionError); + throw new PurviewRequestException(DeserializeExceptionError, jsonException); + } + + if (deserializedResponse != null) + { + return deserializedResponse; + } + + const string DeserializeError = "Failed to deserialize ContentActivities response."; + this._logger.LogError(DeserializeError); + throw new PurviewRequestException(DeserializeError); + } + + this._logger.LogError("Failed to create content activities. Status code: {StatusCode}", response.StatusCode); + throw CreateExceptionForStatusCode(response.StatusCode, "contentActivities"); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs new file mode 100644 index 0000000000..cdeb395d67 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Channels; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Extension methods to add Purview capabilities to an . +/// +public static class PurviewExtensions +{ + private static PurviewWrapper CreateWrapper(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + MemoryDistributedCacheOptions options = new() + { + SizeLimit = purviewSettings.InMemoryCacheSizeLimit, + }; + + IDistributedCache distributedCache = cache ?? new MemoryDistributedCache(Options.Create(options)); + + ServiceCollection services = new(); + services.AddSingleton(tokenCredential); + services.AddSingleton(purviewSettings); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(distributedCache); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(logger ?? NullLogger.Instance); + services.AddSingleton(); + services.AddSingleton(Channel.CreateBounded(purviewSettings.PendingBackgroundJobLimit)); + services.AddSingleton(); + services.AddSingleton(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + return serviceProvider.GetRequiredService(); + } + + /// + /// Adds Purview capabilities to an . + /// + /// The AI Agent builder for the . + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// The updated + public static AIAgentBuilder WithPurview(this AIAgentBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return builder.Use((innerAgent) => new PurviewAgent(innerAgent, purviewWrapper)); + } + + /// + /// Adds Purview capabilities to a . + /// + /// The chat client builder for the . + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// The updated + public static ChatClientBuilder WithPurview(this ChatClientBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return builder.Use((innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper)); + } + + /// + /// Creates a Purview middleware function for use with a . + /// + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// A chat middleware delegate. + public static Func PurviewChatMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return (innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper); + } + + /// + /// Creates a Purview middleware function for use with an . + /// + /// The token credential used to authenticate with Purview. + /// The settings for communication with Purview. + /// The logger to use for logging. + /// The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null. + /// An agent middleware delegate. + public static Func PurviewAgentMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null) + { + PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache); + return (innerAgent) => new PurviewAgent(innerAgent, purviewWrapper); + } + + /// + /// Sets the user id for a message. + /// + /// The message. + /// The id of the owner of the message. + public static void SetUserId(this ChatMessage message, Guid userId) + { + if (message.AdditionalProperties == null) + { + message.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + + message.AdditionalProperties[Constants.UserId] = userId.ToString(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs new file mode 100644 index 0000000000..4fcc145f0b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewLocationType.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview; + +/// +/// The type of location for Purview policy evaluation. +/// +public enum PurviewLocationType +{ + /// + /// An application location. + /// + Application, + + /// + /// A URI location. + /// + Uri, + + /// + /// A domain name location. + /// + Domain +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs new file mode 100644 index 0000000000..cb400805c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Represents the configuration settings for a Purview application, including tenant information, application name, and +/// optional default user settings. +/// +/// This class is used to encapsulate the necessary configuration details for interacting with Purview +/// services. It includes the tenant ID and application name, which are required, and an optional default user ID that +/// can be used for requests where a specific user ID is not provided. +public class PurviewSettings +{ + /// + /// Initializes a new instance of the class. + /// + /// The publicly visible name of the application. + public PurviewSettings(string appName) + { + this.AppName = appName; + } + + /// + /// The publicly visible app name of the application. + /// + public string AppName { get; set; } + + /// + /// The version string of the application. + /// + public string? AppVersion { get; set; } + + /// + /// The tenant id of the user making the request. + /// If this is not provided, the tenant id will be inferred from the token. + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the location of the Purview resource. + /// If this is not provided, a location containing the client id will be used instead. + /// + public PurviewAppLocation? PurviewAppLocation { get; set; } + + /// + /// Gets or sets a flag indicating whether to ignore exceptions when processing Purview requests. False by default. + /// If set to true, exceptions calling Purview will be logged but not thrown. + /// + public bool IgnoreExceptions { get; set; } + + /// + /// Gets or sets the base URI for the Microsoft Graph API. + /// Set to graph v1.0 by default. + /// + public Uri GraphBaseUri { get; set; } = new Uri("https://graph.microsoft.com/v1.0/"); + + /// + /// Gets or sets the message to display when a prompt is blocked by Purview policies. + /// + public string BlockedPromptMessage { get; set; } = "Prompt blocked by policies"; + + /// + /// Gets or sets the message to display when a response is blocked by Purview policies. + /// + public string BlockedResponseMessage { get; set; } = "Response blocked by policies"; + + /// + /// The size limit of the default in memory cache in bytes. This only applies if no cache is provided when creating Purview resources. + /// + public long? InMemoryCacheSizeLimit { get; set; } = 100_000_000; + + /// + /// The TTL of each cache entry. + /// + public TimeSpan CacheTTL { get; set; } = TimeSpan.FromMinutes(30); + + /// + /// The maximum number of background jobs that can be queued up. + /// + public int PendingBackgroundJobLimit { get; set; } = 100; + + /// + /// The maximum number of concurrent job consumers. + /// + public int MaxConcurrentJobConsumers { get; set; } = 10; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs new file mode 100644 index 0000000000..c8316a4e21 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// A delegating agent that connects to Microsoft Purview. +/// +internal sealed class PurviewWrapper : IDisposable +{ + private readonly ILogger _logger; + private readonly IScopedContentProcessor _scopedProcessor; + private readonly PurviewSettings _purviewSettings; + private readonly IChannelHandler _channelHandler; + + /// + /// Creates a new instance. + /// + /// The scoped processor used to orchestrate the calls to Purview. + /// The settings for Purview integration. + /// The logger used for logging. + /// The channel handler used to queue background jobs and add job runners. + public PurviewWrapper(IScopedContentProcessor scopedProcessor, PurviewSettings purviewSettings, ILogger logger, IChannelHandler channelHandler) + { + this._scopedProcessor = scopedProcessor; + this._purviewSettings = purviewSettings; + this._logger = logger; + this._channelHandler = channelHandler; + } + + private static string GetThreadIdFromAgentThread(AgentThread? thread, IEnumerable messages) + { + if (thread is ChatClientAgentThread chatClientAgentThread && + chatClientAgentThread.ConversationId != null) + { + return chatClientAgentThread.ConversationId; + } + + foreach (ChatMessage message in messages) + { + if (message.AdditionalProperties != null && + message.AdditionalProperties.TryGetValue(Constants.ConversationId, out object? conversationId) && + conversationId != null) + { + return conversationId.ToString() ?? Guid.NewGuid().ToString(); + } + } + + return Guid.NewGuid().ToString(); + } + + /// + /// Processes a prompt and response exchange at a chat client level. + /// + /// The messages sent to the chat client. + /// The chat options used with the chat client. + /// The wrapped chat client. + /// The cancellation token used to interrupt async operations. + /// The chat client's response. This could be the response from the chat client or a message indicating that Purview has blocked the prompt or response. + public async Task ProcessChatContentAsync(IEnumerable messages, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken) + { + string? resolvedUserId = null; + + try + { + (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); + if (shouldBlockPrompt) + { + this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage); + return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + ChatResponse response = await innerChatClient.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + + try + { + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + if (shouldBlockResponse) + { + this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage); + return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + return response; + } + + /// + /// Processes a prompt and response exchange at an agent level. + /// + /// The messages sent to the agent. + /// The thread used for this agent conversation. + /// The options used with this agent. + /// The wrapped agent. + /// The cancellation token used to interrupt async operations. + /// The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response. + public async Task ProcessAgentContentAsync(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) + { + string threadId = GetThreadIdFromAgentThread(thread, messages); + + string? resolvedUserId = null; + + try + { + (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, threadId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); + + if (shouldBlockPrompt) + { + this._logger.LogInformation("Prompt blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedPromptMessage); + return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedPromptMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing prompt: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + AgentRunResponse response = await innerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); + + try + { + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, threadId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + + if (shouldBlockResponse) + { + this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage); + return new AgentRunResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage)); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error processing response: {ExceptionMessage}", ex.Message); + + if (!this._purviewSettings.IgnoreExceptions) + { + throw; + } + } + + return response; + } + + /// + public void Dispose() + { +#pragma warning disable VSTHRD002 // Need to wait for pending jobs to complete. + this._channelHandler.StopAndWaitForCompletionAsync().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Need to wait for pending jobs to complete. + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md new file mode 100644 index 0000000000..3e46ceff65 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -0,0 +1,263 @@ +# Microsoft Agent Framework - Purview Integration (Dotnet) + +The Purview plugin for the Microsoft Agent Framework adds Purview policy evaluation to the Microsoft Agent Framework. +It lets you enforce data security and governance policies on both the *prompt* (user input + conversation history) and the *model response* before they proceed further in your workflow. + +> Status: **Preview** + +### Key Features + +- Middleware-based policy enforcement (agent-level and chat-client level) +- Blocks or allows content at both ingress (prompt) and egress (response) +- Works with any `IChatClient` or `AIAgent` using the standard Agent Framework middleware pipeline. +- Authenticates to Purview using `TokenCredential`s +- Simple configuration using `PurviewSettings` +- Configurable caching using `IDistributedCache` +- `WithPurview` Extension methods to easily apply middleware to a `ChatClientBuilder` or `AIAgentBuilder` + +### When to Use +Add Purview when you need to: + +- Prevent sensitive or disallowed content from being sent to an LLM +- Prevent model output containing disallowed data from leaving the system +- Apply centrally managed policies without rewriting agent logic + +--- + + +## Quick Start + +``` csharp +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Purview; +using Microsoft.Extensions.AI; + +Uri endpoint = new Uri("..."); // The endpoint of Azure OpenAI instance. +string deploymentName = "..."; // The deployment name of your Azure OpenAI instance ex: gpt-4o-mini +string purviewClientAppId = "..."; // The client id of your entra app registration. + +// This will get a user token for an entra app configured to call the Purview API. +// Any TokenCredential with permissions to call the Purview API can be used here. +TokenCredential browserCredential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialOptions + { + ClientId = purviewClientAppId + }); + +IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("My Sample App")) + .Build(); + +using (client) +{ + Console.WriteLine("Enter a prompt to send to the client:"); + string? promptText = Console.ReadLine(); + + if (!string.IsNullOrEmpty(promptText)) + { + // Invoke the agent and output the text result. + Console.WriteLine(await client.GetResponseAsync(promptText)); + } +} +``` + +If a policy violation is detected on the prompt, the middleware interrupts the run and outputs the message: `"Prompt blocked by policies"`. If on the response, the result becomes `"Response blocked by policies"`. + +--- + +## Authentication + +The Purview middleware uses Azure.Core TokenCredential objects for authentication. + +The plugin requires the following Graph permissions: +- ProtectionScopes.Compute.All : [userProtectionScopeContainer](https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute) +- Content.Process.All : [processContent](https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent) +- ContentActivity.Write : [contentActivity](https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities) + +Authentication with user tokens is preferred. If authenticating with app tokens, the agent-framework caller will need to provide an entra user id for each `ChatMessage` send to the agent/client. This user id can be set using the `SetUserId` extension method, or by setting the `"userId"` field of the `AdditionalProperties` dictionary. + +``` csharp +// Manually +var message = new ChatMessage(ChatRole.User, promptText); +if (message.AdditionalProperties == null) +{ + message.AdditionalProperties = new AdditionalPropertiesDictionary(); +} +message.AdditionalProperties["userId"] = ""; + +// Or with the extension method +var message = new ChatMessage(ChatRole.User, promptText); +message.SetUserId(new Guid("")); +``` + +### Tenant Enablement for Purview +- The tenant requires an e5 license and consumptive billing setup. +- [Data Loss Prevention](https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy) or [Data Collection Policies](https://learn.microsoft.com/en-us/purview/collection-policies-policy-reference) policies that apply to the user are required to enable classification and message ingestion (Process Content API). Otherwise, messages will only be logged in Purview's Audit log (Content Activities API). + +## Configuration + +### Settings + +The Purview middleware can be customized and configured using the `PurviewSettings` class. + +#### `PurviewSettings` + +| Field | Type | Purpose | +| ----- | ---- | ------- | +| AppName | string | The publicly visible app name of the application. | +| AppVersion | string? | (Optional) The version string of the application. | +| TenantId | string? | (Optional) The tenant id of the user making the request. If not provided, this will be inferred from the token. | +| PurviewAppLocation | PurviewAppLocation? | (Optional) The location of the Purview resource used during policy evaluation. If not provided, a location containing the application client id will be used instead. | +| IgnoreExceptions | bool | (Optional, `false` by default) Determines if the exceptions thrown in the Purview middleware should be ignored. If set to true, exceptions will be logged but not thrown. | +| GraphBaseUri | Uri | (Optional, https://graph.microsoft.com/v1.0/ by default) The base URI used for calls to Purview's Microsoft Graph APIs. | +| BlockedPromptMessage | string | (Optional, `"Prompt blocked by policies"` by default) The message returned when a prompt is blocked by Purview. | +| BlockedResponseMessage | string | (Optional, `"Response blocked by policies"` by default) The message returned when a response is blocked by Purview. | +| InMemoryCacheSizeLimit | long? | (Optional, `100_000_000` by default) The size limit of the default in-memory cache in bytes. This only applies if no cache is provided when creating the Purview middleware. | +| CacheTTL | TimeSpan | (Optional, 30 minutes by default) The time to live of each cache entry. | +| PendingBackgroundJobLimit | int | (Optional, 100 by default) The maximum number of pending background jobs that can be queued in the middleware. | +| MaxConcurrentJobConsumers | int | (Optional, 10 by default) The maximum number of concurrent consumers that can run background jobs in the middleware. | + +#### `PurviewAppLocation` + +| Field | Type | Purpose | +| ----- | ---- | ------- | +| LocationType | PurviewLocationType | The type of the location: Application, Uri, Domain. | +| LocationValue | string | The value of the location. | + +#### Location + +The `PurviewAppLocation` field of the `PurviewSettings` object contains the location of the app which is used by Purview for policy evaluation (see [policyLocation](https://learn.microsoft.com/en-us/graph/api/resources/policylocation?view=graph-rest-1.0) for more information). +This location can be set to the URL of the agent app, the domain of the agent app, or the application id of the agent app. + +#### Example + +```csharp +var location = new PurviewAppLocation(PurviewLocationType.Uri, "https://contoso.com/chatagent"); +var settings = new PurviewSettings("My Sample App") +{ + AppVersion = "1.0", + TenantId = "your-tenant-id", + PurviewAppLocation = location, + IgnoreExceptions = false, + GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/"), + BlockedPromptMessage = "Prompt blocked by policies.", + BlockedResponseMessage = "Response blocked by policies.", + InMemoryCacheSizeLimit = 100_000_000, + CacheTTL = TimeSpan.FromMinutes(30), + PendingBackgroundJobLimit = 100, + MaxConcurrentJobConsumers = 10, +}; + +// ... Set up credential and client builder ... + +var client = builder.WithPurview(credential, settings).Build(); +``` + +#### Customizing Blocked Messages + +This is useful for: +- Providing more user-friendly error messages +- Including support contact information +- Localizing messages for different languages +- Adding branding or specific guidance for your application + +``` csharp +var settings = new PurviewSettings("My Sample App") +{ + BlockedPromptMessage = "Your request contains content that violates our policies. Please rephrase and try again.", + BlockedResponseMessage = "The response was blocked due to policy restrictions. Please contact support if you need assistance.", +}; +``` + +### Selecting Agent vs Chat Middleware + +Use the agent middleware when you already have / want the full agent pipeline: + +``` csharp +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent("You are a helpful assistant.") + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); +``` + +Use the chat middleware when you attach directly to a chat client (e.g. minimal agent shell or custom orchestration): + +``` csharp +IChatClient client = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetOpenAIResponseClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) + .Build(); +``` + +The policy logic is identical; the only difference is the hook point in the pipeline. + +--- + +## Middleware Lifecycle +1. Before sending the prompt to the agent, the middleware checks the app and user metadata against Purview's protection scopes and evaluates all the `ChatMessage`s in the prompt. +2. If the content was blocked, the middleware returns a `ChatResponse` or `AgentRunResponse` containing the `BlockedPromptMessage` text. The blocked content does not get passed to the agent. +3. If the evaluation did not block the content, the middleware passes the prompt data to the agent and waits for a response. +4. After receiving a response from the agent, the middleware calls Purview again to evaluate the response content. +5. If the content was blocked, the middleware returns a response containing the `BlockedResponseMessage`. + +The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user. + +There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. +If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. + +## Exceptions +| Exception | Scenario | +| --------- | -------- | +| PurviewAuthenticationException | Token acquisition / validation issues | +| PurviewJobException | Errors thrown by a background job | +| PurviewJobLimitExceededException | Errors caused by exceeding the background job limit | +| PurviewPaymentRequiredException | 402 responses from the service | +| PurviewRateLimitException | 429 responses from the service | +| PurviewRequestException | Other errors related to Purview requests | +| PurviewException | Base class for all Purview plugin exceptions | + +Callers' exception handling can be fine-grained + +``` csharp +try +{ + // Code that uses Purview middleware +} +catch (PurviewPaymentRequiredException) +{ + this._logger.LogError("Payment required for Purview."); +} +catch (PurviewAuthenticationException) +{ + this._logger.LogError("Error authenticating to Purview."); +} +``` + +Or broad + +``` csharp +try +{ + // Code that uses Purview middleware +} +catch (PurviewException e) +{ + this._logger.LogError(e, "Purview middleware threw an exception.") +} +``` diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs new file mode 100644 index 0000000000..da9e61a22e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Purview; + +/// +/// Processor class that combines protectionScopes, processContent, and contentActivities calls. +/// +internal sealed class ScopedContentProcessor : IScopedContentProcessor +{ + private readonly IPurviewClient _purviewClient; + private readonly ICacheProvider _cacheProvider; + private readonly IChannelHandler _channelHandler; + + /// + /// Create a new instance of . + /// + /// The purview client to use for purview requests. + /// The cache used to store Purview data. + /// The channel handler used to manage background jobs. + public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cacheProvider, IChannelHandler channelHandler) + { + this._purviewClient = purviewClient; + this._cacheProvider = cacheProvider; + this._channelHandler = channelHandler; + } + + /// + public async Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken) + { + List pcRequests = await this.MapMessageToPCRequestsAsync(messages, threadId, activity, purviewSettings, userId, cancellationToken).ConfigureAwait(false); + + bool shouldBlock = false; + string? resolvedUserId = null; + + foreach (ProcessContentRequest pcRequest in pcRequests) + { + resolvedUserId = pcRequest.UserId; + ProcessContentResponse processContentResponse = await this.ProcessContentWithProtectionScopesAsync(pcRequest, cancellationToken).ConfigureAwait(false); + if (processContentResponse.PolicyActions?.Count > 0) + { + foreach (DlpActionInfo policyAction in processContentResponse.PolicyActions) + { + // We need to process all data before blocking, so set the flag and return it outside of this loop. + if (policyAction.Action == DlpAction.BlockAccess) + { + shouldBlock = true; + } + + if (policyAction.RestrictionAction == RestrictionAction.Block) + { + shouldBlock = true; + } + } + } + } + + return (shouldBlock, resolvedUserId); + } + + private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId) + { + userId = null; + + foreach (ChatMessage message in messages) + { + if (message.AdditionalProperties != null && + message.AdditionalProperties.TryGetValue(Constants.UserId, out userId) && + !string.IsNullOrEmpty(userId)) + { + return true; + } + else if (Guid.TryParse(message.AuthorName, out Guid _)) + { + userId = message.AuthorName; + return true; + } + } + + return false; + } + + /// + /// Transform a list of ChatMessages into a list of ProcessContentRequests. + /// + /// The messages to transform. + /// The id of the message thread. + /// The activity performed on the content. + /// The settings used for purview integration. + /// The entra id of the user who made the interaction. + /// The cancellation token used to cancel async operations. + /// A list of process content requests. + private async Task> MapMessageToPCRequestsAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken) + { + List pcRequests = new(); + TokenInfo? tokenInfo = null; + + bool needUserId = userId == null && TryGetUserIdFromPayload(messages, out userId); + + // Only get user info if the tenant id is null or if there's no location. + // If location is missing, we will create a new location using the client id. + if (settings.TenantId == null || + settings.PurviewAppLocation == null || + needUserId) + { + tokenInfo = await this._purviewClient.GetUserInfoFromTokenAsync(cancellationToken, settings.TenantId).ConfigureAwait(false); + } + + string tenantId = settings.TenantId ?? tokenInfo?.TenantId ?? throw new PurviewRequestException("No tenant id provided or inferred for Purview request. Please provide a tenant id in PurviewSettings or configure the TokenCredential to authenticate to a tenant."); + + foreach (ChatMessage message in messages) + { + string messageId = message.MessageId ?? Guid.NewGuid().ToString(); + ContentBase content = new PurviewTextContent(message.Text); + ProcessConversationMetadata conversationmetadata = new(content, messageId, false, $"Agent Framework Message {messageId}") + { + CorrelationId = threadId ?? Guid.NewGuid().ToString() + }; + ActivityMetadata activityMetadata = new(activity); + PolicyLocation policyLocation; + + if (settings.PurviewAppLocation != null) + { + policyLocation = settings.PurviewAppLocation.GetPolicyLocation(); + } + else if (tokenInfo?.ClientId != null) + { + policyLocation = new($"{Constants.ODataGraphNamespace}.policyLocationApplication", tokenInfo.ClientId); + } + else + { + throw new PurviewRequestException("No app location provided or inferred for Purview request. Please provide an app location in PurviewSettings or configure the TokenCredential to authenticate to an entra app."); + } + + string appVersion = !string.IsNullOrEmpty(settings.AppVersion) ? settings.AppVersion : "Unknown"; + + ProtectedAppMetadata protectedAppMetadata = new(policyLocation) + { + Name = settings.AppName, + Version = appVersion + }; + IntegratedAppMetadata integratedAppMetadata = new() + { + Name = settings.AppName, + Version = appVersion + }; + + DeviceMetadata deviceMetadata = new() + { + OperatingSystemSpecifications = new() + { + OperatingSystemPlatform = "Unknown", + OperatingSystemVersion = "Unknown" + } + }; + ContentToProcess contentToProcess = new(new List { conversationmetadata }, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + + if (userId == null && + tokenInfo?.UserId != null) + { + userId = tokenInfo.UserId; + } + + if (string.IsNullOrEmpty(userId)) + { + throw new PurviewRequestException("No user id provided or inferred for Purview request. Please provide an Entra user id in each message's AuthorName, set a default Entra user id in PurviewSettings, or configure the TokenCredential to authenticate to an Entra user."); + } + + ProcessContentRequest pcRequest = new(contentToProcess, userId, tenantId); + pcRequests.Add(pcRequest); + } + + return pcRequests; + } + + /// + /// Orchestrates process content and protection scopes calls. + /// + /// The process content request. + /// The cancellation token used to cancel async operations. + /// A process content response. This could be a response from the process content API or a response generated from a content activities call. + private async Task ProcessContentWithProtectionScopesAsync(ProcessContentRequest pcRequest, CancellationToken cancellationToken) + { + ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId); + + ProtectionScopesCacheKey cacheKey = new(psRequest); + + ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + + ProtectionScopesResponse psResponse; + + if (cacheResponse != null) + { + psResponse = cacheResponse; + } + else + { + psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false); + await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false); + } + + pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier; + + (bool shouldProcess, List dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse); + + if (shouldProcess) + { + if (executionMode == ExecutionMode.EvaluateOffline) + { + this._channelHandler.QueueJob(new ProcessContentJob(pcRequest)); + return new ProcessContentResponse(); + } + + ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false); + + if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified) + { + await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + + pcResponse = CombinePolicyActions(pcResponse, dlpActions); + return pcResponse; + } + + ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId); + this._channelHandler.QueueJob(new ContentActivityJob(caRequest)); + + return new ProcessContentResponse(); + } + + /// + /// Dedupe policy actions received from the service. + /// + /// The process content response which may contain DLP actions. + /// DLP actions returned from protection scopes. + /// The process content response with the protection scopes DLP actions added. Actions are deduplicated. + private static ProcessContentResponse CombinePolicyActions(ProcessContentResponse pcResponse, List? actionInfos) + { + if (actionInfos == null || actionInfos.Count == 0) + { + return pcResponse; + } + + if (pcResponse.PolicyActions == null) + { + pcResponse.PolicyActions = actionInfos; + return pcResponse; + } + + List pcActionInfos = new(pcResponse.PolicyActions); + pcActionInfos.AddRange(actionInfos); + pcResponse.PolicyActions = pcActionInfos; + return pcResponse; + } + + /// + /// Check if any scopes are applicable to the request. + /// + /// The process content request. + /// The protection scopes response that was returned for the process content request. + /// A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request. + private static (bool shouldProcess, List dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse) + { + ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity); + + // The location data type is formatted as microsoft.graph.{locationType} + // Sometimes a '#' gets appended by graph during responses, so for the sake of simplicity, + // Split it by '.' and take the last segment. We'll do a case-insensitive endsWith later. + string[] locationSegments = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.DataType.Split('.'); + string locationType = locationSegments.Length > 0 ? locationSegments[locationSegments.Length - 1] : pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; + + string locationValue = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; + List dlpActions = new(); + bool shouldProcess = false; + ExecutionMode executionMode = ExecutionMode.EvaluateOffline; + + foreach (var scope in psResponse.Scopes ?? Array.Empty()) + { + bool activityMatch = scope.Activities.HasFlag(requestActivity); + bool locationMatch = false; + + foreach (var location in scope.Locations ?? Array.Empty()) + { + locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase); + } + + if (activityMatch && locationMatch) + { + shouldProcess = true; + + if (scope.ExecutionMode == ExecutionMode.EvaluateInline) + { + executionMode = ExecutionMode.EvaluateInline; + } + + if (scope.PolicyActions != null) + { + dlpActions.AddRange(scope.PolicyActions); + } + } + } + + return (shouldProcess, dlpActions, executionMode); + } + + /// + /// Create a ProtectionScopesRequest for the given content ProcessContentRequest. + /// + /// The process content request. + /// The entra user id of the user who sent the data. + /// The tenant id of the user who sent the data. + /// The correlation id of the request. + /// The protection scopes request generated from the process content request. + private static ProtectionScopesRequest CreateProtectionScopesRequest(ProcessContentRequest pcRequest, string userId, string tenantId, Guid correlationId) + { + return new ProtectionScopesRequest(userId, tenantId) + { + Activities = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity), + Locations = new List { pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation }, + DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata, + IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata, + CorrelationId = correlationId + }; + } + + /// + /// Map process content activity to protection scope activity. + /// + /// The process content activity. + /// The protection scopes activity. + private static ProtectionScopeActivities TranslateActivity(Activity activity) + { + switch (activity) + { + case Activity.Unknown: + return ProtectionScopeActivities.None; + case Activity.UploadText: + return ProtectionScopeActivities.UploadText; + case Activity.UploadFile: + return ProtectionScopeActivities.UploadFile; + case Activity.DownloadText: + return ProtectionScopeActivities.DownloadText; + case Activity.DownloadFile: + return ProtectionScopeActivities.DownloadFile; + default: + return ProtectionScopeActivities.UnknownFutureValue; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs new file mode 100644 index 0000000000..320fbcd3b6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; + +namespace Microsoft.Agents.AI.Purview.Serialization; + +/// +/// Source generation context for Purview serialization. +/// +[JsonSerializable(typeof(ProtectionScopesRequest))] +[JsonSerializable(typeof(ProtectionScopesResponse))] +[JsonSerializable(typeof(ProcessContentRequest))] +[JsonSerializable(typeof(ProcessContentResponse))] +[JsonSerializable(typeof(ContentActivitiesRequest))] +[JsonSerializable(typeof(ContentActivitiesResponse))] +[JsonSerializable(typeof(ProtectionScopesCacheKey))] +internal sealed partial class SourceGenerationContext : JsonSerializerContext; + +/// +/// Utility class for Purview serialization settings. +/// +internal static class PurviewSerializationUtils +{ + /// + /// Serialization settings for Purview. + /// + public static JsonSerializerOptions SerializationSettings { get; } = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false, + AllowTrailingCommas = false, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = SourceGenerationContext.Default, + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj new file mode 100644 index 0000000000..bd07eca8ab --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs new file mode 100644 index 0000000000..b2b0ac45e6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -0,0 +1,585 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Agents.AI.Purview.Serialization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class PurviewClientTests : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly PurviewClientHttpMessageHandlerStub _handler; + private readonly PurviewClient _client; + private readonly PurviewSettings _settings; + + public PurviewClientTests() + { + this._handler = new PurviewClientHttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._handler, false); + this._settings = new PurviewSettings("TestApp") + { + GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/") + }; + var tokenCredential = new MockTokenCredential(); + this._client = new PurviewClient(tokenCredential, this._settings, this._httpClient, NullLogger.Instance); + } + + #region ProcessContentAsync Tests + + [Fact] + public async Task ProcessContentAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + var expectedResponse = new ProcessContentResponse + { + Id = "test-id-123", + ProtectionScopeState = ProtectionScopeState.NotModified, + PolicyActions = new List + { + new() { Action = DlpAction.NotifyUser } + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + var result = await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResponse.Id, result.Id); + Assert.Equal(ProtectionScopeState.NotModified, result.ProtectionScopeState); + Assert.Single(result.PolicyActions!); + Assert.Equal(DlpAction.NotifyUser, result.PolicyActions![0].Action); + + // Verify request + Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/processContent", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + Assert.Contains("Bearer ", this._handler.AuthorizationHeader); + } + + [Fact] + public async Task ProcessContentAsync_WithAcceptedStatus_ReturnsSuccessResponseAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + var expectedResponse = new ProcessContentResponse + { + Id = "test-id-456", + ProtectionScopeState = ProtectionScopeState.Modified + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Accepted; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + var result = await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedResponse.Id, result.Id); + Assert.Equal(ProtectionScopeState.Modified, result.ProtectionScopeState); + } + + [Fact] + public async Task ProcessContentAsync_WithScopeIdentifier_IncludesIfNoneMatchHeaderAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + request.ScopeIdentifier = "\"test-scope-123\""; // ETags must be quoted + var expectedResponse = new ProcessContentResponse { Id = "test-id" }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader); + } + + [Fact] + public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithForbiddenError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.Forbidden; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithPaymentRequiredError_ThrowsPurviewPaymentRequiredExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.PaymentRequired; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task ProcessContentAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ProcessContent response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.ProcessContentAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while processing content.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region GetProtectionScopesAsync Tests + + [Fact] + public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id") + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new("microsoft.graph.policyLocationApplication", "app-123") + } + }; + + var expectedResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + } + } + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); + this._handler.ETagToReturn = "\"scope-etag-123\""; + + // Act + var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Scopes); + Assert.Single(result.Scopes); + Assert.Equal("\"scope-etag-123\"", result.ScopeIdentifier); // ETags are stored with quotes + + // Verify request + Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/protectionScopes/compute", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + } + + [Fact] + public async Task GetProtectionScopesAsync_SetsETagFromResponse_Async() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + var expectedResponse = new ProtectionScopesResponse { Scopes = new List() }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); + this._handler.ETagToReturn = "\"custom-etag-456\""; + + // Act + var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); + + // Assert + Assert.Equal("\"custom-etag-456\"", result.ScopeIdentifier); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task GetProtectionScopesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.GetProtectionScopesAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region SendContentActivitiesAsync Tests + + [Fact] + public async Task SendContentActivitiesAsync_WithValidRequest_ReturnsSuccessResponseAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + var expectedResponse = new ContentActivitiesResponse + { + StatusCode = HttpStatusCode.Created + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); + + // Act + var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Null(result.Error); + + // Verify request - note the endpoint is different from ProcessContent + Assert.Equal("https://graph.microsoft.com/v1.0/test-user-id/dataSecurityAndGovernance/activities/contentActivities", this._handler.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithError_ReturnsResponseWithErrorAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + var expectedResponse = new ContentActivitiesResponse + { + Error = new ErrorDetails + { + Code = "InvalidRequest", + Message = "The request is invalid" + } + }; + + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); + + // Act + var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Error); + Assert.Equal("InvalidRequest", result.Error.Code); + Assert.Equal("The request is invalid", result.Error.Message); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = (HttpStatusCode)429; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; + + // Act & Assert + await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.StatusCodeToReturn = HttpStatusCode.Created; + this._handler.ResponseToReturn = "invalid json"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + + Assert.Contains("Failed to deserialize ContentActivities response", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task SendContentActivitiesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() + { + // Arrange + var contentToProcess = CreateValidContentToProcess(); + var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); + this._handler.ShouldThrowHttpRequestException = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._client.SendContentActivitiesAsync(request, CancellationToken.None)); + + Assert.Equal("Http error occurred while creating content activities.", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } + + #endregion + + #region Helper Methods + + private static ProcessContentRequest CreateValidProcessContentRequest() + { + var contentToProcess = CreateValidContentToProcess(); + return new ProcessContentRequest(contentToProcess, "test-user-id", "test-tenant-id"); + } + + private static ContentToProcess CreateValidContentToProcess() + { + var content = new PurviewTextContent("Test content"); + var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message"); + var activityMetadata = new ActivityMetadata(Activity.UploadText); + var deviceMetadata = new DeviceMetadata + { + OperatingSystemSpecifications = new OperatingSystemSpecifications + { + OperatingSystemPlatform = "Windows", + OperatingSystemVersion = "10" + } + }; + var integratedAppMetadata = new IntegratedAppMetadata + { + Name = "TestApp", + Version = "1.0" + }; + var policyLocation = new PolicyLocation("microsoft.graph.policyLocationApplication", "app-123"); + var protectedAppMetadata = new ProtectedAppMetadata(policyLocation) + { + Name = "TestApp", + Version = "1.0" + }; + + return new ContentToProcess( + new List { metadata }, + activityMetadata, + deviceMetadata, + integratedAppMetadata, + protectedAppMetadata + ); + } + + #endregion + + public void Dispose() + { + this._handler.Dispose(); + this._httpClient.Dispose(); + } + + /// + /// Mock HTTP message handler for testing + /// + internal sealed class PurviewClientHttpMessageHandlerStub : HttpMessageHandler + { + public HttpStatusCode StatusCodeToReturn { get; set; } = HttpStatusCode.OK; + public string? ResponseToReturn { get; set; } + public string? ETagToReturn { get; set; } + public bool ShouldThrowHttpRequestException { get; set; } + public Uri? RequestUri { get; private set; } + public HttpMethod? RequestMethod { get; private set; } + public string? AuthorizationHeader { get; private set; } + public string? IfNoneMatchHeader { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Capture request details + this.RequestUri = request.RequestUri; + this.RequestMethod = request.Method; + + if (request.Headers.Authorization != null) + { + this.AuthorizationHeader = request.Headers.Authorization.ToString(); + } + + if (request.Headers.TryGetValues("If-None-Match", out var ifNoneMatchValues)) + { + this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues); + } + + // Throw HttpRequestException if configured + if (this.ShouldThrowHttpRequestException) + { + throw new HttpRequestException("Simulated network error"); + } + + var response = new HttpResponseMessage(this.StatusCodeToReturn); + + response.Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json"); + + if (!string.IsNullOrEmpty(this.ETagToReturn)) + { + response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(this.ETagToReturn); + } + + return await Task.FromResult(response); + } + } + + /// + /// Mock token credential for testing + /// + internal sealed class MockTokenCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs new file mode 100644 index 0000000000..22b729dda4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs @@ -0,0 +1,571 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class PurviewWrapperTests : IDisposable +{ + private readonly Mock _mockProcessor; + private readonly IChannelHandler _channelHandler; + private readonly PurviewSettings _settings; + private readonly PurviewWrapper _wrapper; + + public PurviewWrapperTests() + { + this._mockProcessor = new Mock(); + this._channelHandler = Mock.Of(); + this._settings = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123"), + BlockedPromptMessage = "Prompt blocked by policy", + BlockedResponseMessage = "Response blocked by policy" + }; + this._wrapper = new PurviewWrapper(this._mockProcessor.Object, this._settings, NullLogger.Instance, this._channelHandler); + } + + #region ProcessChatContentAsync Tests + + [Fact] + public async Task ProcessChatContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Sensitive content that should be blocked") + }; + var mockChatClient = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); + mockChatClient.Verify(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")) // Prompt allowed + .ReturnsAsync((true, "user-123")); // Response blocked + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Response blocked by policy", result.Messages[0].Text); + } + + [Fact] + public async Task ProcessChatContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.Same(innerResponse, result); + } + + [Fact] + public async Task ProcessChatContentAsync_WithIgnoreExceptions_ContinuesOnPromptErrorAsync() + { + // Arrange + var settingsWithIgnore = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + IgnoreExceptions = true, + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler); + + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client")); + var mockChatClient = new Mock(); + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")); // Response processing succeeds + + // Act + var result = await wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task ProcessChatContentAsync_WithoutIgnoreExceptions_ThrowsOnPromptErrorAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockChatClient = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None)); + } + + [Fact] + public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var options = new ChatOptions { ConversationId = "conversation-123" }; + var mockChatClient = new Mock(); + var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response")); + + mockChatClient.Setup(x => x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-123", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None); + + // Assert + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-123", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + #endregion + + #region ProcessAgentContentAsync Tests + + [Fact] + public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Sensitive content") + }; + var mockAgent = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); + mockAgent.Verify(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")) // Prompt allowed + .ReturnsAsync((true, "user-123")); // Response blocked + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.System, result.Messages[0].Role); + Assert.Equal("Response blocked by policy", result.Messages[0].Text); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.Same(innerResponse, result); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithIgnoreExceptions_ContinuesOnErrorAsync() + { + // Arrange + var settingsWithIgnore = new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + IgnoreExceptions = true, + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler); + + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Prompt processing error")) + .ReturnsAsync((false, "user-123")); // Response processing succeeds + + // Act + var result = await wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task ProcessAgentContentAsync_WithoutIgnoreExceptions_ThrowsOnErrorAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new PurviewRequestException("Processing error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None)); + } + + [Fact] + public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditionalProperties_Async() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "conversationId", "conversation-from-props" } + } + } + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-from-props", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-from-props", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + + var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + var mockAgent = new Mock(); + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + string? capturedThreadId = null; + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, string, Activity, PurviewSettings, string, CancellationToken>( + (_, threadId, _, _, _, _) => capturedThreadId = threadId) + .ReturnsAsync((false, "user-123")); + + // Act + var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(capturedThreadId); + Assert.True(Guid.TryParse(capturedThreadId, out _), "Generated thread ID should be a valid GUID"); + } + + [Fact] + public async Task ProcessAgentContentAsync_PassesResolvedUserId_ToResponseProcessingAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var mockAgent = new Mock(); + var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response")); + + mockAgent.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(innerResponse); + + var callCount = 0; + string? firstCallUserId = null; + string? secondCallUserId = null; + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, string, Activity, PurviewSettings, string, CancellationToken>( + (_, _, _, _, userId, _) => + { + if (callCount == 0) + { + firstCallUserId = userId; + } + else if (callCount == 1) + { + secondCallUserId = userId; + } + callCount++; + }) + .ReturnsAsync((false, "resolved-user-456")); + + // Act + await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); + + // Assert + Assert.Null(firstCallUserId); // First call (prompt) should have null userId + Assert.Equal("resolved-user-456", secondCallUserId); // Second call (response) should have resolved userId from first call + } + + #endregion + + public void Dispose() + { + this._wrapper.Dispose(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs new file mode 100644 index 0000000000..f43f086de7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -0,0 +1,501 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class ScopedContentProcessorTests +{ + private readonly Mock _mockPurviewClient; + private readonly Mock _mockCacheProvider; + private readonly Mock _mockChannelHandler; + private readonly ScopedContentProcessor _processor; + + public ScopedContentProcessorTests() + { + this._mockPurviewClient = new Mock(); + this._mockCacheProvider = new Mock(); + this._mockChannelHandler = new Mock(); + this._processor = new ScopedContentProcessor( + this._mockPurviewClient.Object, + this._mockCacheProvider.Object, + this._mockChannelHandler.Object); + } + + #region ProcessMessagesAsync Tests + + [Fact] + public async Task ProcessMessagesAsync_WithBlockAccessAction_ReturnsShouldBlockTrueAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { Action = DlpAction.BlockAccess } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + Assert.Equal("user-123", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_WithRestrictionActionBlock_ReturnsShouldBlockTrueAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { RestrictionAction = RestrictionAction.Block } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + Assert.Equal("user-123", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockFalseAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List + { + new() { Action = DlpAction.NotifyUser } + } + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.False(result.shouldBlock); + Assert.Equal("user-123", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + var cachedPsResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(cachedPsResponse); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = new List() + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModifiedAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-123") + }, + ExecutionMode = ExecutionMode.EvaluateInline + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + var pcResponse = new ProcessContentResponse + { + ProtectionScopeState = ProtectionScopeState.Modified, + PolicyActions = new List() + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + this._mockCacheProvider.Verify(x => x.RemoveAsync( + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableScopesAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse + { + Scopes = new List + { + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = new List + { + new ("microsoft.graph.policyLocationApplication", "app-456") + } + } + } + }; + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + // Content activities are now queued as background jobs, not called directly + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = new PurviewSettings("TestApp"); // No TenantId + var tokenInfo = new TokenInfo { UserId = "user-123", ClientId = "client-123" }; // No TenantId + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None)); + + Assert.Contains("No tenant id provided or inferred", exception.Message); + } + + [Fact] + public async Task ProcessMessagesAsync_WithNoUserId_ThrowsPurviewExceptionAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; // No UserId + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None)); + + Assert.Contains("No user id provided or inferred", exception.Message); + } + + [Fact] + public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAdditionalProperties_Async() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "userId", "user-from-props" } + } + } + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse { Scopes = new List() }; + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None); + + // Assert + Assert.Equal("user-from-props", result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenValidGuidAsync() + { + // Arrange + var userId = Guid.NewGuid().ToString(); + var messages = new List + { + new (ChatRole.User, "Test message") + { + AuthorName = userId + } + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var psResponse = new ProtectionScopesResponse { Scopes = new List() }; + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None); + + // Assert + Assert.Equal(userId, result.userId); + } + + #endregion + + #region Helper Methods + + private static PurviewSettings CreateValidPurviewSettings() + { + return new PurviewSettings("TestApp") + { + TenantId = "tenant-123", + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") + }; + } + + #endregion +}