Skip to main content

agent_framework_purview/
processor.rs

1//! [`ContentProcessor`]: maps [`Message`]s to `processContent` requests,
2//! resolves the acting user id, and evaluates the resulting verdicts.
3//!
4//! A scoped-down port of Python's `ScopedContentProcessor` — see
5//! [`crate::client`]'s module docs for exactly what's cut (the protection-
6//! scopes precheck, caching, and background content-activity logging) and
7//! why. What *is* ported faithfully: per-message request construction
8//! (the internal `build_request`) and the GUID-based user-id resolution algorithm
9//! (mirrors `ScopedContentProcessor._map_messages`'s
10//! `additional_properties["user_id"]` / `author_name` scan, minus the
11//! bearer-token-JWT fallback — see the crate docs).
12
13use 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
23/// Validate a string as a GUID/UUID, mirroring Python's `_is_valid_guid`
24/// (`uuid.UUID(value)` succeeding).
25fn is_valid_guid(value: &str) -> bool {
26    uuid::Uuid::parse_str(value).is_ok()
27}
28
29/// Resolve the acting user id for a batch of messages, mirroring
30/// `ScopedContentProcessor._map_messages`'s resolution order (minus the
31/// bearer-token-JWT fallback this port doesn't perform — see the crate
32/// docs):
33///
34/// 1. The first message whose `additional_properties["user_id"]` is a valid
35///    GUID wins outright.
36/// 2. Otherwise, the first message whose `author_name` is a valid GUID is
37///    remembered as a fallback candidate (scanning continues, in case a
38///    later message has an explicit `user_id`).
39/// 3. If neither produced a value, `provided` is used if it is itself a
40///    valid GUID.
41/// 4. Otherwise `None` — callers must treat this as "cannot evaluate; do not
42///    block" (see [`ContentProcessor::evaluate`]), matching Python's
43///    fail-open behavior when no resolvable user id exists.
44pub 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
67/// Build one `processContent` request for a single message. Mirrors the body
68/// of `ScopedContentProcessor._map_messages`'s per-message loop (device
69/// metadata is always `"Unknown"`/`"Unknown"`, matching Python's hardcoded
70/// values there).
71fn 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        // Both the prompt (pre) and response (post) checks use `UploadText`
90        // — see the crate docs' "A curious fidelity note" section for why
91        // this mirrors Python exactly rather than using `DownloadText` for
92        // the response direction.
93        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
115/// Orchestrates `processContent` evaluation over a batch of messages. See
116/// the module docs for how this differs in scope from Python's
117/// `ScopedContentProcessor`.
118pub struct ContentProcessor {
119    client: PurviewClient,
120}
121
122impl ContentProcessor {
123    pub fn new(client: PurviewClient) -> Self {
124        Self { client }
125    }
126
127    /// Evaluate `messages` for policy violations, resolving the user id per
128    /// [`resolve_user_id`] (`provided_user_id` lets a response-phase
129    /// evaluation reuse the id resolved during the prompt phase, matching
130    /// Python's `process_messages(..., user_id=resolved_user_id)`).
131    ///
132    /// Returns `(should_block, resolved_user_id)`. One `processContent` call
133    /// is made per message, in order, stopping at (and including) the first
134    /// one that returns a block verdict — mirrors
135    /// `ScopedContentProcessor.process_messages`'s `for req in
136    /// pc_requests: ...; if should_block: break`.
137    ///
138    /// Fails (rather than silently allowing) when `tenant_id` or
139    /// `purview_app_location` aren't set on `settings`, mirroring Python's
140    /// `_map_messages` raising `ValueError` in the same situation — an
141    /// error a caller with `ignore_exceptions = true` treats as fail-open
142    /// (see [`crate::middleware`]), same as Python.
143    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            // No resolvable user id: fail open, matching Python (an empty
176            // `pc_requests` list never enters the block-checking loop).
177            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    // -- is_valid_guid ------------------------------------------------------
216
217    #[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    // -- resolve_user_id ------------------------------------------------------
230
231    #[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    // -- evaluate: configuration validation (async, no network) ------------
288
289    #[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        // Config is valid, but no message/author/provided id is GUID-shaped
326        // -- if this attempted an HTTP call, it would hang/fail trying to
327        // reach graph.microsoft.com.
328        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}