mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
a75590eb9b
commit
b19860b8a8
@@ -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,
|
||||
};
|
||||
}
|
||||
+12
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user