diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs index 43b564b58f..de57a4f317 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs @@ -265,7 +265,7 @@ public async Task SendContentActivitiesAsync(ContentA 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"; + string uri = $"{this._graphUri}/users/{userId}/dataSecurityAndGovernance/activities/contentActivities"; using (HttpRequestMessage message = new(HttpMethod.Post, new Uri(uri))) { diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md index bcd1a26192..28039b5dd3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -82,7 +82,7 @@ The plugin requires the following Graph permissions: - 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. +Authentication with user tokens is preferred. When the configured credential resolves to a user token, that token's user id is used for Purview policy evaluation. If authenticating with app tokens, the token does not contain an end-user principal, so the agent-framework caller will need to provide an entra user id for each `ChatMessage` sent 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 diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index 3e280014a0..fab7c28d9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -75,9 +75,10 @@ private static bool TryGetUserIdFromPayload(IEnumerable messages, o foreach (ChatMessage message in messages) { if (message.AdditionalProperties != null && - message.AdditionalProperties.TryGetValue(Constants.UserId, out userId) && - !string.IsNullOrEmpty(userId)) + message.AdditionalProperties.TryGetValue(Constants.UserId, out string? potentialUserId) && + Guid.TryParse(potentialUserId, out Guid _)) { + userId = potentialUserId; return true; } else if (Guid.TryParse(message.AuthorName, out Guid _)) @@ -103,21 +104,14 @@ private static bool TryGetUserIdFromPayload(IEnumerable messages, o 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? tokenInfo = await this._purviewClient.GetUserInfoFromTokenAsync(cancellationToken, settings.TenantId).ConfigureAwait(false); + string tenantId = tokenInfo?.TenantId ?? settings.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."); + string? resolvedUserId = !string.IsNullOrEmpty(tokenInfo?.UserId) ? tokenInfo.UserId : userId; + if (string.IsNullOrEmpty(resolvedUserId) && TryGetUserIdFromPayload(messages, out string? payloadUserId)) { - tokenInfo = await this._purviewClient.GetUserInfoFromTokenAsync(cancellationToken, settings.TenantId).ConfigureAwait(false); + resolvedUserId = payloadUserId; } - 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(); @@ -166,18 +160,12 @@ private async Task> MapMessageToPCRequestsAsync(IEnu }; ContentToProcess contentToProcess = new([conversationMetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); - if (userId == null && - tokenInfo?.UserId != null) - { - userId = tokenInfo.UserId; - } - - if (string.IsNullOrEmpty(userId)) + if (string.IsNullOrEmpty(resolvedUserId)) { - 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."); + throw new PurviewRequestException("No user id provided or inferred for Purview request. Please provide an Entra user id in each message, pass a user id to the processor, or configure the TokenCredential to authenticate to an Entra user."); } - ProcessContentRequest pcRequest = new(contentToProcess, userId, tenantId); + ProcessContentRequest pcRequest = new(contentToProcess, resolvedUserId, tenantId); pcRequests.Add(pcRequest); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs index 6b857101c7..c3415d42af 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -376,8 +376,8 @@ public async Task SendContentActivitiesAsync_WithValidRequest_ReturnsSuccessResp 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()); + // Verify request + Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/activities/contentActivities", this._handler.RequestUri?.ToString()); Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 3cfc81face..d1b9d53558 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -446,6 +446,56 @@ await this._processor.ProcessMessagesAsync( It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task ProcessMessagesAsync_MatchesAnyLocationInScope_WhenMatchingLocationFirstAsync() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + + var psResponse = new ProtectionScopesResponse + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123"), + new("microsoft.graph.policyLocationApplication", "other-app-456") + ], + ExecutionMode = ExecutionMode.EvaluateInline, + PolicyActions = + [ + new() { Action = DlpAction.BlockAccess } + ] + } + ] + }; + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessContentResponse { PolicyActions = [] }); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Once); + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never); + } + [Fact] public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync() { @@ -492,13 +542,14 @@ public async Task ProcessMessagesAsync_WithNoUserId_ThrowsPurviewExceptionAsync( public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAdditionalProperties_Async() { // Arrange + string userId = Guid.NewGuid().ToString(); var messages = new List { new (ChatRole.User, "Test message") { AdditionalProperties = new AdditionalPropertiesDictionary { - { "userId", "user-from-props" } + { "userId", userId } } } }; @@ -518,7 +569,153 @@ public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAdditionalProper messages, "session-123", Activity.UploadText, settings, null, CancellationToken.None); // Assert - Assert.Equal("user-from-props", result.userId); + Assert.Equal(userId, result.userId); + } + + [Fact] + public async Task ProcessMessagesAsync_IgnoresInvalidAdditionalPropertiesUserId_Async() + { + // Arrange + var messages = new List + { + new(ChatRole.User, "Test message") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "userId", "not-a-guid" } + } + } + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), settings.TenantId)) + .ReturnsAsync(tokenInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this._processor.ProcessMessagesAsync(messages, "session-123", Activity.UploadText, settings, null, CancellationToken.None)); + + Assert.Contains("No user id provided or inferred", exception.Message); + } + + [Fact] + public async Task ProcessMessagesAsync_UsesTokenUserIdBeforeMessageAdditionalProperties_Async() + { + // Arrange + string tokenUserId = Guid.NewGuid().ToString(); + string messageUserId = Guid.NewGuid().ToString(); + var messages = new List + { + new(ChatRole.User, "Test message") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "userId", messageUserId } + } + } + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = tokenUserId, ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), settings.TenantId)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] }); + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessContentResponse { PolicyActions = [] }); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, null, CancellationToken.None); + + // Assert + Assert.Equal(tokenUserId, result.userId); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.Is(request => request.UserId == tokenUserId), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_UsesTokenUserIdBeforeAuthorName_Async() + { + // Arrange + string tokenUserId = Guid.NewGuid().ToString(); + string authorUserId = Guid.NewGuid().ToString(); + var messages = new List + { + new(ChatRole.User, "Test message") + { + AuthorName = authorUserId + } + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = tokenUserId, ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), settings.TenantId)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] }); + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessContentResponse { PolicyActions = [] }); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, null, CancellationToken.None); + + // Assert + Assert.Equal(tokenUserId, result.userId); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.Is(request => request.UserId == tokenUserId), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_UsesProvidedUserId_WhenTokenUserIdIsEmptyAsync() + { + // Arrange + string providedUserId = Guid.NewGuid().ToString(); + var messages = new List + { + new(ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = string.Empty, ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), settings.TenantId)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessContentResponse { PolicyActions = [] }); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, providedUserId, CancellationToken.None); + + // Assert + Assert.Equal(providedUserId, result.userId); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.Is(request => request.UserId == providedUserId), + It.IsAny()), Times.Once); } [Fact] diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index 0a78e07605..40a53b839a 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -315,7 +315,7 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError, --- ## Notes -- **User Identification**: Provide a `user_id` per request (e.g. in `Message(..., additional_properties={"user_id": ""})`) for per-user policy scoping. If no user_id is provided, policy evaluation is skipped entirely. +- **User Identification**: When the configured credential resolves to a user token, that token's `user_id` is used for per-user policy scoping. For app-token credentials, provide a `user_id` per request (e.g. in `Message(..., additional_properties={"user_id": ""})`). If no user_id is provided or inferred, policy evaluation is skipped. - **Blocking Messages**: Can be customized via `blocked_prompt_message` and `blocked_response_message` in `PurviewSettings`. By default, they are "Prompt blocked by policy" and "Response blocked by policy" respectively. - **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses. - **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request. diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index eb949287fd..8fda0acf34 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -117,10 +117,7 @@ async def _map_messages( A tuple of (requests, resolved_user_id) """ results: list[ProcessContentRequest] = [] - token_info = None - - if not (self._settings.get("tenant_id") and self._settings.get("purview_app_location")): - token_info = await self._client.get_user_info_from_token(tenant_id=self._settings.get("tenant_id")) + token_info = await self._client.get_user_info_from_token(tenant_id=self._settings.get("tenant_id")) tenant_id = (token_info or {}).get("tenant_id") or self._settings.get("tenant_id") if not tenant_id or not _is_valid_guid(tenant_id): @@ -128,6 +125,9 @@ async def _map_messages( resolved_user_id = (token_info or {}).get("user_id") resolved_author_name = None + if not resolved_user_id: + resolved_user_id = provided_user_id if provided_user_id and _is_valid_guid(provided_user_id) else None + if not resolved_user_id: for m in messages: if m.additional_properties: @@ -141,9 +141,6 @@ async def _map_messages( if not resolved_user_id and resolved_author_name: resolved_user_id = resolved_author_name - if not resolved_user_id: - resolved_user_id = provided_user_id if provided_user_id and _is_valid_guid(provided_user_id) else None - # Return empty results if user_id is empty if not resolved_user_id or not _is_valid_guid(resolved_user_id): return results, None diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 8872a72280..e147e8666a 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -471,6 +471,10 @@ async def test_map_messages_with_user_id_in_additional_properties(self, mock_cli ), ) processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [ Message( @@ -496,6 +500,10 @@ async def test_map_messages_with_provided_user_id_fallback(self, mock_client: As ), ) processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [Message(role="user", contents=["Test message"])] @@ -517,6 +525,10 @@ async def test_map_messages_returns_empty_when_no_user_id(self, mock_client: Asy ), ) processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [Message(role="user", contents=["Test message"])] @@ -632,10 +644,10 @@ async def test_user_id_from_token_when_no_other_source(self, mock_client: AsyncM mock_client.get_user_info_from_token.assert_called_once() assert user_id == "11111111-1111-1111-1111-111111111111" - async def test_user_id_from_additional_properties_takes_priority( + async def test_user_id_from_token_takes_priority_over_additional_properties( self, mock_client: AsyncMock, settings: PurviewSettings ) -> None: - """Test user_id from additional_properties takes priority over token.""" + """Test token user_id takes priority over message additional_properties.""" processor = ScopedContentProcessor(mock_client, settings) messages = [ @@ -648,15 +660,19 @@ async def test_user_id_from_additional_properties_takes_priority( requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) - # Token info should not be called since we have user_id in message - mock_client.get_user_info_from_token.assert_not_called() - assert user_id == "22222222-2222-2222-2222-222222222222" + mock_client.get_user_info_from_token.assert_called_once() + assert user_id == "11111111-1111-1111-1111-111111111111" + assert all(req.user_id == "11111111-1111-1111-1111-111111111111" for req in requests) async def test_user_id_from_author_name_as_fallback( self, mock_client: AsyncMock, settings: PurviewSettings ) -> None: """Test user_id is extracted from author_name when it's a valid GUID.""" processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [ Message( @@ -675,6 +691,10 @@ async def test_author_name_ignored_if_not_valid_guid( ) -> None: """Test author_name is ignored if it's not a valid GUID.""" processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [ Message( @@ -695,6 +715,10 @@ async def test_provided_user_id_used_as_last_resort( ) -> None: """Test provided_user_id parameter is used as last resort.""" processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [Message(role="user", contents=["Test"])] @@ -707,6 +731,10 @@ async def test_provided_user_id_used_as_last_resort( async def test_invalid_provided_user_id_ignored(self, mock_client: AsyncMock, settings: PurviewSettings) -> None: """Test invalid provided_user_id is ignored.""" processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [Message(role="user", contents=["Test"])] @@ -718,6 +746,10 @@ async def test_invalid_provided_user_id_ignored(self, mock_client: AsyncMock, se async def test_multiple_messages_same_user_id(self, mock_client: AsyncMock, settings: PurviewSettings) -> None: """Test that all messages use the same resolved user_id.""" processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [ Message( @@ -740,6 +772,10 @@ async def test_first_valid_user_id_in_messages_is_used( ) -> None: """Test that the first valid user_id found in messages is used for all.""" processor = ScopedContentProcessor(mock_client, settings) + mock_client.get_user_info_from_token.return_value = { + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_id": "12345678-1234-1234-1234-123456789012", + } messages = [ Message(role="user", contents=["First"], author_name="Not a GUID"),