// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Purview.Models.Common; using Microsoft.Agents.AI.Purview.Models.Jobs; using Microsoft.Agents.AI.Purview.Models.Requests; using Microsoft.Agents.AI.Purview.Models.Responses; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Purview; /// /// Processor class that combines protectionScopes, processContent, and contentActivities calls. /// internal sealed class ScopedContentProcessor : IScopedContentProcessor { private readonly IPurviewClient _purviewClient; private readonly ICacheProvider _cacheProvider; private readonly IChannelHandler _channelHandler; /// /// Create a new instance of . /// /// The purview client to use for purview requests. /// The cache used to store Purview data. /// The channel handler used to manage background jobs. public ScopedContentProcessor(IPurviewClient purviewClient, ICacheProvider cacheProvider, IChannelHandler channelHandler) { this._purviewClient = purviewClient; this._cacheProvider = cacheProvider; this._channelHandler = channelHandler; } /// public async Task<(bool shouldBlock, string? userId)> ProcessMessagesAsync(IEnumerable messages, string? sessionId, Activity activity, PurviewSettings purviewSettings, string? userId, CancellationToken cancellationToken) { List pcRequests = await this.MapMessageToPCRequestsAsync(messages, sessionId, activity, purviewSettings, userId, cancellationToken).ConfigureAwait(false); bool shouldBlock = false; string? resolvedUserId = null; foreach (ProcessContentRequest pcRequest in pcRequests) { resolvedUserId = pcRequest.UserId; ProcessContentResponse processContentResponse = await this.ProcessContentWithProtectionScopesAsync(pcRequest, cancellationToken).ConfigureAwait(false); if (processContentResponse.PolicyActions?.Count > 0) { foreach (DlpActionInfo policyAction in processContentResponse.PolicyActions) { // We need to process all data before blocking, so set the flag and return it outside of this loop. if (policyAction.Action == DlpAction.BlockAccess) { shouldBlock = true; } if (policyAction.RestrictionAction == RestrictionAction.Block) { shouldBlock = true; } } } } return (shouldBlock, resolvedUserId); } private static bool TryGetUserIdFromPayload(IEnumerable messages, out string? userId) { userId = null; foreach (ChatMessage message in messages) { if (message.AdditionalProperties != null && message.AdditionalProperties.TryGetValue(Constants.UserId, out userId) && !string.IsNullOrEmpty(userId)) { return true; } else if (Guid.TryParse(message.AuthorName, out Guid _)) { userId = message.AuthorName; return true; } } return false; } /// /// Transform a list of ChatMessages into a list of ProcessContentRequests. /// /// The messages to transform. /// The id of the message session. /// The activity performed on the content. /// The settings used for purview integration. /// The entra id of the user who made the interaction. /// The cancellation token used to cancel async operations. /// A list of process content requests. private async Task> MapMessageToPCRequestsAsync(IEnumerable messages, string? sessionId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken) { List pcRequests = []; 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); string correlationId = (sessionId ?? Guid.NewGuid().ToString()) + "@AF"; ProcessConversationMetadata conversationMetadata = new(content, messageId, false, $"Agent Framework Message {messageId}", correlationId) { SequenceNumber = DateTime.UtcNow.Ticks, }; 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([conversationMetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); if (userId == null && tokenInfo?.UserId != null) { userId = tokenInfo.UserId; } if (string.IsNullOrEmpty(userId)) { throw new PurviewRequestException("No user id provided or inferred for Purview request. Please provide an Entra user id in each message's AuthorName, set a default Entra user id in PurviewSettings, or configure the TokenCredential to authenticate to an Entra user."); } ProcessContentRequest pcRequest = new(contentToProcess, userId, tenantId); pcRequests.Add(pcRequest); } return pcRequests; } /// /// Orchestrates process content and protection scopes calls. /// /// The process content request. /// The cancellation token used to cancel async operations. /// A process content response. This could be a response from the process content API or a response generated from a content activities call. private async Task ProcessContentWithProtectionScopesAsync(ProcessContentRequest pcRequest, CancellationToken cancellationToken) { ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId); ProtectionScopesCacheKey cacheKey = new(psRequest); ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); ProtectionScopesResponse psResponse; if (cacheResponse != null) { psResponse = cacheResponse; } else { psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false); await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false); } pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier; (bool shouldProcess, List dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse); if (shouldProcess) { if (executionMode == ExecutionMode.EvaluateOffline) { this._channelHandler.QueueJob(new ProcessContentJob(pcRequest)); return new ProcessContentResponse(); } ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false); if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified) { await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); } pcResponse = CombinePolicyActions(pcResponse, dlpActions); return pcResponse; } ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId); this._channelHandler.QueueJob(new ContentActivityJob(caRequest)); return new ProcessContentResponse(); } /// /// Dedupe policy actions received from the service. /// /// The process content response which may contain DLP actions. /// DLP actions returned from protection scopes. /// The process content response with the protection scopes DLP actions added. private static ProcessContentResponse CombinePolicyActions(ProcessContentResponse pcResponse, List? actionInfos) { if (actionInfos?.Count > 0) { pcResponse.PolicyActions = pcResponse.PolicyActions is null ? actionInfos : [.. pcResponse.PolicyActions, .. actionInfos]; } return pcResponse; } /// /// Check if any scopes are applicable to the request. /// /// The process content request. /// The protection scopes response that was returned for the process content request. /// A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request. private static (bool shouldProcess, List dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse) { ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity); // The location data type is formatted as microsoft.graph.{locationType} // Sometimes a '#' gets appended by graph during responses, so for the sake of simplicity, // Split it by '.' and take the last segment. We'll do a case-insensitive endsWith later. string[] locationSegments = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.DataType.Split('.'); string locationType = locationSegments.Length > 0 ? locationSegments[locationSegments.Length - 1] : pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; string locationValue = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; List dlpActions = []; bool shouldProcess = false; ExecutionMode executionMode = ExecutionMode.EvaluateOffline; foreach (var scope in psResponse.Scopes ?? Array.Empty()) { bool activityMatch = scope.Activities.HasFlag(requestActivity); bool locationMatch = false; foreach (var location in scope.Locations ?? Array.Empty()) { locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase); } if (activityMatch && locationMatch) { shouldProcess = true; if (scope.ExecutionMode == ExecutionMode.EvaluateInline) { executionMode = ExecutionMode.EvaluateInline; } if (scope.PolicyActions != null) { dlpActions.AddRange(scope.PolicyActions); } } } return (shouldProcess, dlpActions, executionMode); } /// /// Create a ProtectionScopesRequest for the given content ProcessContentRequest. /// /// The process content request. /// The entra user id of the user who sent the data. /// The tenant id of the user who sent the data. /// The correlation id of the request. /// The protection scopes request generated from the process content request. private static ProtectionScopesRequest CreateProtectionScopesRequest(ProcessContentRequest pcRequest, string userId, string tenantId, Guid correlationId) { return new ProtectionScopesRequest(userId, tenantId) { Activities = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity), Locations = [pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation], DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata, IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata, CorrelationId = correlationId }; } /// /// Map process content activity to protection scope activity. /// /// The process content activity. /// The protection scopes activity. private static ProtectionScopeActivities TranslateActivity(Activity activity) { return activity switch { Activity.Unknown => ProtectionScopeActivities.None, Activity.UploadText => ProtectionScopeActivities.UploadText, Activity.UploadFile => ProtectionScopeActivities.UploadFile, Activity.DownloadText => ProtectionScopeActivities.DownloadText, Activity.DownloadFile => ProtectionScopeActivities.DownloadFile, _ => ProtectionScopeActivities.UnknownFutureValue, }; } }