Skip to main content

agent_framework_purview/
models.rs

1//! Wire types for the Microsoft Graph `dataSecurityAndGovernance`
2//! `processContent` API.
3//!
4//! Field names and nesting mirror Python's `agent_framework_purview._models`
5//! (which in turn mirrors the Graph API's OData shape: `@odata.type`
6//! discriminators, camelCase field names). Only the subset this port's
7//! [`processContent`](crate::client::PurviewClient::process_content) call
8//! actually sends/receives is modeled — the full Python module additionally
9//! defines `protectionScopes`/`contentActivities` request/response types for
10//! the two other Graph endpoints this port intentionally does not call (see
11//! the crate docs' "Scope" section).
12
13use serde::{Deserialize, Serialize};
14
15/// High-level activity type describing what's being done with content.
16/// Mirrors Python's `Activity` enum. Only `UploadText` is ever sent by this
17/// port's middleware — see the crate docs for why both the prompt and
18/// response checks use it (faithfully mirroring the Python reference, which
19/// does the same, `DownloadText` notwithstanding).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub enum Activity {
23    Unknown,
24    UploadText,
25    UploadFile,
26    DownloadText,
27    DownloadFile,
28}
29
30/// `ActivityMetadata`: wraps an [`Activity`] for `contentToProcess`.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ActivityMetadata {
33    pub activity: Activity,
34}
35
36/// `microsoft.graph.textContent`: the message text being evaluated.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PurviewTextContent {
39    #[serde(rename = "@odata.type")]
40    pub data_type: String,
41    pub data: String,
42}
43
44impl PurviewTextContent {
45    pub fn new(text: impl Into<String>) -> Self {
46        Self {
47            data_type: "microsoft.graph.textContent".to_string(),
48            data: text.into(),
49        }
50    }
51}
52
53/// `microsoft.graph.processConversationMetadata`: one message's content plus
54/// identity metadata. `ContentToProcess::content_entries` carries a list of
55/// these, though this port's [`ContentProcessor`](crate::processor::ContentProcessor)
56/// only ever sends one per `processContent` call (mirrors Python's
57/// `_map_messages`, which builds one whole `ProcessContentRequest` — with a
58/// single-element `content_entries` — per [`Message`](agent_framework_core::types::Message),
59/// not one batched request for all of them).
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProcessConversationMetadata {
62    #[serde(rename = "@odata.type")]
63    pub data_type: String,
64    pub identifier: String,
65    pub content: PurviewTextContent,
66    pub name: String,
67    #[serde(rename = "isTruncated")]
68    pub is_truncated: bool,
69}
70
71impl ProcessConversationMetadata {
72    pub fn new(
73        identifier: impl Into<String>,
74        text: impl Into<String>,
75        name: impl Into<String>,
76    ) -> Self {
77        Self {
78            data_type: "microsoft.graph.processConversationMetadata".to_string(),
79            identifier: identifier.into(),
80            content: PurviewTextContent::new(text),
81            name: name.into(),
82            is_truncated: false,
83        }
84    }
85}
86
87/// `microsoft.graph.operatingSystemSpecifications`, nested under
88/// [`DeviceMetadata`]. This port always sends `"Unknown"`/`"Unknown"`,
89/// matching Python's `_map_messages` (which hardcodes the same values —
90/// device introspection is out of scope for both).
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct OperatingSystemSpecifications {
93    #[serde(rename = "operatingSystemPlatform")]
94    pub operating_system_platform: String,
95    #[serde(rename = "operatingSystemVersion")]
96    pub operating_system_version: String,
97}
98
99impl Default for OperatingSystemSpecifications {
100    fn default() -> Self {
101        Self {
102            operating_system_platform: "Unknown".to_string(),
103            operating_system_version: "Unknown".to_string(),
104        }
105    }
106}
107
108/// `microsoft.graph.deviceMetadata`.
109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct DeviceMetadata {
111    #[serde(rename = "operatingSystemSpecifications")]
112    pub operating_system_specifications: OperatingSystemSpecifications,
113}
114
115/// `microsoft.graph.integratedAppMetadata`: the calling application's
116/// identity (name/version), independent of *where* it's deployed (see
117/// [`PolicyLocation`] for that).
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct IntegratedAppMetadata {
120    pub name: String,
121    pub version: String,
122}
123
124/// A policy location (`@odata.type` + `value`), e.g.
125/// `microsoft.graph.policyLocationApplication`. Mirrors Python's
126/// `PolicyLocation`; see [`crate::settings::PurviewAppLocation::to_policy_location`]
127/// for how the crate's public [`PurviewLocationType`](crate::settings::PurviewLocationType)
128/// enum maps to the `@odata.type` string.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PolicyLocation {
131    #[serde(rename = "@odata.type")]
132    pub data_type: String,
133    pub value: String,
134}
135
136/// `microsoft.graph.protectedAppMetadata`: the app's identity plus *where*
137/// it's running, for policy location matching.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ProtectedAppMetadata {
140    pub name: String,
141    pub version: String,
142    #[serde(rename = "applicationLocation")]
143    pub application_location: PolicyLocation,
144}
145
146/// `microsoft.graph.contentToProcess`: the full bundle of content +
147/// activity/device/app metadata sent to `processContent`.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ContentToProcess {
150    #[serde(rename = "contentEntries")]
151    pub content_entries: Vec<ProcessConversationMetadata>,
152    #[serde(rename = "activityMetadata")]
153    pub activity_metadata: ActivityMetadata,
154    #[serde(rename = "deviceMetadata")]
155    pub device_metadata: DeviceMetadata,
156    #[serde(rename = "integratedAppMetadata")]
157    pub integrated_app_metadata: IntegratedAppMetadata,
158    #[serde(rename = "protectedAppMetadata")]
159    pub protected_app_metadata: ProtectedAppMetadata,
160}
161
162/// The `processContent` request body. Mirrors Python's
163/// `ProcessContentRequest`; `scope_identifier` (sent as an `If-None-Match`
164/// header, not a body field, and only meaningful after a
165/// `protectionScopes/compute` precheck this port doesn't perform) and
166/// `process_inline` (ditto — derived from that same precheck's execution
167/// mode) are intentionally not modeled. See the crate docs' "Scope" section.
168#[derive(Debug, Clone, Serialize)]
169pub struct ProcessContentRequest {
170    #[serde(rename = "contentToProcess")]
171    pub content_to_process: ContentToProcess,
172    #[serde(rename = "userId")]
173    pub user_id: String,
174    #[serde(rename = "tenantId")]
175    pub tenant_id: String,
176    #[serde(rename = "correlationId", skip_serializing_if = "Option::is_none")]
177    pub correlation_id: Option<String>,
178}
179
180/// `blockAccess` vs. anything else. Mirrors Python's `DlpAction`.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub enum DlpAction {
184    BlockAccess,
185    Other,
186}
187
188/// `block` vs. anything else. Mirrors Python's `RestrictionAction`.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub enum RestrictionAction {
192    Block,
193    Other,
194}
195
196/// One policy action returned by `processContent`. A response is a *block*
197/// verdict when any entry has `action == BlockAccess` **or**
198/// `restriction_action == Block` — see
199/// [`ProcessContentResponse::should_block`].
200#[derive(Debug, Clone, Deserialize)]
201pub struct DlpActionInfo {
202    #[serde(default)]
203    pub action: Option<DlpAction>,
204    #[serde(rename = "restrictionAction", default)]
205    pub restriction_action: Option<RestrictionAction>,
206}
207
208/// Whether a protection scope's applicability has changed since it was last
209/// computed/cached. This port doesn't cache (see the crate docs), so this is
210/// informational only — surfaced for callers who want it, not acted on
211/// internally.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub enum ProtectionScopeState {
215    NotModified,
216    Modified,
217    UnknownFutureValue,
218}
219
220/// One entry of `processingErrors` on a `processContent` response.
221#[derive(Debug, Clone, Deserialize)]
222pub struct ProcessingError {
223    #[serde(default)]
224    pub message: Option<String>,
225}
226
227/// The `processContent` response body. Mirrors Python's
228/// `ProcessContentResponse`.
229#[derive(Debug, Clone, Default, Deserialize)]
230pub struct ProcessContentResponse {
231    #[serde(default)]
232    pub id: Option<String>,
233    #[serde(rename = "protectionScopeState", default)]
234    pub protection_scope_state: Option<ProtectionScopeState>,
235    #[serde(rename = "policyActions", default)]
236    pub policy_actions: Option<Vec<DlpActionInfo>>,
237    #[serde(rename = "processingErrors", default)]
238    pub processing_errors: Option<Vec<ProcessingError>>,
239}
240
241impl ProcessContentResponse {
242    /// Whether this response's policy actions constitute a *block* verdict.
243    ///
244    /// Mirrors `ScopedContentProcessor.process_messages`'s check:
245    /// `action == DlpAction.BLOCK_ACCESS or restriction_action ==
246    /// RestrictionAction.BLOCK` on any entry of `policy_actions`.
247    pub fn should_block(&self) -> bool {
248        self.policy_actions.as_deref().is_some_and(|actions| {
249            actions.iter().any(|a| {
250                a.action == Some(DlpAction::BlockAccess)
251                    || a.restriction_action == Some(RestrictionAction::Block)
252            })
253        })
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn activity_serializes_as_lower_camel_case() {
263        assert_eq!(
264            serde_json::to_value(Activity::UploadText).unwrap(),
265            serde_json::json!("uploadText")
266        );
267        assert_eq!(
268            serde_json::to_value(Activity::DownloadText).unwrap(),
269            serde_json::json!("downloadText")
270        );
271    }
272
273    #[test]
274    fn dlp_action_and_restriction_action_serialize_as_expected_wire_strings() {
275        assert_eq!(
276            serde_json::to_value(DlpAction::BlockAccess).unwrap(),
277            serde_json::json!("blockAccess")
278        );
279        assert_eq!(
280            serde_json::to_value(RestrictionAction::Block).unwrap(),
281            serde_json::json!("block")
282        );
283    }
284
285    #[test]
286    fn process_content_request_serializes_with_graph_camel_case_and_odata_type() {
287        let content = ContentToProcess {
288            content_entries: vec![ProcessConversationMetadata::new(
289                "msg-1",
290                "hello",
291                "Agent Framework Message msg-1",
292            )],
293            activity_metadata: ActivityMetadata {
294                activity: Activity::UploadText,
295            },
296            device_metadata: DeviceMetadata::default(),
297            integrated_app_metadata: IntegratedAppMetadata {
298                name: "App".into(),
299                version: "1.0".into(),
300            },
301            protected_app_metadata: ProtectedAppMetadata {
302                name: "App".into(),
303                version: "1.0".into(),
304                application_location: PolicyLocation {
305                    data_type: "microsoft.graph.policyLocationApplication".into(),
306                    value: "app-id".into(),
307                },
308            },
309        };
310        let request = ProcessContentRequest {
311            content_to_process: content,
312            user_id: "user-123".into(),
313            tenant_id: "tenant-456".into(),
314            correlation_id: Some("corr-1".into()),
315        };
316        let value = serde_json::to_value(&request).unwrap();
317        assert_eq!(value["userId"], serde_json::json!("user-123"));
318        assert_eq!(value["tenantId"], serde_json::json!("tenant-456"));
319        assert_eq!(value["correlationId"], serde_json::json!("corr-1"));
320        let entry = &value["contentToProcess"]["contentEntries"][0];
321        assert_eq!(
322            entry["@odata.type"],
323            serde_json::json!("microsoft.graph.processConversationMetadata")
324        );
325        assert_eq!(entry["content"]["data"], serde_json::json!("hello"));
326        assert_eq!(
327            entry["content"]["@odata.type"],
328            serde_json::json!("microsoft.graph.textContent")
329        );
330        assert_eq!(
331            value["contentToProcess"]["activityMetadata"]["activity"],
332            serde_json::json!("uploadText")
333        );
334        assert_eq!(
335            value["contentToProcess"]["protectedAppMetadata"]["applicationLocation"]["@odata.type"],
336            serde_json::json!("microsoft.graph.policyLocationApplication")
337        );
338        assert_eq!(
339            value["contentToProcess"]["deviceMetadata"]["operatingSystemSpecifications"]
340                ["operatingSystemPlatform"],
341            serde_json::json!("Unknown")
342        );
343    }
344
345    #[test]
346    fn process_content_request_omits_correlation_id_when_none() {
347        let content = ContentToProcess {
348            content_entries: vec![],
349            activity_metadata: ActivityMetadata {
350                activity: Activity::UploadText,
351            },
352            device_metadata: DeviceMetadata::default(),
353            integrated_app_metadata: IntegratedAppMetadata {
354                name: "A".into(),
355                version: "1".into(),
356            },
357            protected_app_metadata: ProtectedAppMetadata {
358                name: "A".into(),
359                version: "1".into(),
360                application_location: PolicyLocation {
361                    data_type: "microsoft.graph.policyLocationApplication".into(),
362                    value: "v".into(),
363                },
364            },
365        };
366        let request = ProcessContentRequest {
367            content_to_process: content,
368            user_id: "u".into(),
369            tenant_id: "t".into(),
370            correlation_id: None,
371        };
372        let value = serde_json::to_value(&request).unwrap();
373        assert!(value.get("correlationId").is_none());
374    }
375
376    // -- should_block verdict parsing --------------------------------------
377
378    #[test]
379    fn should_block_true_on_block_access_action() {
380        let resp = ProcessContentResponse {
381            policy_actions: Some(vec![DlpActionInfo {
382                action: Some(DlpAction::BlockAccess),
383                restriction_action: None,
384            }]),
385            ..Default::default()
386        };
387        assert!(resp.should_block());
388    }
389
390    #[test]
391    fn should_block_true_on_block_restriction_action() {
392        let resp = ProcessContentResponse {
393            policy_actions: Some(vec![DlpActionInfo {
394                action: None,
395                restriction_action: Some(RestrictionAction::Block),
396            }]),
397            ..Default::default()
398        };
399        assert!(resp.should_block());
400    }
401
402    #[test]
403    fn should_block_false_when_action_is_other() {
404        let resp = ProcessContentResponse {
405            policy_actions: Some(vec![DlpActionInfo {
406                action: Some(DlpAction::Other),
407                restriction_action: Some(RestrictionAction::Other),
408            }]),
409            ..Default::default()
410        };
411        assert!(!resp.should_block());
412    }
413
414    #[test]
415    fn should_block_false_when_no_policy_actions() {
416        assert!(!ProcessContentResponse::default().should_block());
417        let resp = ProcessContentResponse {
418            policy_actions: Some(vec![]),
419            ..Default::default()
420        };
421        assert!(!resp.should_block());
422    }
423
424    #[test]
425    fn should_block_true_when_any_of_several_actions_blocks() {
426        let resp = ProcessContentResponse {
427            policy_actions: Some(vec![
428                DlpActionInfo {
429                    action: Some(DlpAction::Other),
430                    restriction_action: None,
431                },
432                DlpActionInfo {
433                    action: Some(DlpAction::BlockAccess),
434                    restriction_action: None,
435                },
436            ]),
437            ..Default::default()
438        };
439        assert!(resp.should_block());
440    }
441
442    #[test]
443    fn process_content_response_deserializes_from_graph_shape() {
444        let value = serde_json::json!({
445            "id": "resp-1",
446            "protectionScopeState": "notModified",
447            "policyActions": [{"action": "blockAccess", "restrictionAction": "block"}],
448        });
449        let resp: ProcessContentResponse = serde_json::from_value(value).unwrap();
450        assert_eq!(resp.id.as_deref(), Some("resp-1"));
451        assert_eq!(
452            resp.protection_scope_state,
453            Some(ProtectionScopeState::NotModified)
454        );
455        assert!(resp.should_block());
456    }
457
458    #[test]
459    fn process_content_response_deserializes_empty_object() {
460        let resp: ProcessContentResponse = serde_json::from_value(serde_json::json!({})).unwrap();
461        assert!(resp.id.is_none());
462        assert!(!resp.should_block());
463    }
464}