.NET: Implement Purview middleware in dotnet (#1949)

* Move Purview integration logic into middleware

* Improve error handling and user id management

* Rename purview package

* Handle 402s more explicitly; add Middleware generation methods; don't ignore exceptions

* Use DI container; pass scope id to PC

* Add protection scope caching

* Wrap more exceptions in PurviewClient

* Remove block check dedup; add tests

* Refactor PurviewWrapper intialization; Add unit tests

* Use different .Use method and add IDisposable stub

* Add background job processing for Purview

* Misc comment cleanup

* Apply copilot comments

* Fix formatting

* Formatting other files to fix pipeline

* Small updates to settings and exceptions

* Add README

* Move Purview sample

* Address review comments and update XML comments

* Newline after namespace

* Move public Purview classes to single namespace; Clean up csproj and slnx

* Commit the renames

* Remove unused openAI dependency

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
eoindoherty1
2025-11-13 16:50:08 -08:00
committed by GitHub
Unverified
parent a75590eb9b
commit b19860b8a8
80 changed files with 5727 additions and 0 deletions
+5
View File
@@ -91,6 +91,9 @@
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
</Folder>
<Folder Name="/Samples/Purview/AgentWithPurview/">
<Project Path="samples/Purview/AgentWithPurview/AgentWithPurview.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
@@ -310,6 +313,7 @@
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
@@ -341,6 +345,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
</ItemGroup>
</Project>
@@ -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));
}
@@ -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;
/// <summary>
/// Service that runs jobs in background threads.
/// </summary>
internal sealed class BackgroundJobRunner
{
private readonly IChannelHandler _channelHandler;
private readonly IPurviewClient _purviewClient;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="BackgroundJobRunner"/> class.
/// </summary>
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
/// <param name="logger">The logger used to log information about background jobs.</param>
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
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<BackgroundJobBase> 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);
}
}
});
}
}
/// <summary>
/// Runs a job.
/// </summary>
/// <param name="job">The job to run.</param>
/// <returns>A task representing the job.</returns>
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;
}
}
}
@@ -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;
/// <summary>
/// Manages caching of values.
/// </summary>
internal sealed class CacheProvider : ICacheProvider
{
private readonly IDistributedCache _cache;
private readonly PurviewSettings _purviewSettings;
/// <summary>
/// Create a new instance of the <see cref="CacheProvider"/> class.
/// </summary>
/// <param name="cache">The cache where the data is stored.</param>
/// <param name="purviewSettings">The purview integration settings.</param>
public CacheProvider(IDistributedCache cache, PurviewSettings purviewSettings)
{
this._cache = cache;
this._purviewSettings = purviewSettings;
}
/// <summary>
/// Get a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to look up in the cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>The value in the cache. Null or default if no value is present.</returns>
public async Task<TValue?> GetAsync<TKey, TValue>(TKey key, CancellationToken cancellationToken)
{
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)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<TValue> valueTypeInfo = (JsonTypeInfo<TValue>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TValue));
return JsonSerializer.Deserialize(data, valueTypeInfo);
}
/// <summary>
/// Set a value in the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="value">The value to cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
public Task SetAsync<TKey, TValue>(TKey key, TValue value, CancellationToken cancellationToken)
{
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
JsonTypeInfo<TValue> valueTypeInfo = (JsonTypeInfo<TValue>)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);
}
/// <summary>
/// Removes a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="cancellationToken">The cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
public Task RemoveAsync<TKey>(TKey key, CancellationToken cancellationToken)
{
JsonTypeInfo<TKey> keyTypeInfo = (JsonTypeInfo<TKey>)PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(TKey));
string serializedKey = JsonSerializer.Serialize(key, keyTypeInfo);
return this._cache.RemoveAsync(serializedKey, cancellationToken);
}
}
@@ -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;
/// <summary>
/// Handler class for background job management.
/// </summary>
internal class ChannelHandler : IChannelHandler
{
private readonly Channel<BackgroundJobBase> _jobChannel;
private readonly List<Task> _channelListeners;
private readonly ILogger _logger;
private readonly PurviewSettings _purviewSettings;
/// <summary>
/// Creates a new instance of JobHandler.
/// </summary>
/// <param name="purviewSettings">The purview integration settings.</param>
/// <param name="logger">The logger used for logging job information.</param>
/// <param name="jobChannel">The job channel used for queuing and reading background jobs.</param>
public ChannelHandler(PurviewSettings purviewSettings, ILogger logger, Channel<BackgroundJobBase> jobChannel)
{
this._purviewSettings = purviewSettings;
this._logger = logger;
this._jobChannel = jobChannel;
this._channelListeners = new List<Task>(this._purviewSettings.MaxConcurrentJobConsumers);
}
/// <inheritdoc/>
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;
}
}
}
/// <inheritdoc/>
public void AddRunner(Func<Channel<BackgroundJobBase>, Task> runnerTask)
{
this._channelListeners.Add(Task.Run(async () => await runnerTask(this._jobChannel).ConfigureAwait(false)));
}
/// <inheritdoc/>
public async Task StopAndWaitForCompletionAsync()
{
this._jobChannel.Writer.Complete();
await this._jobChannel.Reader.Completion.ConfigureAwait(false);
await Task.WhenAll(this._channelListeners).ConfigureAwait(false);
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Shared constants for the Purview service.
/// </summary>
internal static class Constants
{
/// <summary>
/// The odata type property name used in requests and responses.
/// </summary>
public const string ODataTypePropertyName = "@odata.type";
/// <summary>
/// The OData Graph namespace used for odata types.
/// </summary>
public const string ODataGraphNamespace = "microsoft.graph";
/// <summary>
/// The name of the property that contains the conversation id.
/// </summary>
public const string ConversationId = "conversationId";
/// <summary>
/// The name of the property that contains the user id.
/// </summary>
public const string UserId = "userId";
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for authentication errors related to Purview.
/// </summary>
public class PurviewAuthenticationException : PurviewException
{
/// <inheritdoc />
public PurviewAuthenticationException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewAuthenticationException() : base()
{
}
/// <inheritdoc />
public PurviewAuthenticationException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// General base exception type for Purview service errors.
/// </summary>
public class PurviewException : Exception
{
/// <inheritdoc />
public PurviewException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewException() : base()
{
}
/// <inheritdoc />
public PurviewException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Represents errors that occur during the execution of a Purview job.
/// </summary>
/// <remarks>This exception is thrown when a Purview job encounters an error that prevents it from completing successfully.</remarks>
internal class PurviewJobException : PurviewException
{
/// <inheritdoc/>
public PurviewJobException(string message) : base(message)
{
}
/// <inheritdoc/>
public PurviewJobException() : base()
{
}
/// <inheritdoc/>
public PurviewJobException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Represents an exception that is thrown when the maximum number of concurrent Purview jobs has been exceeded.
/// </summary>
/// <remarks>This exception indicates that the Purview service has reached its limit for concurrent job executions.</remarks>
internal class PurviewJobLimitExceededException : PurviewJobException
{
/// <inheritdoc/>
public PurviewJobLimitExceededException(string message) : base(message)
{
}
/// <inheritdoc/>
public PurviewJobLimitExceededException() : base()
{
}
/// <inheritdoc/>
public PurviewJobLimitExceededException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for payment required errors related to Purview.
/// </summary>
public class PurviewPaymentRequiredException : PurviewException
{
/// <inheritdoc />
public PurviewPaymentRequiredException(string message) : base(message)
{
}
/// <inheritdoc />
public PurviewPaymentRequiredException() : base()
{
}
/// <inheritdoc />
public PurviewPaymentRequiredException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for rate limit exceeded errors from Purview service.
/// </summary>
public class PurviewRateLimitException : PurviewException
{
/// <inheritdoc />
public PurviewRateLimitException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewRateLimitException() : base()
{
}
/// <inheritdoc />
public PurviewRateLimitException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Exception for general http request errors from Purview.
/// </summary>
public class PurviewRequestException : PurviewException
{
/// <summary>
/// HTTP status code returned by the Purview service.
/// </summary>
public HttpStatusCode StatusCode { get; }
/// <inheritdoc />
public PurviewRequestException(HttpStatusCode statusCode, string endpointName)
: base($"Failed to call {endpointName}. Status code: {statusCode}")
{
this.StatusCode = statusCode;
}
/// <inheritdoc />
public PurviewRequestException(string message)
: base(message)
{
}
/// <inheritdoc />
public PurviewRequestException() : base()
{
}
/// <inheritdoc />
public PurviewRequestException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Manages caching of values.
/// </summary>
internal interface ICacheProvider
{
/// <summary>
/// Get a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to look up in the cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>The value in the cache. Null or default if no value is present.</returns>
Task<TValue?> GetAsync<TKey, TValue>(TKey key, CancellationToken cancellationToken);
/// <summary>
/// Set a value in the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key in the cache. Used for serialization.</typeparam>
/// <typeparam name="TValue">The type of the value in the cache. Used for serialization.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="value">The value to cache.</param>
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
Task SetAsync<TKey, TValue>(TKey key, TValue value, CancellationToken cancellationToken);
/// <summary>
/// Removes a value from the cache.
/// </summary>
/// <typeparam name="TKey">The type of the key.</typeparam>
/// <param name="key">The key to identify the cache entry.</param>
/// <param name="cancellationToken">The cancellation token for the async operation.</param>
/// <returns>A task for the async operation.</returns>
Task RemoveAsync<TKey>(TKey key, CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// Interface for a class that controls background job processing.
/// </summary>
internal interface IChannelHandler
{
/// <summary>
/// Queue a job for background processing.
/// </summary>
/// <param name="job">The job queued for background processing.</param>
void QueueJob(BackgroundJobBase job);
/// <summary>
/// Add a runner to the channel handler.
/// </summary>
/// <param name="runnerTask">The runner task used to process jobs.</param>
void AddRunner(Func<Channel<BackgroundJobBase>, Task> runnerTask);
/// <summary>
/// Stop the channel and wait for all runners to complete
/// </summary>
/// <returns>A task representing the job.</returns>
Task StopAndWaitForCompletionAsync();
}
@@ -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;
/// <summary>
/// Defines methods for interacting with the Purview service, including content processing,
/// protection scope management, and activity tracking.
/// </summary>
/// <remarks>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.</remarks>
internal interface IPurviewClient
{
/// <summary>
/// Get user info from auth token.
/// </summary>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <param name="tenantId">The default tenant id used to retrieve the token and its info.</param>
/// <returns>The token info from the token.</returns>
/// <exception cref="InvalidOperationException">Throw if the token was invalid or could not be retrieved.</exception>
Task<TokenInfo> GetUserInfoFromTokenAsync(CancellationToken cancellationToken, string? tenantId = default);
/// <summary>
/// Call ProcessContent API.
/// </summary>
/// <param name="request">The request containing the content to process.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <returns>The response from the Purview API.</returns>
/// <exception cref="PurviewException">Thrown for validation, auth, and network errors.</exception>
Task<ProcessContentResponse> ProcessContentAsync(ProcessContentRequest request, CancellationToken cancellationToken);
/// <summary>
/// Call user ProtectionScope API.
/// </summary>
/// <param name="request">The request containing the protection scopes metadata.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <returns>The protection scopes that apply to the data sent in the request.</returns>
/// <exception cref="PurviewException">Thrown for validation, auth, and network errors.</exception>
Task<ProtectionScopesResponse> GetProtectionScopesAsync(ProtectionScopesRequest request, CancellationToken cancellationToken);
/// <summary>
/// Call contentActivities API.
/// </summary>
/// <param name="request">The request containing the content metadata. Used to generate interaction records.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async processing.</param>
/// <returns>The response from the Purview API.</returns>
/// <exception cref="PurviewException">Thrown for validation, auth, and network errors.</exception>
Task<ContentActivitiesResponse> SendContentActivitiesAsync(ContentActivitiesRequest request, CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// Orchestrates the processing of scoped content by combining protection scope, process content, and content activities operations.
/// </summary>
internal interface IScopedContentProcessor
{
/// <summary>
/// Process a list of messages.
/// The list of messages should be a prompt or response.
/// </summary>
/// <param name="messages">A list of <see cref="ChatMessage"/> objects sent to the agent or received from the agent..</param>
/// <param name="threadId">The thread where the messages were sent.</param>
/// <param name="activity">An activity to indicate prompt or response.</param>
/// <param name="purviewSettings">Purview settings containing tenant id, app name, etc.</param>
/// <param name="userId">The user who sent the prompt or is receiving the response.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A bool indicating if the request should be blocked and the user id of the user who made the request.</returns>
Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable<ChatMessage> messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft.Agents.AI.Purview</Title>
<Description>Tools to connect generative AI apps to Microsoft Purview.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Purview.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" /> <!-- To let Moq mock internal interfaces -->
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
</Project>
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Info about an AI agent associated with the content.
/// </summary>
internal sealed class AIAgentInfo
{
/// <summary>
/// Gets or sets agent id.
/// </summary>
[JsonPropertyName("identifier")]
public string? Identifier { get; set; }
/// <summary>
/// Gets or sets agent name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Gets or sets agent version.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; set; }
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a plugin used in an AI interaction within the Purview SDK.
/// </summary>
internal sealed class AIInteractionPlugin
{
/// <summary>
/// Gets or sets Plugin id.
/// </summary>
[JsonPropertyName("identifier")]
public string? Identifier { get; set; }
/// <summary>
/// Gets or sets Plugin Name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Gets or sets Plugin Version.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; set; }
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Information about a resource accessed during a conversation.
/// </summary>
internal sealed class AccessedResourceDetails
{
/// <summary>
/// Resource ID.
/// </summary>
[JsonPropertyName("identifier")]
public string? Identifier { get; set; }
/// <summary>
/// Resource name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Resource URL.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
/// <summary>
/// Sensitivity label id detected on the resource.
/// </summary>
[JsonPropertyName("labelId")]
public string? LabelId { get; set; }
/// <summary>
/// Access type performed on the resource.
/// </summary>
[JsonPropertyName("accessType")]
public ResourceAccessType AccessType { get; set; }
/// <summary>
/// Status of the access operation.
/// </summary>
[JsonPropertyName("status")]
public ResourceAccessStatus Status { get; set; }
/// <summary>
/// Indicates if cross prompt injection was detected.
/// </summary>
[JsonPropertyName("isCrossPromptInjectionDetected")]
public bool? IsCrossPromptInjectionDetected { get; set; }
}
@@ -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;
/// <summary>
/// Activity definitions
/// </summary>
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<Activity>))]
internal enum Activity : int
{
/// <summary>
/// Unknown activity
/// </summary>
[EnumMember(Value = "unknown")]
Unknown = 0,
/// <summary>
/// Upload text
/// </summary>
[EnumMember(Value = "uploadText")]
UploadText = 1,
/// <summary>
/// Upload file
/// </summary>
[EnumMember(Value = "uploadFile")]
UploadFile = 2,
/// <summary>
/// Download text
/// </summary>
[EnumMember(Value = "downloadText")]
DownloadText = 3,
/// <summary>
/// Download file
/// </summary>
[EnumMember(Value = "downloadFile")]
DownloadFile = 4,
}
@@ -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;
/// <summary>
/// Request for metadata information
/// </summary>
[DataContract]
internal sealed class ActivityMetadata
{
/// <summary>
/// Initializes a new instance of the <see cref="ActivityMetadata"/> class.
/// </summary>
/// <param name="activity">The activity performed with the content.</param>
public ActivityMetadata(Activity activity)
{
this.Activity = activity;
}
/// <summary>
/// The activity performed with the content.
/// </summary>
[DataMember]
[JsonConverter(typeof(JsonStringEnumConverter<Activity>))]
[JsonPropertyName("activity")]
public Activity Activity { get; }
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base error contract returned when some exception occurs.
/// </summary>
[JsonDerivedType(typeof(ProcessingError))]
internal class ClassificationErrorBase
{
/// <summary>
/// Gets or sets the error code.
/// </summary>
[JsonPropertyName("code")]
public string? ErrorCode { get; set; }
/// <summary>
/// Gets or sets the message.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
/// <summary>
/// Gets or sets target of error.
/// </summary>
[JsonPropertyName("target")]
public string? Target { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("innerError")]
public ClassificationInnerError? InnerError { get; set; }
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Inner classification error.
/// </summary>
internal sealed class ClassificationInnerError
{
/// <summary>
/// Gets or sets date of error.
/// </summary>
[JsonPropertyName("date")]
public DateTime? Date { get; set; }
/// <summary>
/// Gets or sets error code.
/// </summary>
[JsonPropertyName("code")]
public string? ErrorCode { get; set; }
/// <summary>
/// Gets or sets client request ID.
/// </summary>
[JsonPropertyName("clientRequestId")]
public string? ClientRequestId { get; set; }
/// <summary>
/// Gets or sets Activity ID.
/// </summary>
[JsonPropertyName("activityId")]
public string? ActivityId { get; set; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class for content items to be processed by the Purview SDK.
/// </summary>
[JsonDerivedType(typeof(PurviewTextContent))]
[JsonDerivedType(typeof(PurviewBinaryContent))]
internal abstract class ContentBase : GraphDataTypeBase
{
/// <summary>
/// Creates a new instance of the <see cref="ContentBase"/> class.
/// </summary>
/// <param name="dataType">The graph data type of the content.</param>
public ContentBase(string dataType) : base(dataType)
{
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Type of error that occurred during content processing.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ContentProcessingErrorType>))]
internal enum ContentProcessingErrorType
{
/// <summary>
/// Error is transient.
/// </summary>
Transient,
/// <summary>
/// Error is permanent.
/// </summary>
Permanent,
/// <summary>
/// Unknown future value placeholder.
/// </summary>
UnknownFutureValue
}
@@ -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;
/// <summary>
/// Content to be processed by process content.
/// </summary>
internal sealed class ContentToProcess
{
/// <summary>
/// Creates a new instance of ContentToProcess.
/// </summary>
/// <param name="contentEntries">The content to send and its associated ids.</param>
/// <param name="activityMetadata">Metadata about the activity performed with the content.</param>
/// <param name="deviceMetadata">Metadata about the device that produced the content.</param>
/// <param name="integratedAppMetadata">Metadata about the application integrating with Purview.</param>
/// <param name="protectedAppMetadata">Metadata about the application being protected by Purview.</param>
public ContentToProcess(
List<ProcessContentMetadataBase> contentEntries,
ActivityMetadata activityMetadata,
DeviceMetadata deviceMetadata,
IntegratedAppMetadata integratedAppMetadata,
ProtectedAppMetadata protectedAppMetadata)
{
this.ContentEntries = contentEntries;
this.ActivityMetadata = activityMetadata;
this.DeviceMetadata = deviceMetadata;
this.IntegratedAppMetadata = integratedAppMetadata;
this.ProtectedAppMetadata = protectedAppMetadata;
}
/// <summary>
/// Gets or sets the content entries.
/// List of activities supported by caller. It is used to trim response to activities interesting to the caller.
/// </summary>
[JsonPropertyName("contentEntries")]
public List<ProcessContentMetadataBase> ContentEntries { get; set; }
/// <summary>
/// Activity metadata
/// </summary>
[DataMember]
[JsonPropertyName("activityMetadata")]
public ActivityMetadata ActivityMetadata { get; set; }
/// <summary>
/// Device metadata
/// </summary>
[DataMember]
[JsonPropertyName("deviceMetadata")]
public DeviceMetadata DeviceMetadata { get; set; }
/// <summary>
/// Integrated app metadata
/// </summary>
[DataMember]
[JsonPropertyName("integratedAppMetadata")]
public IntegratedAppMetadata IntegratedAppMetadata { get; set; }
/// <summary>
/// Protected app metadata
/// </summary>
[DataMember]
[JsonPropertyName("protectedAppMetadata")]
public ProtectedAppMetadata ProtectedAppMetadata { get; set; }
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Endpoint device Metdata
/// </summary>
internal sealed class DeviceMetadata
{
/// <summary>
/// Device type
/// </summary>
[JsonPropertyName("deviceType")]
public string? DeviceType { get; set; }
/// <summary>
/// The ip address of the device.
/// </summary>
[JsonPropertyName("ipAddress")]
public string? IpAddress { get; set; }
/// <summary>
/// OS specifications
/// </summary>
[JsonPropertyName("operatingSystemSpecifications")]
public OperatingSystemSpecifications? OperatingSystemSpecifications { get; set; }
}
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Defines all the actions for DLP.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<DlpAction>))]
internal enum DlpAction
{
/// <summary>
/// The DLP action to notify user.
/// </summary>
NotifyUser,
/// <summary>
/// The DLP action is block.
/// </summary>
BlockAccess,
/// <summary>
/// The DLP action to apply restrictions on device.
/// </summary>
DeviceRestriction,
/// <summary>
/// The DLP action to apply restrictions on browsers.
/// </summary>
BrowserRestriction,
/// <summary>
/// The DLP action to generate an alert
/// </summary>
GenerateAlert,
/// <summary>
/// The DLP action to generate an incident report
/// </summary>
GenerateIncidentReportAction,
/// <summary>
/// The DLP action to block anonymous link access in SPO
/// </summary>
SPBlockAnonymousAccess,
/// <summary>
/// DLP Action to disallow guest access in SPO
/// </summary>
SPRuntimeAccessControl,
/// <summary>
/// DLP No Op action for NotifyUser. Used in Block Access V2 rule
/// </summary>
SPSharingNotifyUser,
/// <summary>
/// DLP No Op action for GIR. Used in Block Access V2 rule
/// </summary>
SPSharingGenerateIncidentReport,
/// <summary>
/// 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.
/// </summary>
RestrictAccess,
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class to define DLP Actions.
/// </summary>
internal sealed class DlpActionInfo
{
/// <summary>
/// Gets or sets the type of the DLP action.
/// </summary>
[JsonPropertyName("action")]
public DlpAction Action { get; set; }
/// <summary>
/// The type of restriction action to take.
/// </summary>
[JsonPropertyName("restrictionAction")]
public RestrictionAction? RestrictionAction { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents the details of an error.
/// </summary>
internal sealed class ErrorDetails
{
/// <summary>
/// Gets or sets the error code.
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; set; }
/// <summary>
/// Gets or sets the error message.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Request execution mode
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ExecutionMode>))]
internal enum ExecutionMode : int
{
/// <summary>
/// Evaluate inline.
/// </summary>
EvaluateInline = 1,
/// <summary>
/// Evaluate offline.
/// </summary>
EvaluateOffline = 2,
/// <summary>
/// Unknown future value.
/// </summary>
UnknownFutureValue = 3
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class for all graph data types used in the Purview SDK.
/// </summary>
internal abstract class GraphDataTypeBase
{
/// <summary>
/// Create a new instance of the <see cref="GraphDataTypeBase"/> class.
/// </summary>
/// <param name="dataType">The data type of the graph object.</param>
public GraphDataTypeBase(string dataType)
{
this.DataType = dataType;
}
/// <summary>
/// The @odata.type property name used in the JSON representation of the object.
/// </summary>
[JsonPropertyName(Constants.ODataTypePropertyName)]
public string DataType { get; set; }
}
@@ -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;
/// <summary>
/// Request for metadata information
/// </summary>
[JsonDerivedType(typeof(ProtectedAppMetadata))]
internal class IntegratedAppMetadata
{
/// <summary>
/// Application name
/// </summary>
[DataMember]
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Application version
/// </summary>
[DataMember]
[JsonPropertyName("version")]
public string? Version { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Operating System Specifications
/// </summary>
internal sealed class OperatingSystemSpecifications
{
/// <summary>
/// OS platform
/// </summary>
[JsonPropertyName("operatingSystemPlatform")]
public string? OperatingSystemPlatform { get; set; }
/// <summary>
/// OS version
/// </summary>
[JsonPropertyName("operatingSystemVersion")]
public string? OperatingSystemVersion { get; set; }
}
@@ -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;
/// <summary>
/// Represents user scoping information, i.e. which users are affected by the policy.
/// </summary>
internal sealed class PolicyBinding
{
/// <summary>
/// Gets or sets the users to be included.
/// </summary>
[JsonPropertyName("inclusions")]
public ICollection<Scope>? Inclusions { get; set; }
/// <summary>
/// Gets or sets the users to be excluded.
/// Exclusions may not be present in the response, thus this property is nullable.
/// </summary>
[JsonPropertyName("exclusions")]
public ICollection<Scope>? Exclusions { get; set; }
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a location to which policy is applicable.
/// </summary>
internal sealed class PolicyLocation : GraphDataTypeBase
{
/// <summary>
/// Creates a new instance of the <see cref="PolicyLocation"/> class.
/// </summary>
/// <param name="dataType">The graph data type of the PolicyLocation object.</param>
/// <param name="value">THe value of the policy location: app id, domain, etc.</param>
public PolicyLocation(string dataType, string value) : base(dataType)
{
this.Value = value;
}
/// <summary>
/// Gets or sets the applicable value for location.
/// </summary>
[JsonPropertyName("value")]
public string Value { get; set; }
}
@@ -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;
/// <summary>
/// Property for policy scoping response to aggregate on
/// </summary>
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<PolicyPivotProperty>))]
internal enum PolicyPivotProperty : int
{
/// <summary>
/// Unknown activity
/// </summary>
[EnumMember]
[JsonPropertyName("none")]
None = 0,
/// <summary>
/// Pivot on Activity
/// </summary>
[EnumMember]
[JsonPropertyName("activity")]
Activity = 1,
/// <summary>
/// Pivot on location
/// </summary>
[EnumMember]
[JsonPropertyName("location")]
Location = 2,
/// <summary>
/// Pivot on location
/// </summary>
[EnumMember]
[JsonPropertyName("unknownFutureValue")]
UnknownFutureValue = 3,
}
@@ -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;
/// <summary>
/// Represents a scope for policy protection.
/// </summary>
internal sealed class PolicyScopeBase
{
/// <summary>
/// Gets or sets the locations to be protected, e.g. domains or URLs.
/// </summary>
[JsonPropertyName("locations")]
public ICollection<PolicyLocation>? Locations { get; set; }
/// <summary>
/// Gets or sets the activities to be protected, e.g. uploadText, downloadText.
/// </summary>
[JsonPropertyName("activities")]
public ProtectionScopeActivities Activities { get; set; }
/// <summary>
/// Gets or sets how policy should be executed - fire-and-forget or wait for completion.
/// </summary>
[JsonPropertyName("executionMode")]
public ExecutionMode ExecutionMode { get; set; }
/// <summary>
/// Gets or sets the enforcement actions to be taken on activities and locations from this scope.
/// There may be no actions in the response.
/// </summary>
[JsonPropertyName("policyActions")]
public ICollection<DlpActionInfo>? PolicyActions { get; set; }
/// <summary>
/// Gets or sets information about policy applicability to a specific user.
/// </summary>
[JsonPropertyName("policyScope")]
public PolicyBinding? PolicyScope { get; set; }
}
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Base class for process content metadata.
/// </summary>
[JsonDerivedType(typeof(ProcessConversationMetadata))]
[JsonDerivedType(typeof(ProcessFileMetadata))]
internal abstract class ProcessContentMetadataBase : GraphDataTypeBase
{
private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata";
/// <summary>
/// Creates a new instance of ProcessContentMetadataBase.
/// </summary>
/// <param name="content">The content that will be processed.</param>
/// <param name="identifier">The unique identifier for the content.</param>
/// <param name="isTruncated">Indicates if the content is truncated.</param>
/// <param name="name">The name of the content.</param>
public ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType)
{
this.Identifier = identifier;
this.IsTruncated = isTruncated;
this.Content = content;
this.Name = name;
}
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("identifier")]
public string Identifier { get; set; }
/// <summary>
/// Gets or sets the content.
/// The content to be processed.
/// </summary>
[JsonPropertyName("content")]
public ContentBase Content { get; set; }
/// <summary>
/// Gets or sets the name.
/// Name of the content, e.g., file name or web page title.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; }
/// <summary>
/// Gets or sets the correlationId.
/// Identifier to group multiple contents.
/// </summary>
[JsonPropertyName("correlationId")]
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the sequenceNumber.
/// Sequence in which the content was originally generated.
/// </summary>
[JsonPropertyName("sequenceNumber")]
public long? SequenceNumber { get; set; }
/// <summary>
/// Gets or sets the length.
/// Content length in bytes.
/// </summary>
[JsonPropertyName("length")]
public long? Length { get; set; }
/// <summary>
/// Gets or sets the isTruncated.
/// Indicates if the original content has been truncated, e.g., to meet text or file size limits.
/// </summary>
[JsonPropertyName("isTruncated")]
public bool IsTruncated { get; set; }
/// <summary>
/// Gets or sets the createdDateTime.
/// When the content was created. E.g., file created time or the time when a message was sent.
/// </summary>
[JsonPropertyName("createdDateTime")]
public DateTimeOffset CreatedDateTime { get; set; } = DateTime.UtcNow;
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("modifiedDateTime")]
public DateTimeOffset? ModifiedDateTime { get; set; } = DateTime.UtcNow;
}
@@ -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;
/// <summary>
/// Represents metadata for conversation content to be processed by the Purview SDK.
/// </summary>
internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase
{
private const string ProcessConversationMetadataDataType = Constants.ODataGraphNamespace + ".processConversationMetadata";
/// <summary>
/// Initializes a new instance of the <see cref="ProcessConversationMetadata"/> class.
/// </summary>
public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name)
{
this.DataType = ProcessConversationMetadataDataType;
}
/// <summary>
/// Gets or sets the parent message ID for nested conversations.
/// </summary>
[JsonPropertyName("parentMessageId")]
public string? ParentMessageId { get; set; }
/// <summary>
/// Gets or sets the accessed resources during message generation for bot messages.
/// </summary>
[JsonPropertyName("accessedResources_v2")]
public List<AccessedResourceDetails>? AccessedResources { get; set; }
/// <summary>
/// Gets or sets the plugins used during message generation for bot messages.
/// </summary>
[JsonPropertyName("plugins")]
public List<AIInteractionPlugin>? Plugins { get; set; }
/// <summary>
/// Gets or sets the collection of AI agent information.
/// </summary>
[JsonPropertyName("agents")]
public List<AIAgentInfo>? Agents { get; set; }
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents metadata for a file content to be processed by the Purview SDK.
/// </summary>
internal sealed class ProcessFileMetadata : ProcessContentMetadataBase
{
private const string ProcessFileMetadataDataType = Constants.ODataGraphNamespace + ".processFileMetadata";
/// <summary>
/// Initializes a new instance of the <see cref="ProcessFileMetadata"/> class.
/// </summary>
public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name)
{
this.DataType = ProcessFileMetadataDataType;
}
/// <summary>
/// Gets or sets the owner ID.
/// </summary>
[JsonPropertyName("ownerId")]
public string? OwnerId { get; set; }
}
@@ -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;
/// <summary>
/// Contains information about a processing error.
/// </summary>
internal sealed class ProcessingError : ClassificationErrorBase
{
/// <summary>
/// Details about the error.
/// </summary>
[JsonPropertyName("details")]
public List<ClassificationErrorBase>? Details { get; set; }
/// <summary>
/// Gets or sets the error type.
/// </summary>
[JsonPropertyName("type")]
public ContentProcessingErrorType? Type { get; set; }
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents metadata for a protected application that is integrated with Purview.
/// </summary>
internal sealed class ProtectedAppMetadata : IntegratedAppMetadata
{
/// <summary>
/// Creates a new instance of the <see cref="ProtectedAppMetadata"/> class.
/// </summary>
/// <param name="applicationLocation">The location information of the protected app's data.</param>
public ProtectedAppMetadata(PolicyLocation applicationLocation)
{
this.ApplicationLocation = applicationLocation;
}
/// <summary>
/// The location of the application.
/// </summary>
[JsonPropertyName("applicationLocation")]
public PolicyLocation ApplicationLocation { get; set; }
}
@@ -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;
/// <summary>
/// Activities that can be protected by the Purview Protection Scopes API.
/// </summary>
[Flags]
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<ProtectionScopeActivities>))]
internal enum ProtectionScopeActivities
{
/// <summary>
/// None.
/// </summary>
[EnumMember(Value = "none")]
None = 0,
/// <summary>
/// Upload text activity.
/// </summary>
[EnumMember(Value = "uploadText")]
UploadText = 1,
/// <summary>
/// Upload file activity.
/// </summary>
[EnumMember(Value = "uploadFile")]
UploadFile = 2,
/// <summary>
/// Download text activity.
/// </summary>
[EnumMember(Value = "downloadText")]
DownloadText = 4,
/// <summary>
/// Download file activity.
/// </summary>
[EnumMember(Value = "downloadFile")]
DownloadFile = 8,
/// <summary>
/// Unknown future value.
/// </summary>
[EnumMember(Value = "unknownFutureValue")]
UnknownFutureValue = 16
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Indicates status of protection scope changes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ProtectionScopeState>))]
internal enum ProtectionScopeState
{
/// <summary>
/// Scope state hasn't changed.
/// </summary>
NotModified = 0,
/// <summary>
/// Scope state has changed.
/// </summary>
Modified = 1,
/// <summary>
/// Unknown value placeholder for future use.
/// </summary>
UnknownFutureValue = 2
}
@@ -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;
/// <summary>
/// A cache key for storing protection scope responses.
/// </summary>
internal sealed class ProtectionScopesCacheKey
{
/// <summary>
/// Creates a new instance of <see cref="ProtectionScopesCacheKey"/>.
/// </summary>
/// <param name="userId">The entra id of the user who made the interaction.</param>
/// <param name="tenantId">The tenant id of the user who made the interaction.</param>
/// <param name="activities">The activity performed with the data.</param>
/// <param name="location">The location where the data came from.</param>
/// <param name="pivotOn">The property to pivot on.</param>
/// <param name="deviceMetadata">Metadata about the device that made the interaction.</param>
/// <param name="integratedAppMetadata">Metadata about the app that is integrating with Purview.</param>
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;
}
/// <summary>
/// Creates a mew instance of <see cref="ProtectionScopesCacheKey"/>.
/// </summary>
/// <param name="request">A protection scopes request.</param>
public ProtectionScopesCacheKey(
ProtectionScopesRequest request) : this(
request.UserId,
request.TenantId,
request.Activities,
request.Locations.FirstOrDefault(),
request.PivotOn,
request.DeviceMetadata,
request.IntegratedAppMetadata)
{
}
/// <summary>
/// The id of the user making the request.
/// </summary>
public string UserId { get; set; }
/// <summary>
/// The id of the tenant containing the user making the request.
/// </summary>
public string TenantId { get; set; }
/// <summary>
/// The activity performed with the content.
/// </summary>
public ProtectionScopeActivities Activities { get; set; }
/// <summary>
/// The location of the application.
/// </summary>
public PolicyLocation? Location { get; set; }
/// <summary>
/// The property used to pivot the policy evaluation.
/// </summary>
public PolicyPivotProperty? PivotOn { get; set; }
/// <summary>
/// Metadata about the device used to access the content.
/// </summary>
public DeviceMetadata? DeviceMetadata { get; set; }
/// <summary>
/// Metadata about the integrated app used to access the content.
/// </summary>
public IntegratedAppMetadata? IntegratedAppMetadata { get; set; }
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a binary content item to be processed.
/// </summary>
internal sealed class PurviewBinaryContent : ContentBase
{
private const string BinaryContentDataType = Constants.ODataGraphNamespace + ".binaryContent";
/// <summary>
/// Initializes a new instance of the <see cref="PurviewBinaryContent"/> class.
/// </summary>
/// <param name="data">The binary content in byte array format.</param>
public PurviewBinaryContent(byte[] data) : base(BinaryContentDataType)
{
this.Data = data;
}
/// <summary>
/// Gets or sets the binary data.
/// </summary>
[JsonPropertyName("data")]
public byte[] Data { get; set; }
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents a text content item to be processed.
/// </summary>
internal sealed class PurviewTextContent : ContentBase
{
private const string TextContentDataType = Constants.ODataGraphNamespace + ".textContent";
/// <summary>
/// Initializes a new instance of the <see cref="PurviewTextContent"/> class.
/// </summary>
/// <param name="data">The text content in string format.</param>
public PurviewTextContent(string data) : base(TextContentDataType)
{
this.Data = data;
}
/// <summary>
/// Gets or sets the text data.
/// </summary>
[JsonPropertyName("data")]
public string Data { get; set; }
}
@@ -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;
/// <summary>
/// Status of the access operation.
/// </summary>
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<ResourceAccessStatus>))]
internal enum ResourceAccessStatus
{
/// <summary>
/// Represents failed access to the resource.
/// </summary>
[EnumMember(Value = "failure")]
Failure = 0,
/// <summary>
/// Represents successful access to the resource.
/// </summary>
[EnumMember(Value = "success")]
Success = 1,
/// <summary>
/// Unknown future value.
/// </summary>
[EnumMember(Value = "unknownFutureValue")]
UnknownFutureValue = 2
}
@@ -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;
/// <summary>
/// Access type performed on the resource.
/// </summary>
[Flags]
[DataContract]
[JsonConverter(typeof(JsonStringEnumConverter<ResourceAccessType>))]
internal enum ResourceAccessType : long
{
/// <summary>
/// No access type.
/// </summary>
[EnumMember(Value = "none")]
None = 0,
/// <summary>
/// Read access.
/// </summary>
[EnumMember(Value = "read")]
Read = 1 << 0,
/// <summary>
/// Write access.
/// </summary>
[EnumMember(Value = "write")]
Write = 1 << 1,
/// <summary>
/// Create access.
/// </summary>
[EnumMember(Value = "create")]
Create = 1 << 2,
/// <summary>
/// Unknown future value.
/// </summary>
[EnumMember(Value = "unknownFutureValue")]
UnknownFutureValue = 1 << 3
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Restriction actions for devices.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RestrictionAction>))]
internal enum RestrictionAction
{
/// <summary>
/// Warn Action.
/// </summary>
Warn,
/// <summary>
/// Audit action.
/// </summary>
Audit,
/// <summary>
/// Block action.
/// </summary>
Block,
/// <summary>
/// Allow action
/// </summary>
Allow
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Represents tenant/user/group scopes.
/// </summary>
internal sealed class Scope
{
/// <summary>
/// The odata type of the scope used to identify what type of scope was returned.
/// </summary>
[JsonPropertyName("@odata.type")]
public string? ODataType { get; set; }
/// <summary>
/// Gets or sets the scope identifier.
/// </summary>
[JsonPropertyName("identity")]
public string? Identity { get; set; }
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Info pulled from an auth token.
/// </summary>
internal sealed class TokenInfo
{
/// <summary>
/// The entra id of the authenticated user. This is null if the auth token is not a user token.
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// The tenant id of the auth token.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// The client id of the auth token.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Gets a value indicating whether the token is associated with a user.
/// </summary>
public bool IsUserToken => !string.IsNullOrEmpty(this.UserId);
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
/// <summary>
/// Abstract base class for background jobs.
/// </summary>
internal abstract class BackgroundJobBase
{
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Purview.Models.Requests;
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
/// <summary>
/// Class representing a job to send content activities to the Purview service.
/// </summary>
internal sealed class ContentActivityJob : BackgroundJobBase
{
/// <summary>
/// Create a new instance of the <see cref="ContentActivityJob"/> class.
/// </summary>
/// <param name="request">The content activities request to be sent in the background.</param>
public ContentActivityJob(ContentActivitiesRequest request)
{
this.Request = request;
}
/// <summary>
/// The request to send to the Purview service.
/// </summary>
public ContentActivitiesRequest Request { get; }
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Purview.Models.Requests;
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
/// <summary>
/// Class representing a job to process content.
/// </summary>
internal sealed class ProcessContentJob : BackgroundJobBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ProcessContentJob"/> class.
/// </summary>
/// <param name="request">The process content request to be sent in the background.</param>
public ProcessContentJob(ProcessContentRequest request)
{
this.Request = request;
}
/// <summary>
/// The request to process content.
/// </summary>
public ProcessContentRequest Request { get; }
}
@@ -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;
/// <summary>
/// A request class used for contentActivity requests.
/// </summary>
internal sealed class ContentActivitiesRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="ContentActivitiesRequest"/> class.
/// </summary>
/// <param name="userId">The entra id of the user who performed the activity.</param>
/// <param name="tenantId">The tenant id of the user who performed the activity.</param>
/// <param name="contentMetadata">The metadata about the content that was sent.</param>
/// <param name="correlationId">The correlation id of the request.</param>
/// <param name="scopeIdentifier">The scope identifier of the protection scopes associated with this request.</param>
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;
}
/// <summary>
/// Gets or sets the ID of the signal.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// Gets or sets the user ID of the content that is generating the signal.
/// </summary>
[JsonPropertyName("userId")]
public string UserId { get; set; }
/// <summary>
/// Gets or sets the scope identifier for the signal.
/// </summary>
[JsonPropertyName("scopeIdentifier")]
public string? ScopeIdentifier { get; set; }
/// <summary>
/// Gets or sets the content and associated content metadata for the content used to generate the signal.
/// </summary>
[JsonPropertyName("contentMetadata")]
public ContentToProcess ContentMetadata { get; set; }
/// <summary>
/// Gets or sets the correlation ID for the signal.
/// </summary>
[JsonIgnore]
public Guid CorrelationId { get; set; }
/// <summary>
/// Gets or sets the tenant id for the signal.
/// </summary>
[JsonIgnore]
public string TenantId { get; set; }
}
@@ -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;
/// <summary>
/// Request for ProcessContent API
/// </summary>
internal sealed class ProcessContentRequest
{
/// <summary>
/// Creates a new instance of ProcessContentRequest.
/// </summary>
/// <param name="contentToProcess">The content and its metadata that will be processed.</param>
/// <param name="userId">The entra user id of the user making the request.</param>
/// <param name="tenantId">The tenant id of the user making the request.</param>
public ProcessContentRequest(ContentToProcess contentToProcess, string userId, string tenantId)
{
this.ContentToProcess = contentToProcess;
this.UserId = userId;
this.TenantId = tenantId;
}
/// <summary>
/// The content to process.
/// </summary>
[JsonPropertyName("contentToProcess")]
public ContentToProcess ContentToProcess { get; set; }
/// <summary>
/// The user id of the user making the request.
/// </summary>
[JsonIgnore]
public string UserId { get; set; }
/// <summary>
/// The correlation id of the request.
/// </summary>
[JsonIgnore]
public Guid CorrelationId { get; set; } = Guid.NewGuid();
/// <summary>
/// The tenant id of the user making the request.
/// </summary>
[JsonIgnore]
public string TenantId { get; set; }
/// <summary>
/// The identifier of the cached protection scopes.
/// </summary>
[JsonIgnore]
internal string? ScopeIdentifier { get; set; }
}
@@ -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;
/// <summary>
/// Request model for user protection scopes requests.
/// </summary>
[DataContract]
internal sealed class ProtectionScopesRequest
{
/// <summary>
/// Creates a new instance of ProtectionScopesRequest.
/// </summary>
/// <param name="userId">The entra id of the user who made the interaction.</param>
/// <param name="tenantId">The tenant id of the user who made the interaction.</param>
public ProtectionScopesRequest(string userId, string tenantId)
{
this.UserId = userId;
this.TenantId = tenantId;
}
/// <summary>
/// Activities to include in the scope
/// </summary>
[DataMember]
[JsonPropertyName("activities")]
public ProtectionScopeActivities Activities { get; set; }
/// <summary>
/// Gets or sets the locations to compute protection scopes for.
/// </summary>
[JsonPropertyName("locations")]
public ICollection<PolicyLocation> Locations { get; set; } = Array.Empty<PolicyLocation>();
/// <summary>
/// Response aggregation pivot
/// </summary>
[DataMember]
[JsonPropertyName("pivotOn")]
public PolicyPivotProperty? PivotOn { get; set; }
/// <summary>
/// Device metadata
/// </summary>
[DataMember]
[JsonPropertyName("deviceMetadata")]
public DeviceMetadata? DeviceMetadata { get; set; }
/// <summary>
/// Integrated app metadata
/// </summary>
[DataMember]
[JsonPropertyName("integratedAppMetadata")]
public IntegratedAppMetadata? IntegratedAppMetadata { get; set; }
/// <summary>
/// The correlation id of the request.
/// </summary>
[JsonIgnore]
public Guid CorrelationId { get; set; } = Guid.NewGuid();
/// <summary>
/// Scope ID, used to detect stale client scoping information
/// </summary>
[DataMember]
[JsonIgnore]
public string ScopeIdentifier { get; set; } = string.Empty;
/// <summary>
/// The id of the user making the request.
/// </summary>
[JsonIgnore]
public string UserId { get; set; }
/// <summary>
/// The tenant id of the user making the request.
/// </summary>
[JsonIgnore]
public string TenantId { get; set; }
}
@@ -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;
/// <summary>
/// Represents the response for content activities requests.
/// </summary>
internal sealed class ContentActivitiesResponse
{
/// <summary>
/// Gets or sets the HTTP status code associated with the response.
/// </summary>
[JsonIgnore]
public HttpStatusCode StatusCode { get; set; }
/// <summary>
/// Details about any errors returned by the request.
/// </summary>
[JsonPropertyName("error")]
public ErrorDetails? Error { get; set; }
}
@@ -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;
/// <summary>
/// The response of a process content evaluation.
/// </summary>
internal sealed class ProcessContentResponse
{
/// <summary>
/// Gets or sets the evaluation id.
/// </summary>
[Key]
public string? Id { get; set; }
/// <summary>
/// Gets or sets the status of protection scope changes.
/// </summary>
[DataMember]
[JsonPropertyName("protectionScopeState")]
public ProtectionScopeState? ProtectionScopeState { get; set; }
/// <summary>
/// Gets or sets the policy actions to take.
/// </summary>
[DataMember]
[JsonPropertyName("policyActions")]
public IReadOnlyList<DlpActionInfo>? PolicyActions { get; set; }
/// <summary>
/// Gets or sets error information about the evaluation.
/// </summary>
[DataMember]
[JsonPropertyName("processingErrors")]
public IReadOnlyList<ProcessingError>? ProcessingErrors { get; set; }
}
@@ -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;
/// <summary>
/// A response object containing protection scopes for a tenant.
/// </summary>
internal sealed class ProtectionScopesResponse
{
/// <summary>
/// The identifier used for caching the user protection scopes.
/// </summary>
public string? ScopeIdentifier { get; set; }
/// <summary>
/// The user protection scopes.
/// </summary>
[JsonPropertyName("value")]
public IReadOnlyCollection<PolicyScopeBase>? Scopes { get; set; }
}
@@ -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;
/// <summary>
/// A middleware agent that connects to Microsoft Purview.
/// </summary>
internal class PurviewAgent : AIAgent, IDisposable
{
private readonly AIAgent _innerAgent;
private readonly PurviewWrapper _purviewWrapper;
/// <summary>
/// Initializes a new instance of the <see cref="PurviewAgent"/> class.
/// </summary>
/// <param name="innerAgent">The agent-framework agent that the middleware wraps.</param>
/// <param name="purviewWrapper">The purview wrapper used to interact with the Purview service.</param>
public PurviewAgent(AIAgent innerAgent, PurviewWrapper purviewWrapper)
{
this._innerAgent = innerAgent;
this._purviewWrapper = purviewWrapper;
}
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
}
/// <inheritdoc/>
public override AgentThread GetNewThread()
{
return this._innerAgent.GetNewThread();
}
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> 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;
}
}
/// <inheritdoc/>
public void Dispose()
{
if (this._innerAgent is IDisposable disposableAgent)
{
disposableAgent.Dispose();
}
this._purviewWrapper.Dispose();
}
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Purview.Models.Common;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// An identifier representing the app's location for Purview policy evaluation.
/// </summary>
public class PurviewAppLocation
{
/// <summary>
/// Creates a new instance of <see cref="PurviewAppLocation"/>.
/// </summary>
/// <param name="locationType">The type of location.</param>
/// <param name="locationValue">The value of the location.</param>
public PurviewAppLocation(PurviewLocationType locationType, string locationValue)
{
this.LocationType = locationType;
this.LocationValue = locationValue;
}
/// <summary>
/// The type of location.
/// </summary>
public PurviewLocationType LocationType { get; set; }
/// <summary>
/// The location value.
/// </summary>
public string LocationValue { get; set; }
/// <summary>
/// Returns the <see cref="PolicyLocation"/> model for this <see cref="PurviewAppLocation"/>.
/// </summary>
/// <returns>PolicyLocation request model.</returns>
/// <exception cref="InvalidOperationException">Thrown when an invalid location type is provided.</exception>
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.");
}
}
}
@@ -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;
/// <summary>
/// A middleware chat client that connects to Microsoft Purview.
/// </summary>
internal class PurviewChatClient : IChatClient
{
private readonly IChatClient _innerChatClient;
private readonly PurviewWrapper _purviewWrapper;
/// <summary>
/// Initializes a new instance of the <see cref="PurviewChatClient"/> class.
/// </summary>
/// <param name="innerChatClient">The inner chat client to wrap.</param>
/// <param name="purviewWrapper">The purview wrapper used to interact with the Purview service.</param>
public PurviewChatClient(IChatClient innerChatClient, PurviewWrapper purviewWrapper)
{
this._innerChatClient = innerChatClient;
this._purviewWrapper = purviewWrapper;
}
/// <inheritdoc/>
public void Dispose()
{
this._purviewWrapper.Dispose();
this._innerChatClient.Dispose();
}
/// <inheritdoc/>
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
return this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken);
}
/// <inheritdoc/>
public object? GetService(Type serviceType, object? serviceKey = null)
{
return this._innerChatClient.GetService(serviceType, serviceKey);
}
/// <inheritdoc/>
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Task<ChatResponse> responseTask = this._purviewWrapper.ProcessChatContentAsync(messages, options, this._innerChatClient, cancellationToken);
foreach (var update in (await responseTask.ConfigureAwait(false)).ToChatResponseUpdates())
{
yield return update;
}
}
}
@@ -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;
/// <summary>
/// Client for calling Purview APIs.
/// </summary>
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);
}
}
/// <summary>
/// Creates a new <see cref="PurviewClient"/> instance.
/// </summary>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings used for purview requests.</param>
/// <param name="httpClient">The HttpClient used to make network requests to Purview.</param>
/// <param name="logger">The logger used to log information from the middleware.</param>
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
};
}
/// <inheritdoc/>
public async Task<TokenInfo> 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);
}
/// <inheritdoc/>
public async Task<ProcessContentResponse> 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<ProcessContentResponse> typeInfo = (JsonTypeInfo<ProcessContentResponse>)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");
}
}
/// <inheritdoc/>
public async Task<ProtectionScopesResponse> 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<ProtectionScopesResponse> typeInfo = (JsonTypeInfo<ProtectionScopesResponse>)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");
}
}
/// <inheritdoc/>
public async Task<ContentActivitiesResponse> 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<ContentActivitiesResponse> typeInfo = (JsonTypeInfo<ContentActivitiesResponse>)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");
}
}
}
@@ -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;
/// <summary>
/// Extension methods to add Purview capabilities to an <see cref="AIAgent"/>.
/// </summary>
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<IPurviewClient, PurviewClient>();
services.AddSingleton<IScopedContentProcessor, ScopedContentProcessor>();
services.AddSingleton(distributedCache);
services.AddSingleton<ICacheProvider, CacheProvider>();
services.AddSingleton<HttpClient>();
services.AddSingleton(logger ?? NullLogger.Instance);
services.AddSingleton<PurviewWrapper>();
services.AddSingleton(Channel.CreateBounded<BackgroundJobBase>(purviewSettings.PendingBackgroundJobLimit));
services.AddSingleton<IChannelHandler, ChannelHandler>();
services.AddSingleton<BackgroundJobRunner>();
ServiceProvider serviceProvider = services.BuildServiceProvider();
return serviceProvider.GetRequiredService<PurviewWrapper>();
}
/// <summary>
/// Adds Purview capabilities to an <see cref="AIAgentBuilder"/>.
/// </summary>
/// <param name="builder">The AI Agent builder for the <see cref="AIAgent"/>.</param>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>The updated <see cref="AIAgentBuilder"/></returns>
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));
}
/// <summary>
/// Adds Purview capabilities to a <see cref="ChatClientBuilder"/>.
/// </summary>
/// <param name="builder">The chat client builder for the <see cref="IChatClient"/>.</param>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>The updated <see cref="ChatClientBuilder"/></returns>
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));
}
/// <summary>
/// Creates a Purview middleware function for use with a <see cref="IChatClient"/>.
/// </summary>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>A chat middleware delegate.</returns>
public static Func<IChatClient, IChatClient> PurviewChatMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
{
PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
return (innerChatClient) => new PurviewChatClient(innerChatClient, purviewWrapper);
}
/// <summary>
/// Creates a Purview middleware function for use with an <see cref="AIAgent"/>.
/// </summary>
/// <param name="tokenCredential">The token credential used to authenticate with Purview.</param>
/// <param name="purviewSettings">The settings for communication with Purview.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>An agent middleware delegate.</returns>
public static Func<AIAgent, AIAgent> PurviewAgentMiddleware(TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
{
PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
return (innerAgent) => new PurviewAgent(innerAgent, purviewWrapper);
}
/// <summary>
/// Sets the user id for a message.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="userId">The id of the owner of the message.</param>
public static void SetUserId(this ChatMessage message, Guid userId)
{
if (message.AdditionalProperties == null)
{
message.AdditionalProperties = new AdditionalPropertiesDictionary();
}
message.AdditionalProperties[Constants.UserId] = userId.ToString();
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// The type of location for Purview policy evaluation.
/// </summary>
public enum PurviewLocationType
{
/// <summary>
/// An application location.
/// </summary>
Application,
/// <summary>
/// A URI location.
/// </summary>
Uri,
/// <summary>
/// A domain name location.
/// </summary>
Domain
}
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Purview;
/// <summary>
/// Represents the configuration settings for a Purview application, including tenant information, application name, and
/// optional default user settings.
/// </summary>
/// <remarks>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.</remarks>
public class PurviewSettings
{
/// <summary>
/// Initializes a new instance of the <see cref="PurviewSettings"/> class.
/// </summary>
/// <param name="appName">The publicly visible name of the application.</param>
public PurviewSettings(string appName)
{
this.AppName = appName;
}
/// <summary>
/// The publicly visible app name of the application.
/// </summary>
public string AppName { get; set; }
/// <summary>
/// The version string of the application.
/// </summary>
public string? AppVersion { get; set; }
/// <summary>
/// The tenant id of the user making the request.
/// If this is not provided, the tenant id will be inferred from the token.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets the location of the Purview resource.
/// If this is not provided, a location containing the client id will be used instead.
/// </summary>
public PurviewAppLocation? PurviewAppLocation { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool IgnoreExceptions { get; set; }
/// <summary>
/// Gets or sets the base URI for the Microsoft Graph API.
/// Set to graph v1.0 by default.
/// </summary>
public Uri GraphBaseUri { get; set; } = new Uri("https://graph.microsoft.com/v1.0/");
/// <summary>
/// Gets or sets the message to display when a prompt is blocked by Purview policies.
/// </summary>
public string BlockedPromptMessage { get; set; } = "Prompt blocked by policies";
/// <summary>
/// Gets or sets the message to display when a response is blocked by Purview policies.
/// </summary>
public string BlockedResponseMessage { get; set; } = "Response blocked by policies";
/// <summary>
/// The size limit of the default in memory cache in bytes. This only applies if no cache is provided when creating Purview resources.
/// </summary>
public long? InMemoryCacheSizeLimit { get; set; } = 100_000_000;
/// <summary>
/// The TTL of each cache entry.
/// </summary>
public TimeSpan CacheTTL { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// The maximum number of background jobs that can be queued up.
/// </summary>
public int PendingBackgroundJobLimit { get; set; } = 100;
/// <summary>
/// The maximum number of concurrent job consumers.
/// </summary>
public int MaxConcurrentJobConsumers { get; set; } = 10;
}
@@ -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;
/// <summary>
/// A delegating agent that connects to Microsoft Purview.
/// </summary>
internal sealed class PurviewWrapper : IDisposable
{
private readonly ILogger _logger;
private readonly IScopedContentProcessor _scopedProcessor;
private readonly PurviewSettings _purviewSettings;
private readonly IChannelHandler _channelHandler;
/// <summary>
/// Creates a new <see cref="PurviewWrapper"/> instance.
/// </summary>
/// <param name="scopedProcessor">The scoped processor used to orchestrate the calls to Purview.</param>
/// <param name="purviewSettings">The settings for Purview integration.</param>
/// <param name="logger">The logger used for logging.</param>
/// <param name="channelHandler">The channel handler used to queue background jobs and add job runners.</param>
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<ChatMessage> 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();
}
/// <summary>
/// Processes a prompt and response exchange at a chat client level.
/// </summary>
/// <param name="messages">The messages sent to the chat client.</param>
/// <param name="options">The chat options used with the chat client.</param>
/// <param name="innerChatClient">The wrapped chat client.</param>
/// <param name="cancellationToken">The cancellation token used to interrupt async operations.</param>
/// <returns>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.</returns>
public async Task<ChatResponse> ProcessChatContentAsync(IEnumerable<ChatMessage> 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;
}
/// <summary>
/// Processes a prompt and response exchange at an agent level.
/// </summary>
/// <param name="messages">The messages sent to the agent.</param>
/// <param name="thread">The thread used for this agent conversation.</param>
/// <param name="options">The options used with this agent.</param>
/// <param name="innerAgent">The wrapped agent.</param>
/// <param name="cancellationToken">The cancellation token used to interrupt async operations.</param>
/// <returns>The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response.</returns>
public async Task<AgentRunResponse> ProcessAgentContentAsync(IEnumerable<ChatMessage> 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;
}
/// <inheritdoc/>
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.
}
}
@@ -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"] = "<your-entra-user-id-here>";
// Or with the extension method
var message = new ChatMessage(ChatRole.User, promptText);
message.SetUserId(new Guid("<your-entra-user-id-here>"));
```
### 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.")
}
```
@@ -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;
/// <summary>
/// Processor class that combines protectionScopes, processContent, and contentActivities calls.
/// </summary>
internal sealed class ScopedContentProcessor : IScopedContentProcessor
{
private readonly IPurviewClient _purviewClient;
private readonly ICacheProvider _cacheProvider;
private readonly IChannelHandler _channelHandler;
/// <summary>
/// Create a new instance of <see cref="ScopedContentProcessor"/>.
/// </summary>
/// <param name="purviewClient">The purview client to use for purview requests.</param>
/// <param name="cacheProvider">The cache used to store Purview data.</param>
/// <param name="channelHandler">The channel handler used to manage background jobs.</param>
public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cacheProvider, IChannelHandler channelHandler)
{
this._purviewClient = purviewClient;
this._cacheProvider = cacheProvider;
this._channelHandler = channelHandler;
}
/// <inheritdoc/>
public async Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable<ChatMessage> messages, string? threadId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken)
{
List<ProcessContentRequest> 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<ChatMessage> 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;
}
/// <summary>
/// Transform a list of ChatMessages into a list of ProcessContentRequests.
/// </summary>
/// <param name="messages">The messages to transform.</param>
/// <param name="threadId">The id of the message thread.</param>
/// <param name="activity">The activity performed on the content.</param>
/// <param name="settings">The settings used for purview integration.</param>
/// <param name="userId">The entra id of the user who made the interaction.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async operations.</param>
/// <returns>A list of process content requests.</returns>
private async Task<List<ProcessContentRequest>> MapMessageToPCRequestsAsync(IEnumerable<ChatMessage> messages, string? threadId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken)
{
List<ProcessContentRequest> 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<ProcessContentMetadataBase> { 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;
}
/// <summary>
/// Orchestrates process content and protection scopes calls.
/// </summary>
/// <param name="pcRequest">The process content request.</param>
/// <param name="cancellationToken">The cancellation token used to cancel async operations.</param>
/// <returns>A process content response. This could be a response from the process content API or a response generated from a content activities call.</returns>
private async Task<ProcessContentResponse> 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<ProtectionScopesCacheKey, ProtectionScopesResponse>(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<DlpActionInfo> 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();
}
/// <summary>
/// Dedupe policy actions received from the service.
/// </summary>
/// <param name="pcResponse">The process content response which may contain DLP actions.</param>
/// <param name="actionInfos">DLP actions returned from protection scopes.</param>
/// <returns>The process content response with the protection scopes DLP actions added. Actions are deduplicated.</returns>
private static ProcessContentResponse CombinePolicyActions(ProcessContentResponse pcResponse, List<DlpActionInfo>? actionInfos)
{
if (actionInfos == null || actionInfos.Count == 0)
{
return pcResponse;
}
if (pcResponse.PolicyActions == null)
{
pcResponse.PolicyActions = actionInfos;
return pcResponse;
}
List<DlpActionInfo> pcActionInfos = new(pcResponse.PolicyActions);
pcActionInfos.AddRange(actionInfos);
pcResponse.PolicyActions = pcActionInfos;
return pcResponse;
}
/// <summary>
/// Check if any scopes are applicable to the request.
/// </summary>
/// <param name="pcRequest">The process content request.</param>
/// <param name="psResponse">The protection scopes response that was returned for the process content request.</param>
/// <returns>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.</returns>
private static (bool shouldProcess, List<DlpActionInfo> 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<DlpActionInfo> dlpActions = new();
bool shouldProcess = false;
ExecutionMode executionMode = ExecutionMode.EvaluateOffline;
foreach (var scope in psResponse.Scopes ?? Array.Empty<PolicyScopeBase>())
{
bool activityMatch = scope.Activities.HasFlag(requestActivity);
bool locationMatch = false;
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
{
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);
}
/// <summary>
/// Create a ProtectionScopesRequest for the given content ProcessContentRequest.
/// </summary>
/// <param name="pcRequest">The process content request.</param>
/// <param name="userId">The entra user id of the user who sent the data.</param>
/// <param name="tenantId">The tenant id of the user who sent the data.</param>
/// <param name="correlationId">The correlation id of the request.</param>
/// <returns>The protection scopes request generated from the process content request.</returns>
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<PolicyLocation> { pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation },
DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata,
IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata,
CorrelationId = correlationId
};
}
/// <summary>
/// Map process content activity to protection scope activity.
/// </summary>
/// <param name="activity">The process content activity.</param>
/// <returns>The protection scopes activity.</returns>
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;
}
}
}
@@ -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;
/// <summary>
/// Source generation context for Purview serialization.
/// </summary>
[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;
/// <summary>
/// Utility class for Purview serialization settings.
/// </summary>
internal static class PurviewSerializationUtils
{
/// <summary>
/// Serialization settings for Purview.
/// </summary>
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,
};
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Unit tests for the <see cref="PurviewClient"/> class.
/// </summary>
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<DlpActionInfo>
{
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<PurviewRateLimitException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewPaymentRequiredException>(() =>
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<PurviewRequestException>(() =>
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<PurviewRequestException>(() =>
this._client.ProcessContentAsync(request, CancellationToken.None));
Assert.Contains("Failed to deserialize ProcessContent response", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<JsonException>(exception.InnerException);
}
[Fact]
public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
{
// Arrange
var request = CreateValidProcessContentRequest();
this._handler.ShouldThrowHttpRequestException = true;
// Act & Assert
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._client.ProcessContentAsync(request, CancellationToken.None));
Assert.Equal("Http error occurred while processing content.", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<HttpRequestException>(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<PolicyLocation>
{
new("microsoft.graph.policyLocationApplication", "app-123")
}
};
var expectedResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
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<PolicyScopeBase>() };
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<PurviewRateLimitException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewRequestException>(() =>
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<JsonException>(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<PurviewRequestException>(() =>
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<HttpRequestException>(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<PurviewRateLimitException>(() =>
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<PurviewAuthenticationException>(() =>
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<PurviewRequestException>(() =>
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<PurviewRequestException>(() =>
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
Assert.Contains("Failed to deserialize ContentActivities response", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<JsonException>(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<PurviewRequestException>(() =>
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
Assert.Equal("Http error occurred while creating content activities.", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<HttpRequestException>(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<ProcessContentMetadataBase> { metadata },
activityMetadata,
deviceMetadata,
integratedAppMetadata,
protectedAppMetadata
);
}
#endregion
public void Dispose()
{
this._handler.Dispose();
this._httpClient.Dispose();
}
/// <summary>
/// Mock HTTP message handler for testing
/// </summary>
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<HttpResponseMessage> 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);
}
}
/// <summary>
/// Mock token credential for testing
/// </summary>
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<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
}
}
}
@@ -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;
/// <summary>
/// Unit tests for the <see cref="PurviewWrapper"/> class.
/// </summary>
public sealed class PurviewWrapperTests : IDisposable
{
private readonly Mock<IScopedContentProcessor> _mockProcessor;
private readonly IChannelHandler _channelHandler;
private readonly PurviewSettings _settings;
private readonly PurviewWrapper _wrapper;
public PurviewWrapperTests()
{
this._mockProcessor = new Mock<IScopedContentProcessor>();
this._channelHandler = Mock.Of<IChannelHandler>();
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<ChatMessage>
{
new(ChatRole.User, "Sensitive content that should be blocked")
};
var mockChatClient = new Mock<IChatClient>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockChatClient = new Mock<IChatClient>();
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockChatClient = new Mock<IChatClient>();
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client"));
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockChatClient = new Mock<IChatClient>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new PurviewRequestException("Prompt processing error"));
// Act & Assert
await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None));
}
[Fact]
public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var options = new ChatOptions { ConversationId = "conversation-123" };
var mockChatClient = new Mock<IChatClient>();
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"));
mockChatClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
"conversation-123",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((false, "user-123"));
// Act
await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None);
// Assert
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
"conversation-123",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
#endregion
#region ProcessAgentContentAsync Tests
[Fact]
public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Sensitive content")
};
var mockAgent = new Mock<AIAgent>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new PurviewRequestException("Processing error"));
// Act & Assert
await Assert.ThrowsAsync<PurviewRequestException>(() =>
this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None));
}
[Fact]
public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditionalProperties_Async()
{
// Arrange
var messages = new List<ChatMessage>
{
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<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
"conversation-from-props",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<IEnumerable<ChatMessage>>(),
"conversation-from-props",
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
string? capturedThreadId = null;
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(innerResponse);
var callCount = 0;
string? firstCallUserId = null;
string? secondCallUserId = null;
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
It.IsAny<Activity>(),
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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();
}
}
@@ -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;
/// <summary>
/// Unit tests for the <see cref="ScopedContentProcessor"/> class.
/// </summary>
public sealed class ScopedContentProcessorTests
{
private readonly Mock<IPurviewClient> _mockPurviewClient;
private readonly Mock<ICacheProvider> _mockCacheProvider;
private readonly Mock<IChannelHandler> _mockChannelHandler;
private readonly ScopedContentProcessor _processor;
public ScopedContentProcessorTests()
{
this._mockPurviewClient = new Mock<IPurviewClient>();
this._mockCacheProvider = new Mock<ICacheProvider>();
this._mockChannelHandler = new Mock<IChannelHandler>();
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<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>
{
new() { Action = DlpAction.BlockAccess }
}
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>
{
new() { RestrictionAction = RestrictionAction.Block }
}
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>
{
new() { Action = DlpAction.NotifyUser }
}
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
var cachedPsResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(cachedPsResponse);
var pcResponse = new ProcessContentResponse
{
PolicyActions = new List<DlpActionInfo>()
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModifiedAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-123")
},
ExecutionMode = ExecutionMode.EvaluateInline
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
{
ProtectionScopeState = ProtectionScopeState.Modified,
PolicyActions = new List<DlpActionInfo>()
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableScopesAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes = new List<PolicyScopeBase>
{
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations = new List<PolicyLocation>
{
new ("microsoft.graph.policyLocationApplication", "app-456")
}
}
}
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.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<ContentActivityJob>()), Times.Once);
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
// Act & Assert
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
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<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
// Act & Assert
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
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<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse { Scopes = new List<PolicyScopeBase>() };
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.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<ChatMessage>
{
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<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse { Scopes = new List<PolicyScopeBase>() };
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.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
}