mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
383d551b86
* Parallelize Purview PSPC cold cache path * Cache Purview payment-required state for scope refresh * Cache Purview payment-required state for scope refresh * Align Purview policy action dedupe and 402 caching Deduplicate combined policy actions by action and restriction action so restriction-only actions are preserved without duplicating identical entries. Cache tenant-level payment-required state from background scope refresh so subsequent calls short-circuit consistently. * .NET: Implement best-effort caching for background job scope retrieval and add unit tests for cache write failures * Purview - feat: Enhance ScopedContentProcessor to queue ContentActivityJob when no applicable scopes are found and update related tests * docs: Update purview package README and AGENTS documentation to reflect caching optimizations and policy enforcement scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
329 lines
15 KiB
C#
329 lines
15 KiB
C#
// 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 = [$"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);
|
|
}
|
|
|
|
if (request.ProcessInline)
|
|
{
|
|
message.Headers.Add("Prefer", "evaluateInline");
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (this._logger.IsEnabled(LogLevel.Error))
|
|
{
|
|
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);
|
|
}
|
|
|
|
if (this._logger.IsEnabled(LogLevel.Error))
|
|
{
|
|
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);
|
|
}
|
|
|
|
if (this._logger.IsEnabled(LogLevel.Error))
|
|
{
|
|
this._logger.LogError("Failed to create content activities. Status code: {StatusCode}", response.StatusCode);
|
|
}
|
|
|
|
throw CreateExceptionForStatusCode(response.StatusCode, "contentActivities");
|
|
}
|
|
}
|
|
}
|