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
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:
@@ -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"]
|
||||
Reference in New Issue
Block a user