use agent_framework_core::error::{Error, Result};
use agent_framework_core::types::Message;
use crate::client::PurviewClient;
use crate::models::{
Activity, ActivityMetadata, ContentToProcess, DeviceMetadata, IntegratedAppMetadata,
ProcessContentRequest, ProcessConversationMetadata, ProtectedAppMetadata,
};
use crate::settings::PurviewSettings;
fn is_valid_guid(value: &str) -> bool {
uuid::Uuid::parse_str(value).is_ok()
}
pub fn resolve_user_id(messages: &[Message], provided: Option<&str>) -> Option<String> {
let mut author_name_fallback: Option<String> = None;
for message in messages {
if let Some(user_id) = message
.additional_properties
.get("user_id")
.and_then(serde_json::Value::as_str)
{
if is_valid_guid(user_id) {
return Some(user_id.to_string());
}
}
if author_name_fallback.is_none() {
if let Some(name) = &message.author_name {
if is_valid_guid(name) {
author_name_fallback = Some(name.clone());
}
}
}
}
author_name_fallback.or_else(|| provided.filter(|p| is_valid_guid(p)).map(str::to_string))
}
fn build_request(
message: &Message,
user_id: &str,
tenant_id: &str,
app_name: &str,
app_location: &crate::models::PolicyLocation,
) -> ProcessContentRequest {
let message_id = message
.message_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let entry = ProcessConversationMetadata::new(
message_id.clone(),
message.text(),
format!("Agent Framework Message {message_id}"),
);
let content_to_process = ContentToProcess {
content_entries: vec![entry],
activity_metadata: ActivityMetadata {
activity: Activity::UploadText,
},
device_metadata: DeviceMetadata::default(),
integrated_app_metadata: IntegratedAppMetadata {
name: app_name.to_string(),
version: "1.0".to_string(),
},
protected_app_metadata: ProtectedAppMetadata {
name: app_name.to_string(),
version: "1.0".to_string(),
application_location: app_location.clone(),
},
};
ProcessContentRequest {
content_to_process,
user_id: user_id.to_string(),
tenant_id: tenant_id.to_string(),
correlation_id: Some(uuid::Uuid::new_v4().to_string()),
}
}
pub struct ContentProcessor {
client: PurviewClient,
}
impl ContentProcessor {
pub fn new(client: PurviewClient) -> Self {
Self { client }
}
pub async fn evaluate(
&self,
messages: &[Message],
settings: &PurviewSettings,
provided_user_id: Option<&str>,
) -> Result<(bool, Option<String>)> {
let tenant_id = settings.tenant_id.as_deref().ok_or_else(|| {
Error::Configuration(
"PurviewSettings::tenant_id is required (this port infers it from neither a \
protectionScopes precheck nor the bearer token's JWT claims)"
.into(),
)
})?;
if !is_valid_guid(tenant_id) {
return Err(Error::Configuration(format!(
"PurviewSettings::tenant_id '{tenant_id}' is not a valid GUID"
)));
}
let app_location = settings
.purview_app_location
.as_ref()
.ok_or_else(|| {
Error::Configuration(
"PurviewSettings::purview_app_location is required (this port infers it \
from neither a protectionScopes precheck nor the bearer token's JWT \
claims)"
.into(),
)
})?
.to_policy_location();
let Some(user_id) = resolve_user_id(messages, provided_user_id) else {
return Ok((false, None));
};
for message in messages {
let request = build_request(
message,
&user_id,
tenant_id,
&settings.app_name,
&app_location,
);
let response = self.client.process_content(&request).await?;
if response.should_block() {
return Ok((true, Some(user_id)));
}
}
Ok((false, Some(user_id)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_framework_core::types::Role;
use std::collections::HashMap;
fn msg_with_user_id(text: &str, user_id: &str) -> Message {
let mut m = Message::user(text);
let mut props = HashMap::new();
props.insert("user_id".to_string(), serde_json::json!(user_id));
m.additional_properties = props;
m
}
fn msg_with_author(text: &str, author_name: &str) -> Message {
Message::new(Role::user(), text).with_author(author_name)
}
#[test]
fn is_valid_guid_accepts_well_formed_guids() {
assert!(is_valid_guid("12345678-1234-1234-1234-123456789012"));
assert!(is_valid_guid("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"));
}
#[test]
fn is_valid_guid_rejects_garbage() {
assert!(!is_valid_guid("not-a-guid"));
assert!(!is_valid_guid(""));
}
#[test]
fn resolve_user_id_prefers_additional_properties_user_id() {
let guid = "12345678-1234-1234-1234-123456789012";
let messages = vec![msg_with_user_id("hi", guid)];
assert_eq!(resolve_user_id(&messages, None).as_deref(), Some(guid));
}
#[test]
fn resolve_user_id_falls_back_to_guid_shaped_author_name() {
let guid = "12345678-1234-1234-1234-123456789012";
let messages = vec![msg_with_author("hi", guid)];
assert_eq!(resolve_user_id(&messages, None).as_deref(), Some(guid));
}
#[test]
fn resolve_user_id_prefers_explicit_user_id_over_author_name_fallback() {
let author_guid = "11111111-1111-1111-1111-111111111111";
let user_id_guid = "22222222-2222-2222-2222-222222222222";
let messages = vec![
msg_with_author("first", author_guid),
msg_with_user_id("second", user_id_guid),
];
assert_eq!(
resolve_user_id(&messages, None).as_deref(),
Some(user_id_guid)
);
}
#[test]
fn resolve_user_id_falls_back_to_provided_when_nothing_in_messages() {
let guid = "33333333-3333-3333-3333-333333333333";
let messages = vec![Message::user("hi")];
assert_eq!(
resolve_user_id(&messages, Some(guid)).as_deref(),
Some(guid)
);
}
#[test]
fn resolve_user_id_ignores_non_guid_provided_fallback() {
let messages = vec![Message::user("hi")];
assert!(resolve_user_id(&messages, Some("not-a-guid")).is_none());
}
#[test]
fn resolve_user_id_none_when_nothing_resolvable() {
let messages = vec![Message::user("hi"), Message::assistant("there")];
assert!(resolve_user_id(&messages, None).is_none());
}
#[test]
fn resolve_user_id_ignores_non_guid_user_id_property() {
let messages = vec![msg_with_user_id("hi", "not-a-guid")];
assert!(resolve_user_id(&messages, None).is_none());
}
#[tokio::test]
async fn evaluate_fails_without_tenant_id() {
let settings = PurviewSettings::new("App").with_purview_app_location(
crate::settings::PurviewAppLocation::new(
crate::settings::PurviewLocationType::Application,
"app-1",
),
);
let processor = ContentProcessor::new(PurviewClient::new(
crate::auth::StaticTokenProvider::new("t"),
&settings,
));
let err = processor
.evaluate(&[Message::user("hi")], &settings, None)
.await
.unwrap_err();
assert!(err.to_string().contains("tenant_id"));
}
#[tokio::test]
async fn evaluate_fails_without_app_location() {
let settings =
PurviewSettings::new("App").with_tenant_id("12345678-1234-1234-1234-123456789012");
let processor = ContentProcessor::new(PurviewClient::new(
crate::auth::StaticTokenProvider::new("t"),
&settings,
));
let err = processor
.evaluate(&[Message::user("hi")], &settings, None)
.await
.unwrap_err();
assert!(err.to_string().contains("purview_app_location"));
}
#[tokio::test]
async fn evaluate_returns_allow_without_any_network_call_when_no_user_id_resolvable() {
let settings = PurviewSettings::new("App")
.with_tenant_id("12345678-1234-1234-1234-123456789012")
.with_purview_app_location(crate::settings::PurviewAppLocation::new(
crate::settings::PurviewLocationType::Application,
"app-1",
));
let processor = ContentProcessor::new(PurviewClient::new(
crate::auth::StaticTokenProvider::new("t"),
&settings,
));
let (should_block, user_id) = processor
.evaluate(
&[Message::user("hi, no identifying info here")],
&settings,
None,
)
.await
.unwrap();
assert!(!should_block);
assert!(user_id.is_none());
}
}