chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,27 @@
# Copyright (c) Microsoft. All rights reserved.
from ._cache import CacheProvider
from ._exceptions import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
)
from ._middleware import PurviewChatPolicyMiddleware, PurviewPolicyMiddleware
from ._settings import PurviewAppLocation, PurviewLocationType, PurviewSettings, get_purview_scopes
__all__ = [
"CacheProvider",
"PurviewAppLocation",
"PurviewAuthenticationError",
"PurviewChatPolicyMiddleware",
"PurviewLocationType",
"PurviewPaymentRequiredError",
"PurviewPolicyMiddleware",
"PurviewRateLimitError",
"PurviewRequestError",
"PurviewServiceError",
"PurviewSettings",
"get_purview_scopes",
]
@@ -0,0 +1,188 @@
# Copyright (c) Microsoft. All rights reserved.
"""Cache provider for Purview data."""
import hashlib
import heapq
import json
import sys
import time
from typing import Any, Protocol
from ._models import ProtectionScopesRequest
class CacheProvider(Protocol):
"""Protocol for cache providers used by Purview integration."""
async def get(self, key: str) -> Any | None:
"""Get a value from the cache.
Args:
key: The cache key.
Returns:
The cached value or None if not found or expired.
"""
...
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
"""Set a value in the cache.
Args:
key: The cache key.
value: The value to cache.
ttl_seconds: Time to live in seconds. If None, uses provider default.
"""
...
async def remove(self, key: str) -> None:
"""Remove a value from the cache.
Args:
key: The cache key.
"""
...
class InMemoryCacheProvider:
"""Simple in-memory cache implementation for Purview data.
This implementation uses a dictionary with expiration tracking and size limits.
"""
def __init__(self, default_ttl_seconds: int = 1800, max_size_bytes: int = 200 * 1024 * 1024):
"""Initialize the in-memory cache.
Args:
default_ttl_seconds: Default time to live in seconds (default 1800 = 30 minutes).
max_size_bytes: Maximum cache size in bytes (default 200MB).
"""
self._cache: dict[str, tuple[Any, float, int]] = {} # key -> (value, expiry, size)
self._expiry_heap: list[tuple[float, str]] = [] # min-heap of (expiry_time, key)
self._default_ttl = default_ttl_seconds
self._max_size_bytes = max_size_bytes
self._current_size_bytes = 0
def _estimate_size(self, value: Any) -> int:
"""Estimate the size of a cached value in bytes.
Args:
value: The value to estimate size for.
Returns:
Estimated size in bytes.
"""
try:
if hasattr(value, "model_dump_json"):
return len(value.model_dump_json().encode("utf-8"))
return len(json.dumps(value, default=str).encode("utf-8"))
except Exception:
# Fallback to sys.getsizeof if JSON serialization fails
try:
return sys.getsizeof(value)
except Exception:
# Conservative fallback estimate
return 1024
def _evict_if_needed(self, required_size: int) -> None:
"""Evict oldest entries if needed to make room for new entry.
Uses a min-heap to efficiently find and evict entries with earliest expiry times.
Also cleans up stale heap entries for keys that no longer exist in cache.
Args:
required_size: Size in bytes needed for new entry.
"""
if self._current_size_bytes + required_size <= self._max_size_bytes:
return
while self._expiry_heap and self._current_size_bytes + required_size > self._max_size_bytes:
expiry_time, key = heapq.heappop(self._expiry_heap)
if key in self._cache:
_, cached_expiry, size = self._cache[key]
if cached_expiry == expiry_time:
del self._cache[key]
self._current_size_bytes -= size
# else: stale heap entry, already updated/removed, skip it
async def get(self, key: str) -> Any | None:
"""Get a value from the cache.
Args:
key: The cache key.
Returns:
The cached value or None if not found or expired.
"""
if key not in self._cache:
return None
value, expiry, size = self._cache[key]
if time.time() > expiry:
del self._cache[key]
self._current_size_bytes -= size
return None
return value
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
"""Set a value in the cache.
Args:
key: The cache key.
value: The value to cache.
ttl_seconds: Time to live in seconds. If None, uses default TTL.
"""
ttl = ttl_seconds if ttl_seconds is not None else self._default_ttl
expiry = time.time() + ttl
size = self._estimate_size(value)
# Remove old entry if exists
if key in self._cache:
old_size = self._cache[key][2]
self._current_size_bytes -= old_size
# Evict if needed
self._evict_if_needed(size)
self._cache[key] = (value, expiry, size)
self._current_size_bytes += size
heapq.heappush(self._expiry_heap, (expiry, key))
async def remove(self, key: str) -> None:
"""Remove a value from the cache.
Args:
key: The cache key.
"""
entry = self._cache.pop(key, None)
if entry is not None:
self._current_size_bytes -= entry[2]
def create_protection_scopes_cache_key(request: ProtectionScopesRequest) -> str:
"""Create a cache key for a ProtectionScopesRequest.
The key is based on the serialized request content (excluding correlation_id).
Args:
request: The protection scopes request.
Returns:
A string cache key.
"""
data = request.to_dict(exclude_none=True)
for field in ["correlation_id"]:
data.pop(field, None)
json_str = json.dumps(data, sort_keys=True)
return f"purview:protection_scopes:{hashlib.sha256(json_str.encode()).hexdigest()}"
__all__ = [
"CacheProvider",
]
@@ -0,0 +1,233 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import base64
import inspect
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Any, Literal, TypeVar, Union, overload
from uuid import uuid4
import httpx
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import get_tracer
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from opentelemetry import trace
from ._exceptions import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
)
from ._models import (
ContentActivitiesRequest,
ContentActivitiesResponse,
ProcessContentRequest,
ProcessContentResponse,
ProtectionScopesRequest,
ProtectionScopesResponse,
)
from ._settings import PurviewSettings, get_purview_scopes
AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential]
AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]]
logger = logging.getLogger("agent_framework.purview")
ResponseT = TypeVar("ResponseT")
class PurviewClient:
"""Async client for calling Graph Purview endpoints.
Supports synchronous TokenCredential, asynchronous AsyncTokenCredential,
or callable token providers. A sync credential will be invoked in a thread
to avoid blocking the event loop.
"""
def __init__(
self,
credential: AzureCredentialTypes | AzureTokenProvider,
settings: PurviewSettings,
*,
timeout: float | None = 10.0,
):
self._credential: AzureCredentialTypes | AzureTokenProvider = credential
self._settings = settings
self._graph_uri = (settings.get("graph_base_uri") or "https://graph.microsoft.com/v1.0/").rstrip("/")
self._timeout = timeout
self._client = httpx.AsyncClient(timeout=timeout)
async def close(self) -> None:
await self._client.aclose()
async def _get_token(self, *, tenant_id: str | None = None) -> str:
"""Acquire an access token using either async or sync credential, or callable token provider."""
cred = self._credential
# Callable token provider — returns a token string directly
if callable(cred) and not isinstance(cred, (TokenCredential, AsyncTokenCredential)):
result = cred()
return await result if inspect.isawaitable(result) else result
scopes = get_purview_scopes(self._settings)
token = cred.get_token(*scopes, tenant_id=tenant_id)
token = await token if inspect.isawaitable(token) else token
return token.token
@staticmethod
def _extract_token_info(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2:
raise ValueError("Invalid JWT token format")
payload = parts[1]
rem = len(payload) % 4
if rem:
payload += "=" * (4 - rem)
decoded = base64.urlsafe_b64decode(payload)
data = json.loads(decoded.decode("utf-8"))
return {
"user_id": data.get("oid") if data.get("idtyp") == "user" else None,
"tenant_id": data.get("tid"),
"client_id": data.get("appid"),
}
async def get_user_info_from_token(self, *, tenant_id: str | None = None) -> dict[str, Any]:
token = await self._get_token(tenant_id=tenant_id)
return self._extract_token_info(token)
async def process_content(self, request: ProcessContentRequest) -> ProcessContentResponse:
with get_tracer().start_as_current_span("purview.process_content"):
token = await self._get_token(tenant_id=request.tenant_id)
url = f"{self._graph_uri}/users/{request.user_id}/dataSecurityAndGovernance/processContent"
headers: dict[str, str] = {}
# Add If-None-Match header if scope_identifier is present
if hasattr(request, "scope_identifier") and request.scope_identifier:
headers["If-None-Match"] = request.scope_identifier
# Add Prefer: evaluateInline header if process_inline is True
if hasattr(request, "process_inline") and request.process_inline:
headers["Prefer"] = "evaluateInline"
response: ProcessContentResponse | tuple[ProcessContentResponse, httpx.Headers] = await self._post(
url, request, ProcessContentResponse, token, headers=headers, return_response=True
)
if isinstance(response, tuple) and len(response) == 2:
response_obj, _ = response
return response_obj
return response
async def get_protection_scopes(self, request: ProtectionScopesRequest) -> ProtectionScopesResponse:
with get_tracer().start_as_current_span("purview.get_protection_scopes"):
token = await self._get_token()
url = f"{self._graph_uri}/users/{request.user_id}/dataSecurityAndGovernance/protectionScopes/compute"
response: ProtectionScopesResponse | tuple[ProtectionScopesResponse, httpx.Headers] = await self._post(
url, request, ProtectionScopesResponse, token, return_response=True
)
# Extract etag from response headers
if isinstance(response, tuple) and len(response) == 2:
response_obj, headers = response
if "etag" in headers:
etag_value = headers["etag"].strip('"')
response_obj.scope_identifier = etag_value
return response_obj
return response
async def send_content_activities(self, request: ContentActivitiesRequest) -> ContentActivitiesResponse:
with get_tracer().start_as_current_span("purview.send_content_activities"):
token = await self._get_token()
url = f"{self._graph_uri}/users/{request.user_id}/dataSecurityAndGovernance/activities/contentActivities"
return await self._post(url, request, ContentActivitiesResponse, token)
@overload
async def _post(
self,
url: str,
model: Any,
response_type: type[ResponseT],
token: str,
headers: dict[str, str] | None = None,
return_response: Literal[False] = False,
) -> ResponseT: ...
@overload
async def _post(
self,
url: str,
model: Any,
response_type: type[ResponseT],
token: str,
headers: dict[str, str] | None = None,
return_response: Literal[True] = True,
) -> tuple[ResponseT, httpx.Headers]: ...
async def _post(
self,
url: str,
model: Any,
response_type: type[ResponseT],
token: str,
headers: dict[str, str] | None = None,
return_response: bool = False,
) -> ResponseT | tuple[ResponseT, httpx.Headers]:
if hasattr(model, "correlation_id") and not model.correlation_id:
model.correlation_id = str(uuid4())
correlation_id = getattr(model, "correlation_id", None)
if correlation_id:
span = trace.get_current_span()
if span and span.is_recording():
span.set_attribute("correlation_id", correlation_id)
logger.info(f"Purview request to {url} with correlation_id: {correlation_id}")
payload = model.model_dump(by_alias=True, exclude_none=True, mode="json")
request_headers = {
"Authorization": f"Bearer {token}",
"User-Agent": get_user_agent(),
"Content-Type": "application/json",
}
if correlation_id:
request_headers["client-request-id"] = correlation_id
if headers:
request_headers.update(headers)
resp = await self._client.post(url, json=payload, headers=request_headers)
if resp.status_code in (401, 403):
raise PurviewAuthenticationError(f"Auth failure {resp.status_code}: {resp.text}")
if resp.status_code == 402:
if self._settings.get("ignore_payment_required", False):
return response_type()
raise PurviewPaymentRequiredError(f"Payment required {resp.status_code}: {resp.text}")
if resp.status_code == 429:
raise PurviewRateLimitError(f"Rate limited {resp.status_code}: {resp.text}")
if resp.status_code not in (200, 201, 202):
raise PurviewRequestError(f"Purview request failed {resp.status_code}: {resp.text}")
try:
data = resp.json()
except ValueError:
data = {}
try:
# Prefer pydantic-style model_validate if present, else fall back to constructor.
model_validate = getattr(response_type, "model_validate", None)
response_obj = model_validate(data) if callable(model_validate) else response_type(**data)
# Extract correlation_id from response headers if response object supports it
if "client-request-id" in resp.headers and hasattr(response_obj, "correlation_id"):
response_correlation_id = resp.headers["client-request-id"]
response_obj.correlation_id = response_correlation_id # pyright: ignore[reportAttributeAccessIssue]
logger.info(f"Purview response from {url} with correlation_id: {response_correlation_id}")
typed_response_obj = response_obj if isinstance(response_obj, response_type) else response_type(**data)
if return_response:
return (typed_response_obj, resp.headers)
return typed_response_obj
except Exception as ex:
raise PurviewServiceError(f"Failed to deserialize Purview response: {ex}") from ex
@@ -0,0 +1,32 @@
# Copyright (c) Microsoft. All rights reserved.
"""Purview specific exceptions mapped to the Integration exception hierarchy."""
from agent_framework.exceptions import IntegrationException, IntegrationInvalidAuthException
__all__ = [
"PurviewAuthenticationError",
"PurviewPaymentRequiredError",
"PurviewRateLimitError",
"PurviewRequestError",
"PurviewServiceError",
]
class PurviewServiceError(IntegrationException):
"""Base exception for Purview errors."""
class PurviewAuthenticationError(IntegrationInvalidAuthException):
"""Authentication / authorization failure (401/403)."""
class PurviewPaymentRequiredError(PurviewServiceError):
"""Payment required (402)."""
class PurviewRateLimitError(PurviewServiceError):
"""Rate limiting or throttling (429)."""
class PurviewRequestError(PurviewServiceError):
"""Other non-success HTTP errors."""
@@ -0,0 +1,249 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Awaitable, Callable
from typing import Union
from agent_framework import AgentContext, AgentMiddleware, ChatContext, ChatMiddleware, MiddlewareTermination
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from ._cache import CacheProvider
from ._client import PurviewClient
from ._exceptions import PurviewPaymentRequiredError
from ._models import Activity
from ._processor import ScopedContentProcessor
from ._settings import PurviewSettings
AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential]
AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]]
logger = logging.getLogger("agent_framework.purview")
class PurviewPolicyMiddleware(AgentMiddleware):
"""Agent middleware that enforces Purview policies on prompt and response.
Accepts a TokenCredential, AsyncTokenCredential, or callable token provider.
Usage:
.. code-block:: python
from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings
from agent_framework import Agent
credential = ... # TokenCredential, AsyncTokenCredential, or callable
settings = PurviewSettings(app_name="My App")
agent = Agent(client=client, instructions="...", middleware=[PurviewPolicyMiddleware(credential, settings)])
"""
def __init__(
self,
credential: AzureCredentialTypes | AzureTokenProvider,
settings: PurviewSettings,
cache_provider: CacheProvider | None = None,
) -> None:
self._client = PurviewClient(credential, settings)
self._processor = ScopedContentProcessor(self._client, settings, cache_provider)
self._settings = settings
@staticmethod
def _get_agent_session_id(context: AgentContext) -> str | None:
"""Resolve a session/conversation id from the agent run context.
Resolution order:
1. session.service_session_id
2. First message whose additional_properties contains 'conversation_id'
3. None: the downstream processor will generate a new UUID
"""
if context.session:
service_session_id = context.session.service_session_id
if isinstance(service_session_id, str) and service_session_id:
return service_session_id
for message in context.messages:
conversation_id = message.additional_properties.get("conversation_id")
if conversation_id is not None:
return str(conversation_id)
return None
async def process(
self,
context: AgentContext,
call_next: Callable[[], Awaitable[None]],
) -> None:
resolved_user_id: str | None = None
session_id: str | None = None
try:
# Pre (prompt) check
session_id = self._get_agent_session_id(context)
should_block_prompt, resolved_user_id = await self._processor.process_messages(
context.messages, Activity.UPLOAD_TEXT, session_id=session_id
)
if should_block_prompt:
from agent_framework import AgentResponse, Message
msg = self._settings.get("blocked_prompt_message", None) or "Prompt blocked by policy"
context.result = AgentResponse(
messages=[
Message(
role="system",
contents=[msg],
)
]
)
raise MiddlewareTermination
except MiddlewareTermination:
raise
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy pre-check: {ex}")
if not self._settings.get("ignore_exceptions", False):
raise
await call_next()
try:
# Post (response) check only if we have a normal AgentResponse
# Use the same user_id from the request for the response evaluation
session_id_response = self._get_agent_session_id(context)
if session_id_response is None:
session_id_response = session_id
if context.result and not context.stream:
should_block_response, _ = await self._processor.process_messages(
context.result.messages, # type: ignore[union-attr]
Activity.DOWNLOAD_TEXT,
session_id=session_id_response,
user_id=resolved_user_id,
)
if should_block_response:
from agent_framework import AgentResponse, Message
msg = self._settings.get("blocked_response_message", None) or "Response blocked by policy"
context.result = AgentResponse(
messages=[
Message(
role="system",
contents=[msg],
)
]
)
else:
# Streaming responses are not supported for post-checks
logger.debug("Streaming responses are not supported for Purview policy post-checks")
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy post-check: {ex}")
if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy post-check: {ex}")
if not self._settings.get("ignore_exceptions", False):
raise
class PurviewChatPolicyMiddleware(ChatMiddleware):
"""Chat middleware variant for Purview policy evaluation.
This allows users to attach Purview enforcement directly to a chat client
Behavior:
* Pre-chat: evaluates outgoing (user + context) messages as an upload activity
and can terminate execution if blocked.
* Post-chat: evaluates the received response messages (streaming is not presently supported)
and can replace them with a blocked message. Uses the same user_id from the request
to ensure consistent user identity throughout the evaluation.
Usage:
.. code-block:: python
from agent_framework.microsoft import PurviewChatPolicyMiddleware, PurviewSettings
from agent_framework import ChatClient
credential = ... # TokenCredential, AsyncTokenCredential, or callable
settings = PurviewSettings(app_name="My App")
client = ChatClient(..., middleware=[PurviewChatPolicyMiddleware(credential, settings)])
"""
def __init__(
self,
credential: AzureCredentialTypes | AzureTokenProvider,
settings: PurviewSettings,
cache_provider: CacheProvider | None = None,
) -> None:
self._client = PurviewClient(credential, settings)
self._processor = ScopedContentProcessor(self._client, settings, cache_provider)
self._settings = settings
async def process(
self,
context: ChatContext,
call_next: Callable[[], Awaitable[None]],
) -> None:
resolved_user_id: str | None = None
session_id: str | None = None
try:
session_id = context.options.get("conversation_id") if context.options else None
should_block_prompt, resolved_user_id = await self._processor.process_messages(
context.messages, Activity.UPLOAD_TEXT, session_id=session_id
)
if should_block_prompt:
from agent_framework import ChatResponse, Message
blocked_message = Message(
role="system",
contents=[self._settings.get("blocked_prompt_message", None) or "Prompt blocked by policy"],
)
context.result = ChatResponse(messages=[blocked_message])
raise MiddlewareTermination
except MiddlewareTermination:
raise
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy pre-check: {ex}")
if not self._settings.get("ignore_exceptions", False):
raise
await call_next()
try:
# Post (response) evaluation only if non-streaming and we have messages result shape
# Use the same user_id from the request for the response evaluation
session_id_response = context.options.get("conversation_id") if context.options else None
if session_id_response is None:
session_id_response = session_id
if context.result and not context.stream:
result_obj = context.result
messages = getattr(result_obj, "messages", None)
if messages:
should_block_response, _ = await self._processor.process_messages(
messages, Activity.DOWNLOAD_TEXT, session_id=session_id_response, user_id=resolved_user_id
)
if should_block_response:
from agent_framework import ChatResponse, Message
blocked_message = Message(
role="system",
contents=[
self._settings.get("blocked_response_message", None) or "Response blocked by policy"
],
)
context.result = ChatResponse(messages=[blocked_message])
else:
logger.debug("Streaming responses are not supported for Purview policy post-checks")
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy post-check: {ex}")
if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy post-check: {ex}")
if not self._settings.get("ignore_exceptions", False):
raise
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,388 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import time
import uuid
from collections.abc import Iterable, MutableMapping
from typing import Any
from agent_framework import Message
from ._cache import CacheProvider, InMemoryCacheProvider, create_protection_scopes_cache_key
from ._client import PurviewClient
from ._exceptions import PurviewPaymentRequiredError
from ._models import (
Activity,
ActivityMetadata,
ContentActivitiesRequest,
ContentToProcess,
DeviceMetadata,
DlpAction,
DlpActionInfo,
ExecutionMode,
IntegratedAppMetadata,
OperatingSystemSpecifications,
PolicyLocation,
ProcessContentRequest,
ProcessContentResponse,
ProcessConversationMetadata,
ProtectedAppMetadata,
ProtectionScopesRequest,
ProtectionScopesResponse,
ProtectionScopeState,
PurviewTextContent,
RestrictionAction,
translate_activity,
)
from ._settings import PurviewSettings
logger = logging.getLogger("agent_framework.purview")
def _is_valid_guid(value: str | None) -> bool:
"""Check if a string is a valid GUID/UUID format using uuid module."""
if not value:
return False
try:
uuid.UUID(value)
return True
except (ValueError, AttributeError):
return False
class ScopedContentProcessor:
"""Combine protection scopes, process content, and content activities logic."""
def __init__(self, client: PurviewClient, settings: PurviewSettings, cache_provider: CacheProvider | None = None):
self._client = client
self._settings = settings
cache_ttl = settings.get("cache_ttl_seconds")
max_cache = settings.get("max_cache_size_bytes")
self._cache: CacheProvider = cache_provider or InMemoryCacheProvider(
default_ttl_seconds=cache_ttl if cache_ttl is not None else 14400,
max_size_bytes=max_cache if max_cache is not None else 200 * 1024 * 1024,
)
self._background_tasks: set[asyncio.Task[Any]] = set()
async def process_messages(
self,
messages: Iterable[Message],
activity: Activity,
session_id: str | None = None,
user_id: str | None = None,
) -> tuple[bool, str | None]:
"""Process messages for policy evaluation.
Args:
messages: The messages to process
activity: The activity type (e.g., UPLOAD_TEXT)
session_id: Optional session/conversation id. Else, a new GUID is generated.
user_id: Optional user_id to use for all messages. If provided, this is the fallback.
Returns:
A tuple of (should_block: bool, resolved_user_id: str | None).
The resolved_user_id can be stored and passed back when processing the response
to ensure the same user context is maintained throughout the request/response cycle.
"""
pc_requests, resolved_user_id = await self._map_messages(messages, activity, session_id, user_id)
should_block = False
for req in pc_requests:
resp = await self._process_with_scopes(req)
if resp.policy_actions:
for act in resp.policy_actions:
if act.action == DlpAction.BLOCK_ACCESS or act.restriction_action == RestrictionAction.BLOCK:
should_block = True
break
if should_block:
break
return should_block, resolved_user_id
async def _map_messages(
self,
messages: Iterable[Message],
activity: Activity,
session_id: str | None = None,
provided_user_id: str | None = None,
) -> tuple[list[ProcessContentRequest], str | None]:
"""Map messages to ProcessContentRequests.
Args:
messages: The messages to map
activity: The activity type
session_id: Optional session/conversation id to use for correlation
provided_user_id: Optional user_id to use. If provided, this is the fallback.
Returns:
A tuple of (requests, resolved_user_id)
"""
results: list[ProcessContentRequest] = []
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:
potential_user_id = m.additional_properties.get("user_id")
if _is_valid_guid(potential_user_id):
resolved_user_id = potential_user_id
break
if m.author_name and _is_valid_guid(m.author_name) and not resolved_author_name:
resolved_author_name = m.author_name
if not resolved_user_id and resolved_author_name:
resolved_user_id = resolved_author_name
# Return empty results if user_id is empty
if not resolved_user_id or not _is_valid_guid(resolved_user_id):
return results, None
for m in messages:
message_id = m.message_id or str(uuid.uuid4())
content = PurviewTextContent(data=m.text or "")
correlation_id = (session_id or str(uuid.uuid4())) + "@AF"
meta = ProcessConversationMetadata(
identifier=message_id,
content=content,
name=f"Agent Framework Message {message_id}",
is_truncated=False,
correlation_id=correlation_id,
# This would be c# ticks equivalent and needs to fit inside c# long
sequence_number=time.time_ns() // 100 + 621355968000000000,
)
activity_meta = ActivityMetadata(activity=activity)
purview_app_location = self._settings.get("purview_app_location")
if purview_app_location:
policy_location = PolicyLocation(
data_type=purview_app_location.get_policy_location()["@odata.type"],
value=purview_app_location.location_value,
)
elif token_info and token_info.get("client_id"):
policy_location = PolicyLocation(
data_type="microsoft.graph.policyLocationApplication",
value=token_info["client_id"],
)
else:
raise ValueError("App location not provided or inferable")
app_name = self._settings.get("app_name") or "Unknown"
protected_app = ProtectedAppMetadata(
name=app_name,
version=self._settings.get("app_version", "Unknown"),
application_location=policy_location,
)
integrated_app = IntegratedAppMetadata(name=app_name, version=self._settings.get("app_version", "Unknown"))
device_meta = DeviceMetadata(
operating_system_specifications=OperatingSystemSpecifications(
operating_system_platform="Unknown", operating_system_version="Unknown"
)
)
ctp = ContentToProcess(
content_entries=[meta],
activity_metadata=activity_meta,
device_metadata=device_meta,
integrated_app_metadata=integrated_app,
protected_app_metadata=protected_app,
)
req = ProcessContentRequest(
content_to_process=ctp,
user_id=resolved_user_id, # Use the resolved user_id for all messages
tenant_id=tenant_id,
correlation_id=meta.correlation_id,
process_inline=None, # Will be set based on execution mode
)
results.append(req)
return results, resolved_user_id
async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> ProcessContentResponse:
app_location = pc_request.content_to_process.protected_app_metadata.application_location
locations: list[PolicyLocation | MutableMapping[str, Any]] = [app_location] if app_location is not None else []
ps_req = ProtectionScopesRequest(
user_id=pc_request.user_id,
tenant_id=pc_request.tenant_id,
activities=translate_activity(pc_request.content_to_process.activity_metadata.activity),
locations=locations,
device_metadata=pc_request.content_to_process.device_metadata,
integrated_app_metadata=pc_request.content_to_process.integrated_app_metadata,
correlation_id=pc_request.correlation_id,
)
# Check for tenant-level 402 exception cache first
tenant_payment_cache_key = f"purview:payment_required:{pc_request.tenant_id}"
cached_payment_exception = await self._cache.get(tenant_payment_cache_key)
if isinstance(cached_payment_exception, PurviewPaymentRequiredError):
raise cached_payment_exception
cache_key = create_protection_scopes_cache_key(ps_req)
cached_ps_resp = await self._cache.get(cache_key)
if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse):
return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key)
task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return await self._call_process_content(pc_request, cache_key, dlp_actions=[])
async def _process_with_cached_scopes(
self,
pc_request: ProcessContentRequest,
ps_resp: ProtectionScopesResponse,
cache_key: str,
) -> ProcessContentResponse:
if ps_resp.scope_identifier:
pc_request.scope_identifier = ps_resp.scope_identifier
should_process, dlp_actions, execution_mode = self._check_applicable_scopes(pc_request, ps_resp)
if should_process:
# Set process_inline based on execution mode
pc_request.process_inline = execution_mode == ExecutionMode.EVALUATE_INLINE
# If execution mode is offline, queue the PC request in background
if execution_mode != ExecutionMode.EVALUATE_INLINE:
task = asyncio.create_task(self._process_content_background(pc_request, cache_key))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
return await self._call_process_content(pc_request, cache_key, dlp_actions=dlp_actions)
# No applicable scopes - send content activities in background
ca_req = ContentActivitiesRequest(
user_id=pc_request.user_id,
tenant_id=pc_request.tenant_id,
content_to_process=pc_request.content_to_process,
correlation_id=pc_request.correlation_id,
)
task = asyncio.create_task(self._send_content_activities_background(ca_req))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# Respond with HttpStatusCode 204(No Content)
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
async def _call_process_content(
self,
pc_request: ProcessContentRequest,
cache_key: str,
dlp_actions: list[DlpActionInfo],
) -> ProcessContentResponse:
pc_resp = await self._client.process_content(pc_request)
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
await self._cache.remove(cache_key)
if dlp_actions:
pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions)
return pc_resp
async def _refresh_protection_scopes_background(
self, ps_req: ProtectionScopesRequest, cache_key: str, pc_request: ProcessContentRequest
) -> None:
"""Fetch protection scopes and warm the cache without blocking the foreground call."""
ttl = self._settings.get("cache_ttl_seconds")
ttl_seconds = ttl if ttl is not None else 14400
try:
ps_resp = await self._client.get_protection_scopes(ps_req)
await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds)
should_process, _, _ = self._check_applicable_scopes(pc_request, ps_resp)
if not should_process:
ca_req = ContentActivitiesRequest(
user_id=pc_request.user_id,
tenant_id=pc_request.tenant_id,
content_to_process=pc_request.content_to_process,
correlation_id=pc_request.correlation_id,
)
await self._send_content_activities_background(ca_req)
except PurviewPaymentRequiredError as ex:
tenant_payment_cache_key = f"purview:payment_required:{ps_req.tenant_id}"
await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds)
logger.warning("Background protection scopes refresh failed with payment required: %s", ex)
except Exception as ex:
logger.warning("Background protection scopes refresh failed: %s", ex)
async def _process_content_background(self, pc_request: ProcessContentRequest, cache_key: str) -> None:
"""Process content in background for offline execution mode."""
try:
pc_resp = await self._client.process_content(pc_request)
# If protection scopes changed, invalidate cache and retry once.
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
await self._cache.remove(cache_key)
await self._client.process_content(pc_request)
except Exception as ex:
# Log errors but don't propagate since this is fire-and-forget
logger.warning(f"Background process content request failed: {ex}")
async def _send_content_activities_background(self, ca_req: ContentActivitiesRequest) -> None:
"""Send content activities in background without blocking."""
try:
await self._client.send_content_activities(ca_req)
except Exception as ex:
# Log errors but don't propagate since this is fire-and-forget
logger.warning(f"Background content activities request failed: {ex}")
@staticmethod
def _combine_policy_actions(
existing: list[DlpActionInfo] | None, new_actions: list[DlpActionInfo]
) -> list[DlpActionInfo]:
combined: dict[tuple[DlpAction | None, RestrictionAction | None], DlpActionInfo] = {}
for action_info in (existing or []) + new_actions:
combined.setdefault((action_info.action, action_info.restriction_action), action_info)
return list(combined.values())
@staticmethod
def _check_applicable_scopes(
pc_request: ProcessContentRequest, ps_response: ProtectionScopesResponse
) -> tuple[bool, list[DlpActionInfo], ExecutionMode]:
"""Check if any scopes are applicable to the request.
Args:
pc_request: The process content request
ps_response: The protection scopes response
Returns:
A tuple of (should_process, dlp_actions, execution_mode)
"""
req_activity = translate_activity(pc_request.content_to_process.activity_metadata.activity)
location = pc_request.content_to_process.protected_app_metadata.application_location
should_process: bool = False
dlp_actions: list[DlpActionInfo] = []
execution_mode: ExecutionMode = ExecutionMode.EVALUATE_OFFLINE # Default to offline
for scope in ps_response.scopes or []:
# Check if all activities in req_activity are present in scope.activities using bitwise flags.
activity_match = bool(scope.activities and (scope.activities & req_activity) == req_activity)
location_match = False
if location is not None:
for loc in scope.locations or []:
if (
loc.data_type
and location.data_type
and loc.data_type.lower().endswith(location.data_type.split(".")[-1].lower())
and loc.value == location.value
):
location_match = True
break
if activity_match and location_match:
should_process = True
# If any scope has EvaluateInline, upgrade to inline mode
if scope.execution_mode == ExecutionMode.EVALUATE_INLINE:
execution_mode = ExecutionMode.EVALUATE_INLINE
if scope.policy_actions:
dlp_actions.extend(scope.policy_actions)
return should_process, dlp_actions, execution_mode
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from enum import Enum
from pydantic import BaseModel
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
class PurviewLocationType(str, Enum):
"""The type of location for Purview policy evaluation."""
APPLICATION = "application"
URI = "uri"
DOMAIN = "domain"
class PurviewAppLocation(BaseModel):
"""Identifier representing the app's location for Purview policy evaluation."""
location_type: PurviewLocationType
location_value: str
def get_policy_location(self) -> dict[str, str]:
ns = "microsoft.graph"
if self.location_type == PurviewLocationType.APPLICATION:
dt = f"{ns}.policyLocationApplication"
elif self.location_type == PurviewLocationType.URI:
dt = f"{ns}.policyLocationUrl"
elif self.location_type == PurviewLocationType.DOMAIN:
dt = f"{ns}.policyLocationDomain"
else: # pragma: no cover - defensive
raise ValueError("Invalid Purview location type")
return {"@odata.type": dt, "value": self.location_value}
class PurviewSettings(TypedDict, total=False):
"""Settings for Purview integration mirroring .NET PurviewSettings.
Attributes:
app_name: Public app name.
app_version: Optional version string of the application.
tenant_id: Optional tenant id (guid) of the user making the request.
purview_app_location: Optional app location for policy evaluation.
graph_base_uri: Base URI for Microsoft Graph.
blocked_prompt_message: Custom message to return when a prompt is blocked by policy.
blocked_response_message: Custom message to return when a response is blocked by policy.
ignore_exceptions: If True, all Purview exceptions will be logged but not thrown in middleware.
ignore_payment_required: If True, 402 payment required errors will be logged but not thrown.
cache_ttl_seconds: Time to live for cache entries in seconds (default 14400 = 4 hours).
max_cache_size_bytes: Maximum cache size in bytes (default 200MB).
"""
app_name: str | None
app_version: str | None
tenant_id: str | None
purview_app_location: PurviewAppLocation | None
graph_base_uri: str | None
blocked_prompt_message: str | None
blocked_response_message: str | None
ignore_exceptions: bool | None
ignore_payment_required: bool | None
cache_ttl_seconds: int | None
max_cache_size_bytes: int | None
def get_purview_scopes(settings: PurviewSettings) -> list[str]:
"""Get the OAuth scopes for the Purview Graph API.
Args:
settings: The Purview settings containing graph_base_uri.
Returns:
A list of OAuth scope strings.
"""
from urllib.parse import urlparse
graph_base_uri = settings.get("graph_base_uri", "https://graph.microsoft.com/v1.0/")
host = urlparse(str(graph_base_uri)).hostname or "graph.microsoft.com"
return [f"https://{host}/.default"]