agent_framework_purview/lib.rs
1//! # agent-framework-purview
2//!
3//! [Microsoft Purview](https://learn.microsoft.com/purview/) compliance
4//! middleware for `agent-framework-rs`: evaluate an agent's/chat client's
5//! outgoing prompt and incoming response against Microsoft Graph
6//! `dataSecurityAndGovernance` data-loss-prevention policy, blocking either
7//! direction when policy says to.
8//!
9//! This is the Rust equivalent of `agent_framework_purview`
10//! (`PurviewPolicyMiddleware` / `PurviewChatPolicyMiddleware`) in the Python
11//! reference implementation, hand-rolled against `reqwest` rather than
12//! wrapping `httpx` + `azure-identity` (which have no Rust equivalents in
13//! this workspace).
14//!
15//! ## Scope: what this port narrows down to, and why
16//!
17//! The Python package's `ScopedContentProcessor` orchestrates **three**
18//! Graph endpoints per evaluated message: it first computes applicable
19//! *protection scopes* (`protectionScopes/compute`, cached with ETag-based
20//! invalidation and a 4-hour default TTL), only calls `processContent`
21//! inline when a scope actually applies and demands inline evaluation
22//! (queuing it in the background otherwise), and — when no scope applies at
23//! all — fires a background `activities/contentActivities` audit log entry
24//! instead. This work package's brief scopes this crate down to the single
25//! call that actually produces a block/allow verdict:
26//!
27//! > "PurviewClient: POST the processContent route Python uses ... parse
28//! > verdict (policy actions / restrict/block per Python)."
29//!
30//! So **this crate calls `processContent` directly**, on every evaluated
31//! message, and parses *its* `policyActions` for a block verdict. It does
32//! **not** call `protectionScopes/compute` or `activities/contentActivities`,
33//! does not cache anything, and has no background-task queuing. A
34//! consequence worth calling out: since there's no scopes precheck to
35//! determine "does any policy even apply here", this port evaluates inline
36//! (and synchronously) on every call, which is both simpler and more
37//! conservative (never silently skips evaluation the way Python's
38//! "no applicable scope → log-only, don't block" fallback can) but forgoes
39//! the offline/background execution mode and the performance benefit of
40//! Python's caching layer. [`PurviewSettings::cache_ttl_seconds`] /
41//! [`PurviewSettings::max_cache_size_bytes`] are still present on the
42//! settings struct (parity with Python's configuration surface) but are
43//! currently unused by this port.
44//!
45//! Two further, independent scope cuts, both driven by this crate's
46//! self-contained [`TokenProvider`] (see the `auth` module docs — there's no
47//! `azure-identity`-equivalent credential to introspect a JWT with here):
48//!
49//! - **No token-derived `tenant_id`/`user_id`/app-location fallback.**
50//! Python's `PurviewClient.get_user_info_from_token` decodes the bearer
51//! token's JWT payload (`tid`/`oid`/`appid` claims, unverified — no
52//! signature check either side) to fill in `tenant_id` when
53//! `PurviewSettings.tenant_id` is unset, and an application-location
54//! fallback when `PurviewSettings.purview_app_location` is unset. This
55//! port requires both to be set explicitly on [`PurviewSettings`] —
56//! attempting to evaluate without them is a [`agent_framework_core::error::Error::Configuration`]
57//! error (itself subject to [`PurviewSettings::ignore_exceptions`], same
58//! as any other evaluation failure).
59//! - **User id resolution from messages is still ported.** The *other* half
60//! of Python's user-id resolution — scanning `Message::additional_properties["user_id"]`,
61//! falling back to a GUID-shaped `author_name` — has nothing to do with
62//! the bearer token and is faithfully ported; see
63//! [`processor::resolve_user_id`].
64//!
65//! ## A curious fidelity note: both directions check `UploadText`
66//!
67//! Python's `Activity` enum has both `UPLOAD_TEXT` and `DOWNLOAD_TEXT`
68//! variants, and it would be reasonable to expect the response-direction
69//! (egress) check to use `DOWNLOAD_TEXT`. It does not: both
70//! `PurviewPolicyMiddleware.process`'s pre- *and* post-check call
71//! `self._processor.process_messages(messages, Activity.UPLOAD_TEXT, ...)`
72//! — the exact same activity constant, confirmed against the Python
73//! package's own test suite
74//! (`tests/test_middleware.py::test_middleware_processor_receives_correct_activity`
75//! asserts `Activity.UPLOAD_TEXT` for *both* calls). This port mirrors that
76//! exactly rather than "fixing" it — see
77//! [`processor::ContentProcessor::evaluate`]'s caller in [`middleware`].
78//!
79//! ## Layout
80//!
81//! - [`settings`] — [`PurviewSettings`], [`PurviewAppLocation`],
82//! [`PurviewLocationType`].
83//! - [`auth`] — [`TokenProvider`]: bring-your-own bearer token (see "Auth
84//! burden" below).
85//! - [`models`] — the `processContent` request/response wire shapes.
86//! - [`client`] — [`PurviewClient`]: the single `processContent` HTTP call.
87//! - [`processor`] — [`processor::ContentProcessor`]: message → request
88//! mapping, user-id resolution, and the per-message evaluate-until-blocked
89//! loop.
90//! - [`middleware`] — [`PurviewAgentMiddleware`] / [`PurviewChatMiddleware`]:
91//! the two middleware hook points.
92//!
93//! ## Example
94//!
95//! ```no_run
96//! use std::sync::Arc;
97//! use agent_framework_core::prelude::*;
98//! use agent_framework_purview::{
99//! PurviewAgentMiddleware, PurviewAppLocation, PurviewLocationType, PurviewSettings,
100//! StaticTokenProvider,
101//! };
102//!
103//! # async fn demo(client: impl ChatClient + 'static) -> Result<()> {
104//! let settings = PurviewSettings::new("My App")
105//! .with_tenant_id("00000000-0000-0000-0000-000000000000")
106//! .with_purview_app_location(PurviewAppLocation::new(
107//! PurviewLocationType::Application,
108//! "00000000-0000-0000-0000-000000000001", // the app registration's client id
109//! ));
110//! // Bring your own Microsoft Graph bearer token (see the `auth` module docs).
111//! let token_provider = StaticTokenProvider::new("<graph-bearer-token>");
112//! let middleware = PurviewAgentMiddleware::new(token_provider, settings);
113//!
114//! let agent = Agent::builder(client)
115//! .instructions("You are a helpful assistant.")
116//! .middleware(Arc::new(middleware))
117//! .build();
118//!
119//! // A `Message` needs a GUID-shaped `additional_properties["user_id"]`
120//! // (or `author_name`) for policy evaluation to run at all -- see the
121//! // crate docs' "Scope" section.
122//! let mut message = Message::user("Summarize this quarter's roadmap.");
123//! message
124//! .additional_properties
125//! .insert("user_id".into(), serde_json::json!("00000000-0000-0000-0000-0000000000aa"));
126//!
127//! let response = agent.run(vec![message], None).await?;
128//! println!("{}", response.text());
129//! # Ok(())
130//! # }
131//! ```
132//!
133//! ## Auth burden
134//!
135//! The Python reference accepts an `azure-identity` `TokenCredential` /
136//! `AsyncTokenCredential` directly, inheriting whatever credential chain the
137//! caller already has configured. This crate has no such dependency and is
138//! deliberately self-contained per this work package's brief: implement
139//! [`TokenProvider`] to bring a Microsoft Graph bearer token (scope
140//! `https://graph.microsoft.com/.default`, or the equivalent for a custom
141//! [`PurviewSettings::graph_base_uri`] — see [`PurviewSettings::get_scopes`]),
142//! carrying the `dataSecurityAndGovernance` Graph permission. See the `auth`
143//! module docs for the full rationale; [`StaticTokenProvider`] is provided
144//! for a fixed/pre-fetched token (tests, short scripts, externally-managed
145//! refresh).
146//!
147//! ## Divergences from the Python reference (summary)
148//!
149//! - Calls only `processContent` — no `protectionScopes/compute` precheck,
150//! no caching, no background `contentActivities` logging (see "Scope"
151//! above).
152//! - No JWT-derived `tenant_id` / app-location fallback — both must be set
153//! explicitly on [`PurviewSettings`] (see "Scope" above).
154//! - No distinct exception *type* hierarchy
155//! (`PurviewAuthenticationError`/`PurviewRateLimitError`/...): every HTTP
156//! failure becomes [`agent_framework_core::error::Error::ServiceStatus`],
157//! carrying the real status code; callers (namely this crate's own
158//! middleware) branch on [`agent_framework_core::error::Error::status`]
159//! the same way Python's `except PurviewPaymentRequiredError` branches on
160//! exception type — status 402 is still special-cased identically (see
161//! [`client::PurviewClient::process_content`] and
162//! [`middleware::PurviewAgentMiddleware`]).
163
164pub mod auth;
165pub mod client;
166pub mod middleware;
167pub mod models;
168pub mod processor;
169pub mod settings;
170
171pub use auth::{StaticTokenProvider, TokenProvider};
172pub use client::PurviewClient;
173pub use middleware::{PurviewAgentMiddleware, PurviewChatMiddleware};
174pub use models::{
175 Activity, DlpAction, DlpActionInfo, ProcessContentRequest, ProcessContentResponse,
176 ProtectionScopeState, RestrictionAction,
177};
178pub use processor::ContentProcessor;
179pub use settings::{PurviewAppLocation, PurviewLocationType, PurviewSettings};