Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ public async Task<ContentActivitiesResponse> 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)))
{
Expand Down
2 changes: 1 addition & 1 deletion dotnet/src/Microsoft.Agents.AI.Purview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 11 additions & 23 deletions dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ private static bool TryGetUserIdFromPayload(IEnumerable<ChatMessage> 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 _))
Expand All @@ -103,21 +104,14 @@ private static bool TryGetUserIdFromPayload(IEnumerable<ChatMessage> messages, o
private async Task<List<ProcessContentRequest>> MapMessageToPCRequestsAsync(IEnumerable<ChatMessage> messages, string? sessionId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken)
{
List<ProcessContentRequest> 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();
Expand Down Expand Up @@ -166,18 +160,12 @@ private async Task<List<ProcessContentRequest>> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,56 @@ await this._processor.ProcessMessagesAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task ProcessMessagesAsync_MatchesAnyLocationInScope_WhenMatchingLocationFirstAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
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<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);

this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Never);
}

[Fact]
public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync()
{
Expand Down Expand Up @@ -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<ChatMessage>
{
new (ChatRole.User, "Test message")
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
{ "userId", "user-from-props" }
{ "userId", userId }
}
}
};
Expand All @@ -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<ChatMessage>
{
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<CancellationToken>(), settings.TenantId))
.ReturnsAsync(tokenInfo);

// Act & Assert
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
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<ChatMessage>
{
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<CancellationToken>(), settings.TenantId))
.ReturnsAsync(tokenInfo);

this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);

this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ProcessContentRequest>(request => request.UserId == tokenUserId),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task ProcessMessagesAsync_UsesTokenUserIdBeforeAuthorName_Async()
{
// Arrange
string tokenUserId = Guid.NewGuid().ToString();
string authorUserId = Guid.NewGuid().ToString();
var messages = new List<ChatMessage>
{
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<CancellationToken>(), settings.TenantId))
.ReturnsAsync(tokenInfo);

this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);

this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ProcessContentRequest>(request => request.UserId == tokenUserId),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task ProcessMessagesAsync_UsesProvidedUserId_WhenTokenUserIdIsEmptyAsync()
{
// Arrange
string providedUserId = Guid.NewGuid().ToString();
var messages = new List<ChatMessage>
{
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<CancellationToken>(), settings.TenantId))
.ReturnsAsync(tokenInfo);

this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);

this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.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<ProcessContentRequest>(request => request.UserId == providedUserId),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion python/packages/purview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<guid>"})`) 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": "<guid>"})`). 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.
Expand Down
11 changes: 4 additions & 7 deletions python/packages/purview/agent_framework_purview/_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,17 @@ 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):
raise ValueError("Tenant id required or must be inferable from credential")

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:
Expand All @@ -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
Expand Down
Loading
Loading