Skip to main content

agent_framework_purview/
settings.rs

1//! [`PurviewSettings`] and the location-scoping types it carries. Mirrors
2//! Python's `agent_framework_purview._settings`.
3
4use crate::models::PolicyLocation;
5
6/// Default Microsoft Graph base URI, matching `PurviewSettings.graph_base_uri`'s
7/// Python default.
8pub const DEFAULT_GRAPH_BASE_URI: &str = "https://graph.microsoft.com/v1.0/";
9
10/// Default message returned when a prompt is blocked by policy.
11pub const DEFAULT_BLOCKED_PROMPT_MESSAGE: &str = "Prompt blocked by policy";
12
13/// Default message returned when a response is blocked by policy.
14pub const DEFAULT_BLOCKED_RESPONSE_MESSAGE: &str = "Response blocked by policy";
15
16/// Default protection-scopes cache TTL Python declares (14400s = 4 hours).
17/// Carried here for settings parity even though this port does not cache —
18/// see the crate docs' "Scope" section.
19pub const DEFAULT_CACHE_TTL_SECONDS: u64 = 14_400;
20
21/// Default max cache size Python declares (200 MiB). See
22/// [`DEFAULT_CACHE_TTL_SECONDS`].
23pub const DEFAULT_MAX_CACHE_SIZE_BYTES: u64 = 200 * 1024 * 1024;
24
25/// The kind of location a [`PurviewAppLocation`] identifies. Mirrors
26/// Python's `PurviewLocationType`.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PurviewLocationType {
29    Application,
30    Uri,
31    Domain,
32}
33
34/// Identifies the calling application's location for Purview policy
35/// evaluation (an application id, a URL, or a domain). Mirrors Python's
36/// `PurviewAppLocation`.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PurviewAppLocation {
39    pub location_type: PurviewLocationType,
40    pub location_value: String,
41}
42
43impl PurviewAppLocation {
44    pub fn new(location_type: PurviewLocationType, location_value: impl Into<String>) -> Self {
45        Self {
46            location_type,
47            location_value: location_value.into(),
48        }
49    }
50
51    /// Build the Graph `@odata.type` + `value` pair for this location.
52    /// Mirrors `PurviewAppLocation.get_policy_location`.
53    pub fn to_policy_location(&self) -> PolicyLocation {
54        let data_type = match self.location_type {
55            PurviewLocationType::Application => "microsoft.graph.policyLocationApplication",
56            PurviewLocationType::Uri => "microsoft.graph.policyLocationUrl",
57            PurviewLocationType::Domain => "microsoft.graph.policyLocationDomain",
58        };
59        PolicyLocation {
60            data_type: data_type.to_string(),
61            value: self.location_value.clone(),
62        }
63    }
64}
65
66/// Settings for Purview integration. Mirrors Python's `PurviewSettings`
67/// (an `AFBaseSettings`/pydantic model there; a plain builder-style struct
68/// here — this port has no environment-variable-driven construction for it,
69/// since Python's `PurviewSettings` isn't env-prefixed either, unlike
70/// `agent-framework-copilotstudio`'s `CopilotStudioSettings`).
71#[derive(Debug, Clone)]
72pub struct PurviewSettings {
73    /// Required: the calling application's display/logical name. Sent as
74    /// both `integratedAppMetadata.name` and `protectedAppMetadata.name`.
75    pub app_name: String,
76    pub app_version: Option<String>,
77    /// The tenant id (GUID) evaluated requests are scoped to. Python can
78    /// also infer this from the bearer token's `tid` claim when unset; this
79    /// port requires it to be set explicitly — see the crate docs' "Scope"
80    /// section (no JWT introspection here).
81    pub tenant_id: Option<String>,
82    /// Where the calling application is considered to run, for policy
83    /// location matching. Python can also infer an application-type
84    /// location from the bearer token's `appid` claim when unset; this port
85    /// requires it to be set explicitly, for the same reason as `tenant_id`.
86    pub purview_app_location: Option<PurviewAppLocation>,
87    pub graph_base_uri: String,
88    pub blocked_prompt_message: String,
89    pub blocked_response_message: String,
90    /// If `true`, a policy-evaluation failure (any error other than a 402 —
91    /// see [`Self::ignore_payment_required`]) is logged and swallowed
92    /// (fail-open: the run proceeds as if evaluation passed) instead of
93    /// propagated.
94    pub ignore_exceptions: bool,
95    /// If `true`, a 402 Payment Required response is logged and swallowed
96    /// instead of propagated — checked independently of
97    /// [`Self::ignore_exceptions`], mirroring Python's separate
98    /// `except PurviewPaymentRequiredError` clause (checked *before*, and
99    /// independently of, the generic exception handler).
100    pub ignore_payment_required: bool,
101    /// Carried for settings parity with Python; this port does not cache
102    /// protection-scopes responses (see the crate docs), so this value is
103    /// currently unused.
104    pub cache_ttl_seconds: u64,
105    /// See [`Self::cache_ttl_seconds`].
106    pub max_cache_size_bytes: u64,
107}
108
109impl PurviewSettings {
110    /// `app_name` is Purview's only required setting; everything else takes
111    /// Python's documented defaults.
112    pub fn new(app_name: impl Into<String>) -> Self {
113        Self {
114            app_name: app_name.into(),
115            app_version: None,
116            tenant_id: None,
117            purview_app_location: None,
118            graph_base_uri: DEFAULT_GRAPH_BASE_URI.to_string(),
119            blocked_prompt_message: DEFAULT_BLOCKED_PROMPT_MESSAGE.to_string(),
120            blocked_response_message: DEFAULT_BLOCKED_RESPONSE_MESSAGE.to_string(),
121            ignore_exceptions: false,
122            ignore_payment_required: false,
123            cache_ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
124            max_cache_size_bytes: DEFAULT_MAX_CACHE_SIZE_BYTES,
125        }
126    }
127
128    pub fn with_app_version(mut self, version: impl Into<String>) -> Self {
129        self.app_version = Some(version.into());
130        self
131    }
132    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
133        self.tenant_id = Some(tenant_id.into());
134        self
135    }
136    pub fn with_purview_app_location(mut self, location: PurviewAppLocation) -> Self {
137        self.purview_app_location = Some(location);
138        self
139    }
140    pub fn with_graph_base_uri(mut self, uri: impl Into<String>) -> Self {
141        self.graph_base_uri = uri.into();
142        self
143    }
144    pub fn with_blocked_prompt_message(mut self, message: impl Into<String>) -> Self {
145        self.blocked_prompt_message = message.into();
146        self
147    }
148    pub fn with_blocked_response_message(mut self, message: impl Into<String>) -> Self {
149        self.blocked_response_message = message.into();
150        self
151    }
152    pub fn with_ignore_exceptions(mut self, value: bool) -> Self {
153        self.ignore_exceptions = value;
154        self
155    }
156    pub fn with_ignore_payment_required(mut self, value: bool) -> Self {
157        self.ignore_payment_required = value;
158        self
159    }
160    pub fn with_cache_ttl_seconds(mut self, ttl: u64) -> Self {
161        self.cache_ttl_seconds = ttl;
162        self
163    }
164    pub fn with_max_cache_size_bytes(mut self, bytes: u64) -> Self {
165        self.max_cache_size_bytes = bytes;
166        self
167    }
168
169    /// The Microsoft Graph OAuth scope(s) required for this base URI:
170    /// `https://{host}/.default`. Mirrors `PurviewSettings.get_scopes`.
171    pub fn get_scopes(&self) -> Vec<String> {
172        let host =
173            extract_host(&self.graph_base_uri).unwrap_or_else(|| "graph.microsoft.com".to_string());
174        vec![format!("https://{host}/.default")]
175    }
176}
177
178/// Extract the host from an `http(s)://host[:port]/path` URI without a full
179/// URL-parsing dependency — this crate only ever needs the host, and Graph
180/// base URIs are always simple `https://graph.microsoft.com/...`-shaped.
181fn extract_host(uri: &str) -> Option<String> {
182    let after_scheme = uri.split_once("://").map(|(_, rest)| rest).unwrap_or(uri);
183    let host = after_scheme.split(['/', '?', '#']).next()?;
184    if host.is_empty() {
185        None
186    } else {
187        Some(host.to_string())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn new_applies_python_documented_defaults() {
197        let settings = PurviewSettings::new("Test App");
198        assert_eq!(settings.app_name, "Test App");
199        assert_eq!(settings.graph_base_uri, "https://graph.microsoft.com/v1.0/");
200        assert!(settings.tenant_id.is_none());
201        assert!(settings.purview_app_location.is_none());
202        assert_eq!(settings.blocked_prompt_message, "Prompt blocked by policy");
203        assert_eq!(
204            settings.blocked_response_message,
205            "Response blocked by policy"
206        );
207        assert!(!settings.ignore_exceptions);
208        assert!(!settings.ignore_payment_required);
209        assert_eq!(settings.cache_ttl_seconds, 14_400);
210        assert_eq!(settings.max_cache_size_bytes, 200 * 1024 * 1024);
211    }
212
213    #[test]
214    fn builders_override_defaults() {
215        let location = PurviewAppLocation::new(PurviewLocationType::Application, "app-123");
216        let settings = PurviewSettings::new("Test App")
217            .with_graph_base_uri("https://graph.microsoft-ppe.com")
218            .with_tenant_id("test-tenant-id")
219            .with_purview_app_location(location.clone());
220        assert_eq!(settings.graph_base_uri, "https://graph.microsoft-ppe.com");
221        assert_eq!(settings.tenant_id.as_deref(), Some("test-tenant-id"));
222        assert_eq!(settings.purview_app_location, Some(location));
223    }
224
225    #[test]
226    fn get_scopes_derives_default_suffix_from_graph_base_uri() {
227        let settings = PurviewSettings::new("Test App");
228        assert_eq!(
229            settings.get_scopes(),
230            vec!["https://graph.microsoft.com/.default".to_string()]
231        );
232    }
233
234    #[test]
235    fn get_scopes_derives_suffix_from_custom_graph_base_uri() {
236        let settings = PurviewSettings::new("Test App")
237            .with_graph_base_uri("https://graph.microsoft-ppe.com/v1.0/");
238        assert_eq!(
239            settings.get_scopes(),
240            vec!["https://graph.microsoft-ppe.com/.default".to_string()]
241        );
242    }
243
244    #[test]
245    fn get_policy_location_maps_all_three_location_types() {
246        let cases = [
247            (
248                PurviewLocationType::Application,
249                "microsoft.graph.policyLocationApplication",
250            ),
251            (
252                PurviewLocationType::Uri,
253                "microsoft.graph.policyLocationUrl",
254            ),
255            (
256                PurviewLocationType::Domain,
257                "microsoft.graph.policyLocationDomain",
258            ),
259        ];
260        for (location_type, expected) in cases {
261            let location = PurviewAppLocation::new(location_type, "value-1");
262            let policy_location = location.to_policy_location();
263            assert_eq!(policy_location.data_type, expected);
264            assert_eq!(policy_location.value, "value-1");
265        }
266    }
267
268    #[test]
269    fn extract_host_handles_scheme_path_and_bare_host() {
270        assert_eq!(
271            extract_host("https://graph.microsoft.com/v1.0/"),
272            Some("graph.microsoft.com".to_string())
273        );
274        assert_eq!(
275            extract_host("https://graph.microsoft-ppe.com"),
276            Some("graph.microsoft-ppe.com".to_string())
277        );
278        assert_eq!(
279            extract_host("graph.microsoft.com/v1.0/"),
280            Some("graph.microsoft.com".to_string())
281        );
282    }
283}