1use agent_framework_core::error::{Error, Result};
14use agent_framework_core::types::Message;
15
16use crate::client::PurviewClient;
17use crate::models::{
18 Activity, ActivityMetadata, ContentToProcess, DeviceMetadata, IntegratedAppMetadata,
19 ProcessContentRequest, ProcessConversationMetadata, ProtectedAppMetadata,
20};
21use crate::settings::PurviewSettings;
22
23fn is_valid_guid(value: &str) -> bool {
26 uuid::Uuid::parse_str(value).is_ok()
27}
28
29pub fn resolve_user_id(messages: &[Message], provided: Option<&str>) -> Option<String> {
45 let mut author_name_fallback: Option<String> = None;
46 for message in messages {
47 if let Some(user_id) = message
48 .additional_properties
49 .get("user_id")
50 .and_then(serde_json::Value::as_str)
51 {
52 if is_valid_guid(user_id) {
53 return Some(user_id.to_string());
54 }
55 }
56 if author_name_fallback.is_none() {
57 if let Some(name) = &message.author_name {
58 if is_valid_guid(name) {
59 author_name_fallback = Some(name.clone());
60 }
61 }
62 }
63 }
64 author_name_fallback.or_else(|| provided.filter(|p| is_valid_guid(p)).map(str::to_string))
65}
66
67fn build_request(
72 message: &Message,
73 user_id: &str,
74 tenant_id: &str,
75 app_name: &str,
76 app_location: &crate::models::PolicyLocation,
77) -> ProcessContentRequest {
78 let message_id = message
79 .message_id
80 .clone()
81 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
82 let entry = ProcessConversationMetadata::new(
83 message_id.clone(),
84 message.text(),
85 format!("Agent Framework Message {message_id}"),
86 );
87 let content_to_process = ContentToProcess {
88 content_entries: vec![entry],
89 activity_metadata: ActivityMetadata {
94 activity: Activity::UploadText,
95 },
96 device_metadata: DeviceMetadata::default(),
97 integrated_app_metadata: IntegratedAppMetadata {
98 name: app_name.to_string(),
99 version: "1.0".to_string(),
100 },
101 protected_app_metadata: ProtectedAppMetadata {
102 name: app_name.to_string(),
103 version: "1.0".to_string(),
104 application_location: app_location.clone(),
105 },
106 };
107 ProcessContentRequest {
108 content_to_process,
109 user_id: user_id.to_string(),
110 tenant_id: tenant_id.to_string(),
111 correlation_id: Some(uuid::Uuid::new_v4().to_string()),
112 }
113}
114
115pub struct ContentProcessor {
119 client: PurviewClient,
120}
121
122impl ContentProcessor {
123 pub fn new(client: PurviewClient) -> Self {
124 Self { client }
125 }
126
127 pub async fn evaluate(
144 &self,
145 messages: &[Message],
146 settings: &PurviewSettings,
147 provided_user_id: Option<&str>,
148 ) -> Result<(bool, Option<String>)> {
149 let tenant_id = settings.tenant_id.as_deref().ok_or_else(|| {
150 Error::Configuration(
151 "PurviewSettings::tenant_id is required (this port infers it from neither a \
152 protectionScopes precheck nor the bearer token's JWT claims)"
153 .into(),
154 )
155 })?;
156 if !is_valid_guid(tenant_id) {
157 return Err(Error::Configuration(format!(
158 "PurviewSettings::tenant_id '{tenant_id}' is not a valid GUID"
159 )));
160 }
161 let app_location = settings
162 .purview_app_location
163 .as_ref()
164 .ok_or_else(|| {
165 Error::Configuration(
166 "PurviewSettings::purview_app_location is required (this port infers it \
167 from neither a protectionScopes precheck nor the bearer token's JWT \
168 claims)"
169 .into(),
170 )
171 })?
172 .to_policy_location();
173
174 let Some(user_id) = resolve_user_id(messages, provided_user_id) else {
175 return Ok((false, None));
178 };
179
180 for message in messages {
181 let request = build_request(
182 message,
183 &user_id,
184 tenant_id,
185 &settings.app_name,
186 &app_location,
187 );
188 let response = self.client.process_content(&request).await?;
189 if response.should_block() {
190 return Ok((true, Some(user_id)));
191 }
192 }
193 Ok((false, Some(user_id)))
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use agent_framework_core::types::Role;
201 use std::collections::HashMap;
202
203 fn msg_with_user_id(text: &str, user_id: &str) -> Message {
204 let mut m = Message::user(text);
205 let mut props = HashMap::new();
206 props.insert("user_id".to_string(), serde_json::json!(user_id));
207 m.additional_properties = props;
208 m
209 }
210
211 fn msg_with_author(text: &str, author_name: &str) -> Message {
212 Message::new(Role::user(), text).with_author(author_name)
213 }
214
215 #[test]
218 fn is_valid_guid_accepts_well_formed_guids() {
219 assert!(is_valid_guid("12345678-1234-1234-1234-123456789012"));
220 assert!(is_valid_guid("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"));
221 }
222
223 #[test]
224 fn is_valid_guid_rejects_garbage() {
225 assert!(!is_valid_guid("not-a-guid"));
226 assert!(!is_valid_guid(""));
227 }
228
229 #[test]
232 fn resolve_user_id_prefers_additional_properties_user_id() {
233 let guid = "12345678-1234-1234-1234-123456789012";
234 let messages = vec![msg_with_user_id("hi", guid)];
235 assert_eq!(resolve_user_id(&messages, None).as_deref(), Some(guid));
236 }
237
238 #[test]
239 fn resolve_user_id_falls_back_to_guid_shaped_author_name() {
240 let guid = "12345678-1234-1234-1234-123456789012";
241 let messages = vec![msg_with_author("hi", guid)];
242 assert_eq!(resolve_user_id(&messages, None).as_deref(), Some(guid));
243 }
244
245 #[test]
246 fn resolve_user_id_prefers_explicit_user_id_over_author_name_fallback() {
247 let author_guid = "11111111-1111-1111-1111-111111111111";
248 let user_id_guid = "22222222-2222-2222-2222-222222222222";
249 let messages = vec![
250 msg_with_author("first", author_guid),
251 msg_with_user_id("second", user_id_guid),
252 ];
253 assert_eq!(
254 resolve_user_id(&messages, None).as_deref(),
255 Some(user_id_guid)
256 );
257 }
258
259 #[test]
260 fn resolve_user_id_falls_back_to_provided_when_nothing_in_messages() {
261 let guid = "33333333-3333-3333-3333-333333333333";
262 let messages = vec![Message::user("hi")];
263 assert_eq!(
264 resolve_user_id(&messages, Some(guid)).as_deref(),
265 Some(guid)
266 );
267 }
268
269 #[test]
270 fn resolve_user_id_ignores_non_guid_provided_fallback() {
271 let messages = vec![Message::user("hi")];
272 assert!(resolve_user_id(&messages, Some("not-a-guid")).is_none());
273 }
274
275 #[test]
276 fn resolve_user_id_none_when_nothing_resolvable() {
277 let messages = vec![Message::user("hi"), Message::assistant("there")];
278 assert!(resolve_user_id(&messages, None).is_none());
279 }
280
281 #[test]
282 fn resolve_user_id_ignores_non_guid_user_id_property() {
283 let messages = vec![msg_with_user_id("hi", "not-a-guid")];
284 assert!(resolve_user_id(&messages, None).is_none());
285 }
286
287 #[tokio::test]
290 async fn evaluate_fails_without_tenant_id() {
291 let settings = PurviewSettings::new("App").with_purview_app_location(
292 crate::settings::PurviewAppLocation::new(
293 crate::settings::PurviewLocationType::Application,
294 "app-1",
295 ),
296 );
297 let processor = ContentProcessor::new(PurviewClient::new(
298 crate::auth::StaticTokenProvider::new("t"),
299 &settings,
300 ));
301 let err = processor
302 .evaluate(&[Message::user("hi")], &settings, None)
303 .await
304 .unwrap_err();
305 assert!(err.to_string().contains("tenant_id"));
306 }
307
308 #[tokio::test]
309 async fn evaluate_fails_without_app_location() {
310 let settings =
311 PurviewSettings::new("App").with_tenant_id("12345678-1234-1234-1234-123456789012");
312 let processor = ContentProcessor::new(PurviewClient::new(
313 crate::auth::StaticTokenProvider::new("t"),
314 &settings,
315 ));
316 let err = processor
317 .evaluate(&[Message::user("hi")], &settings, None)
318 .await
319 .unwrap_err();
320 assert!(err.to_string().contains("purview_app_location"));
321 }
322
323 #[tokio::test]
324 async fn evaluate_returns_allow_without_any_network_call_when_no_user_id_resolvable() {
325 let settings = PurviewSettings::new("App")
329 .with_tenant_id("12345678-1234-1234-1234-123456789012")
330 .with_purview_app_location(crate::settings::PurviewAppLocation::new(
331 crate::settings::PurviewLocationType::Application,
332 "app-1",
333 ));
334 let processor = ContentProcessor::new(PurviewClient::new(
335 crate::auth::StaticTokenProvider::new("t"),
336 &settings,
337 ));
338 let (should_block, user_id) = processor
339 .evaluate(
340 &[Message::user("hi, no identifying info here")],
341 &settings,
342 None,
343 )
344 .await
345 .unwrap();
346 assert!(!should_block);
347 assert!(user_id.is_none());
348 }
349}