Skip to main content

av_harness/
authzen.rs

1//! Client for an external AuthZEN policy decision point
2//! ([Authorization API 1.0](https://openid.net/specs/authorization-api-1_0.html)).
3//!
4//! When `[authzen]` is configured, every tool call that the local policy
5//! decision point permits is also sent to the external PDP's Access
6//! Evaluation endpoint (`POST .../access/v1/evaluation`). Both must permit.
7//! The request names the human principal as the subject, the acting agent
8//! and its scopes as subject properties, the resolved business intent as
9//! the action, and the tool (with its backend) as the resource.
10//!
11//! A `{"decision": false}` answer is an audited policy denial
12//! (`PDP_DENIED`). Anything else that is not a well-formed decision — a
13//! timeout, a connection failure, a non-200 status, an unreadable body —
14//! fails closed with 503, because an unreachable policy service is an
15//! availability fault, not a verdict about the caller.
16
17use crate::authz::{AuthzAction, AuthzEvalRequest, AuthzEvalResponse, AuthzResource, AuthzSubject};
18use crate::config::AuthzenConfig;
19use crate::pipeline::AuthenticatedCaller;
20use axum::http::HeaderValue;
21use futures::StreamExt as _;
22use serde_json::{json, Value};
23
24/// Largest PDP response body read.
25const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
26
27/// Most evaluations sent in one batch request.
28const MAX_BATCH: usize = 256;
29
30/// The external PDP's verdict.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AuthzenDecision {
33    /// The PDP permitted the call.
34    Permit,
35    /// The PDP denied the call, with the reason it gave (if any).
36    Deny(String),
37}
38
39/// HTTP client for the configured AuthZEN endpoints.
40pub struct AuthzenClient {
41    client: reqwest::Client,
42    evaluation_endpoint: String,
43    evaluations_endpoint: Option<String>,
44    authorization: Option<HeaderValue>,
45    timeout: std::time::Duration,
46}
47
48impl AuthzenClient {
49    /// Build from configuration, reading the optional bearer token file
50    /// with the same owner-only checks as other secrets.
51    pub fn from_config(config: &AuthzenConfig, client: reqwest::Client) -> Result<Self, String> {
52        let authorization =
53            crate::pipeline::read_secret(None, config.auth_file.as_deref(), "AuthZEN bearer token")
54                .map_err(|error| error.to_string())?
55                .map(|token| {
56                    let mut value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
57                        "AuthZEN bearer token cannot be represented as an HTTP header".to_owned()
58                    })?;
59                    value.set_sensitive(true);
60                    Ok::<_, String>(value)
61                })
62                .transpose()?;
63        Ok(Self {
64            client,
65            evaluation_endpoint: config.evaluation_endpoint.clone(),
66            evaluations_endpoint: config.evaluations_endpoint.clone(),
67            authorization,
68            timeout: std::time::Duration::from_millis(config.timeout_ms),
69        })
70    }
71
72    async fn post(&self, endpoint: &str, body: &Value) -> Result<Value, String> {
73        let bytes = serde_json::to_vec(body).map_err(|error| error.to_string())?;
74        let request_id = av_core::ids::new_event_uid();
75        let mut request = self
76            .client
77            .post(endpoint)
78            .timeout(self.timeout)
79            .header(axum::http::header::CONTENT_TYPE, "application/json")
80            .header(axum::http::header::ACCEPT, "application/json")
81            .header("x-request-id", request_id.as_str())
82            .body(bytes);
83        if let Some(authorization) = &self.authorization {
84            request = request.header(axum::http::header::AUTHORIZATION, authorization.clone());
85        }
86        let response = request
87            .send()
88            .await
89            .map_err(|error| crate::pipeline::classify_upstream_error(&error).to_owned())?;
90        let status = response.status();
91        if status != reqwest::StatusCode::OK {
92            return Err(format!("policy decision point answered HTTP {}", status.as_u16()));
93        }
94        let mut stream = response.bytes_stream();
95        let mut body = Vec::new();
96        while let Some(chunk) = stream.next().await {
97            let chunk = chunk.map_err(|error| crate::pipeline::classify_upstream_error(&error).to_owned())?;
98            if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
99                return Err("policy decision point response is too large".to_owned());
100            }
101            body.extend_from_slice(&chunk);
102        }
103        serde_json::from_slice(&body).map_err(|_| "policy decision point response is not JSON".to_owned())
104    }
105
106    /// Evaluate one access request.
107    pub async fn evaluate(&self, request: &AuthzEvalRequest) -> Result<AuthzenDecision, String> {
108        let body = serde_json::to_value(request).map_err(|error| error.to_string())?;
109        let answer = self.post(&self.evaluation_endpoint, &body).await?;
110        decision_of(&answer)
111    }
112
113    /// Evaluate several requests that share a subject and context. Uses the
114    /// batch endpoint when configured (in chunks of at most 256), and one
115    /// evaluation per request otherwise. The result has one entry per
116    /// request, in order.
117    pub async fn evaluate_many(&self, requests: &[AuthzEvalRequest]) -> Result<Vec<bool>, String> {
118        let Some(endpoint) = &self.evaluations_endpoint else {
119            let answers =
120                futures::future::join_all(requests.iter().map(|request| self.evaluate(request))).await;
121            return answers
122                .into_iter()
123                .map(|answer| answer.map(|decision| decision == AuthzenDecision::Permit))
124                .collect();
125        };
126        let mut decisions = Vec::with_capacity(requests.len());
127        for chunk in requests.chunks(MAX_BATCH) {
128            let Some(first) = chunk.first() else {
129                continue;
130            };
131            let evaluations: Vec<Value> = chunk
132                .iter()
133                .map(|request| json!({"action": request.action, "resource": request.resource}))
134                .collect();
135            let mut body = json!({
136                "subject": first.subject,
137                "evaluations": evaluations,
138                "options": {"evaluations_semantic": "execute_all"},
139            });
140            if let (Some(context), Some(object)) = (&first.context, body.as_object_mut()) {
141                object.insert("context".to_owned(), context.clone());
142            }
143            let answer = self.post(endpoint, &body).await?;
144            let results = answer
145                .get("evaluations")
146                .and_then(Value::as_array)
147                .filter(|results| results.len() == chunk.len())
148                .ok_or_else(|| "policy decision point returned a mismatched evaluations array".to_owned())?;
149            for result in results {
150                decisions.push(decision_of(result)? == AuthzenDecision::Permit);
151            }
152        }
153        Ok(decisions)
154    }
155}
156
157/// Read `{"decision": bool, "context": ...}`; anything else is an error.
158pub fn decision_of(answer: &Value) -> Result<AuthzenDecision, String> {
159    let parsed: AuthzEvalResponse = serde_json::from_value(answer.clone())
160        .map_err(|_| "policy decision point returned no boolean decision".to_owned())?;
161    if parsed.decision {
162        return Ok(AuthzenDecision::Permit);
163    }
164    Ok(AuthzenDecision::Deny(reason_of(parsed.context.as_ref())))
165}
166
167/// The most useful human-readable reason in a PDP context. The spec's
168/// examples use `reason_admin` / `reason_user`, either as a string or as
169/// a language map such as `{"en": "..."}`. Control characters and
170/// bidirectional overrides are stripped so a hostile PDP cannot forge
171/// log lines or spoof display text.
172fn reason_of(context: Option<&Value>) -> String {
173    let text = |value: &Value| -> Option<String> {
174        match value {
175            Value::String(text) => Some(text.clone()),
176            Value::Object(map) => map
177                .get("en")
178                .or_else(|| map.values().next())
179                .and_then(Value::as_str)
180                .map(str::to_owned),
181            _ => None,
182        }
183    };
184    context
185        .and_then(|context| {
186            ["reason_admin", "reason_user", "reason"]
187                .iter()
188                .find_map(|key| context.get(*key).and_then(text))
189        })
190        .map(|reason| sanitize_reason(&reason))
191        .filter(|reason| !reason.is_empty())
192        .unwrap_or_else(|| "denied by the external policy decision point".to_owned())
193}
194
195/// Strip control characters and bidirectional overrides from a
196/// PDP-supplied reason string. The result is at most 512 characters.
197fn sanitize_reason(reason: &str) -> String {
198    reason
199        .chars()
200        .filter(|c| !c.is_control() && !av_core::text::is_bidi_or_zero_width(*c))
201        .take(512)
202        .collect()
203}
204
205/// Build the Access Evaluation request for `tool` on behalf of `caller`.
206pub(crate) fn evaluation_request(
207    caller: &AuthenticatedCaller,
208    tool: &str,
209    intent: &str,
210    backend: Option<&str>,
211    session: Option<&str>,
212    missions: &[String],
213) -> AuthzEvalRequest {
214    let (subject_type, subject_id, properties) = match &caller.validated {
215        Some(validated) => (
216            "user",
217            validated.claims.sub.clone(),
218            json!({
219                "issuer": validated.claims.iss,
220                "agent": {
221                    "instance_uid": validated.claims.instance_uid,
222                    "charter": validated.claims.charter,
223                    "version": validated.claims.version,
224                },
225                "azp": validated.claims.azp,
226                "act": validated.claims.act,
227                "scopes": validated.claims.scopes,
228            }),
229        ),
230        None => (
231            "agent",
232            caller.identity.instance_uid.clone(),
233            json!({"agent": {"instance_uid": caller.identity.instance_uid}}),
234        ),
235    };
236    AuthzEvalRequest {
237        subject: AuthzSubject {
238            id: subject_id,
239            subject_type: subject_type.to_owned(),
240            properties: Some(properties),
241        },
242        action: AuthzAction {
243            name: intent.to_owned(),
244            properties: Some(json!({"tool": tool})),
245        },
246        resource: AuthzResource {
247            id: tool.to_owned(),
248            resource_type: "tool".to_owned(),
249            properties: Some(json!({"backend": backend})),
250        },
251        context: Some(json!({
252            "session_id": session,
253            "missions": missions,
254            "time": av_core::time::now_ms() / 1000,
255        })),
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    #![allow(
262        clippy::unwrap_used,
263        clippy::expect_used,
264        clippy::panic,
265        clippy::indexing_slicing
266    )]
267
268    use super::*;
269    use std::sync::{Arc, Mutex};
270
271    #[test]
272    fn decisions_and_reasons_are_parsed_strictly() {
273        assert_eq!(
274            decision_of(&json!({"decision": true})).unwrap(),
275            AuthzenDecision::Permit
276        );
277        assert_eq!(
278            decision_of(&json!({"decision": false, "context": {"reason_admin": {"en": "outside hours"}}}))
279                .unwrap(),
280            AuthzenDecision::Deny("outside hours".into())
281        );
282        assert_eq!(
283            decision_of(&json!({"decision": false, "context": {"reason_user": "ask your manager"}})).unwrap(),
284            AuthzenDecision::Deny("ask your manager".into())
285        );
286        assert_eq!(
287            decision_of(&json!({"decision": false})).unwrap(),
288            AuthzenDecision::Deny("denied by the external policy decision point".into())
289        );
290        assert!(decision_of(&json!({"decision": "yes"})).is_err());
291        assert!(decision_of(&json!({})).is_err());
292    }
293
294    #[test]
295    fn hostile_reasons_stripped_of_control_and_bidi() {
296        // Newlines and carriage returns are removed so a PDP cannot
297        // inject extra log lines through a denial reason.
298        let reason = decision_of(&json!({
299            "decision": false,
300            "context": {"reason_user": "ok\nmalicious line"}
301        }))
302        .unwrap();
303        assert_eq!(reason, AuthzenDecision::Deny("okmalicious line".into()));
304
305        // Tab is a control character and is stripped.
306        let reason = decision_of(&json!({
307            "decision": false,
308            "context": {"reason_admin": "a\tb"}
309        }))
310        .unwrap();
311        assert_eq!(reason, AuthzenDecision::Deny("ab".into()));
312
313        // Bidirectional overrides are stripped so a PDP cannot reorder
314        // displayed text.
315        let reason = decision_of(&json!({
316            "decision": false,
317            "context": {"reason_user": "before\u{202E}evil\u{202D}after"}
318        }))
319        .unwrap();
320        assert_eq!(reason, AuthzenDecision::Deny("beforeevilafter".into()));
321
322        // A reason consisting entirely of hostile bytes collapses to
323        // the fallback string.
324        let reason = decision_of(&json!({
325            "decision": false,
326            "context": {"reason_user": "\r\n\t\u{202E}"}
327        }))
328        .unwrap();
329        assert_eq!(
330            reason,
331            AuthzenDecision::Deny("denied by the external policy decision point".into())
332        );
333    }
334
335    #[test]
336    fn reason_language_map_variants_are_read() {
337        // Language map with "en" key is preferred.
338        assert_eq!(
339            reason_of(Some(
340                &json!({"reason_admin": {"en": "outside hours", "fr": "hors heures"}})
341            )),
342            "outside hours"
343        );
344        // Missing "en" falls back to the first value.
345        assert_eq!(
346            reason_of(Some(&json!({"reason_user": {"fr": "hors heures"}}))),
347            "hors heures"
348        );
349        // Non-string values are ignored.
350        assert_eq!(
351            reason_of(Some(&json!({"reason_user": 42}))),
352            "denied by the external policy decision point"
353        );
354        // reason_user is preferred over reason when both exist.
355        assert_eq!(
356            reason_of(Some(&json!({"reason_user": "user reason", "reason": "generic"}))),
357            "user reason"
358        );
359        // reason_admin is preferred over reason_user.
360        assert_eq!(
361            reason_of(Some(
362                &json!({"reason_admin": "admin reason", "reason_user": "user reason"})
363            )),
364            "admin reason"
365        );
366        // Unknown keys are ignored.
367        assert_eq!(
368            reason_of(Some(&json!({"unknown": "value"}))),
369            "denied by the external policy decision point"
370        );
371    }
372
373    #[test]
374    fn reason_is_capped_at_512_characters() {
375        let long = "x".repeat(2000);
376        assert_eq!(sanitize_reason(&long).chars().count(), 512);
377    }
378
379    /// How the mock's batch endpoint answers a request.
380    #[derive(Clone)]
381    enum Batch {
382        /// One result per evaluation in the request, all with this verdict.
383        Echo(bool),
384        /// Exactly these verdicts, whatever the request length.
385        Fixed(Vec<bool>),
386        /// Omit the `evaluations` array entirely.
387        Missing,
388    }
389
390    /// A stand-in for an external policy decision point. It records every
391    /// request body it receives. `single` is the verdict it gives on the
392    /// Access Evaluation endpoint; `batch` is how it answers the Access
393    /// Evaluations endpoint.
394    async fn mock_pdp(single: bool, batch: Batch) -> (String, Arc<Mutex<Vec<Value>>>) {
395        let seen: Arc<Mutex<Vec<Value>>> = Arc::default();
396        let seen_in_handler = Arc::clone(&seen);
397        let app = axum::Router::new().fallback(move |uri: axum::http::Uri, body: axum::body::Bytes| {
398            let seen = Arc::clone(&seen_in_handler);
399            let batch = batch.clone();
400            async move {
401                let request: Value = serde_json::from_slice(&body).unwrap();
402                seen.lock().unwrap().push(request.clone());
403                if !uri.path().ends_with("/evaluations") {
404                    let answer = if single {
405                        json!({"decision": true})
406                    } else {
407                        json!({"decision": false, "context": {"reason_admin": "blocked by policy"}})
408                    };
409                    return (axum::http::StatusCode::OK, axum::Json(answer));
410                }
411                let answer = match batch {
412                    Batch::Echo(verdict) => {
413                        let count = request["evaluations"].as_array().map(Vec::len).unwrap_or(0);
414                        let evaluations: Vec<Value> =
415                            (0..count).map(|_| json!({"decision": verdict})).collect();
416                        json!({"evaluations": evaluations})
417                    }
418                    Batch::Fixed(verdicts) => json!({
419                        "evaluations": verdicts
420                            .iter()
421                            .map(|verdict| json!({"decision": verdict}))
422                            .collect::<Vec<Value>>()
423                    }),
424                    Batch::Missing => json!({}),
425                };
426                (axum::http::StatusCode::OK, axum::Json(answer))
427            }
428        });
429        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
430        let base = format!("http://{}", listener.local_addr().unwrap());
431        tokio::spawn(async move {
432            let _ = axum::serve(listener, app).await;
433        });
434        (base, seen)
435    }
436
437    fn eval_request(id: &str) -> AuthzEvalRequest {
438        AuthzEvalRequest {
439            subject: AuthzSubject {
440                id: "user:alice".into(),
441                subject_type: "user".into(),
442                properties: None,
443            },
444            action: AuthzAction {
445                name: "data.read".into(),
446                properties: None,
447            },
448            resource: AuthzResource {
449                id: id.into(),
450                resource_type: "tool".into(),
451                properties: None,
452            },
453            context: None,
454        }
455    }
456
457    fn pdp_client(base: &str, batch: bool) -> AuthzenClient {
458        AuthzenClient::from_config(
459            &AuthzenConfig {
460                evaluation_endpoint: format!("{base}/access/v1/evaluation"),
461                evaluations_endpoint: batch.then(|| format!("{base}/access/v1/evaluations")),
462                auth_file: None,
463                timeout_ms: 2_000,
464            },
465            reqwest::Client::new(),
466        )
467        .unwrap()
468    }
469
470    #[tokio::test]
471    async fn batch_eval_returns_one_decision_per_request() {
472        let (base, seen) = mock_pdp(false, Batch::Fixed(vec![true, false, true])).await;
473        let client = pdp_client(&base, true);
474        let decisions = client
475            .evaluate_many(&[eval_request("a"), eval_request("b"), eval_request("c")])
476            .await
477            .unwrap();
478        assert_eq!(decisions, vec![true, false, true]);
479        let requests = seen.lock().unwrap();
480        assert_eq!(requests.len(), 1, "the batch path sends exactly one request");
481        assert_eq!(requests[0]["evaluations"].as_array().unwrap().len(), 3);
482    }
483
484    #[tokio::test]
485    async fn batch_eval_mismatched_array_length_is_an_error() {
486        // Fewer results than requests must be refused.
487        let (base, _seen) = mock_pdp(false, Batch::Fixed(vec![true])).await;
488        let client = pdp_client(&base, true);
489        let error = client
490            .evaluate_many(&[eval_request("a"), eval_request("b")])
491            .await
492            .unwrap_err();
493        assert!(error.contains("mismatched"), "{error}");
494
495        // More results than requests must also be refused.
496        let (base, _seen) = mock_pdp(false, Batch::Fixed(vec![true, true, true])).await;
497        let client = pdp_client(&base, true);
498        let error = client
499            .evaluate_many(&[eval_request("a"), eval_request("b")])
500            .await
501            .unwrap_err();
502        assert!(error.contains("mismatched"), "{error}");
503
504        // A missing `evaluations` array must also be refused.
505        let (base, _seen) = mock_pdp(false, Batch::Missing).await;
506        let client = pdp_client(&base, true);
507        let error = client.evaluate_many(&[eval_request("a")]).await.unwrap_err();
508        assert!(error.contains("mismatched"), "{error}");
509    }
510
511    #[tokio::test]
512    async fn batch_eval_chunks_requests_above_the_batch_limit() {
513        // MAX_BATCH is 256, so 300 requests become two HTTP calls.
514        let (base, seen) = mock_pdp(false, Batch::Echo(true)).await;
515        let client = pdp_client(&base, true);
516        let requests: Vec<AuthzEvalRequest> = (0..300)
517            .map(|index| eval_request(&format!("tool-{index}")))
518            .collect();
519        let decisions = client.evaluate_many(&requests).await.unwrap();
520        assert_eq!(decisions.len(), 300);
521        assert!(decisions.iter().all(|decision| *decision));
522        let requests = seen.lock().unwrap();
523        assert_eq!(requests.len(), 2, "300 requests are sent in two chunks");
524        assert_eq!(requests[0]["evaluations"].as_array().unwrap().len(), 256);
525        assert_eq!(requests[1]["evaluations"].as_array().unwrap().len(), 44);
526    }
527
528    #[tokio::test]
529    async fn single_eval_falls_back_when_no_batch_endpoint() {
530        let (base, seen) = mock_pdp(true, Batch::Missing).await;
531        let client = pdp_client(&base, false);
532        let decisions = client
533            .evaluate_many(&[eval_request("a"), eval_request("b")])
534            .await
535            .unwrap();
536        assert_eq!(decisions, vec![true, true], "each request gets its own verdict");
537        let requests = seen.lock().unwrap();
538        assert_eq!(
539            requests.len(),
540            2,
541            "one HTTP call per request without a batch endpoint"
542        );
543    }
544
545    #[test]
546    fn decision_of_rejects_non_object_answers() {
547        for answer in [json!("deny"), json!(null), json!([])] {
548            assert!(decision_of(&answer).is_err(), "accepted: {answer}");
549        }
550    }
551
552    #[test]
553    fn sanitize_reason_strips_all_hostile_code_points() {
554        // Every control character and bidi override is stripped.
555        let hostile = "\u{0000}\u{0001}\u{0007}\u{0008}\u{000B}\u{000C}\u{000E}\u{001F}\u{007F}\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}\u{2066}\u{2067}\u{2068}\u{2069}\u{206A}\u{206B}\u{206C}\u{206D}\u{206E}\u{206F}\u{FEFF}";
556        assert_eq!(sanitize_reason(hostile), "");
557        // Normal text is preserved.
558        assert_eq!(sanitize_reason("hello world 42"), "hello world 42");
559        // Unicode text is preserved.
560        assert_eq!(sanitize_reason("réseau café"), "réseau café");
561    }
562
563    #[test]
564    fn decision_of_rejects_non_boolean_decisions() {
565        for answer in [
566            json!({"decision": 1}),
567            json!({"decision": "true"}),
568            json!({"decision": null}),
569            json!({"decision": []}),
570            json!({"decision": {}}),
571            json!({"result": true}),
572            json!({"allow": true}),
573        ] {
574            assert!(
575                decision_of(&answer).is_err(),
576                "accepted non-boolean decision: {answer}"
577            );
578        }
579    }
580
581    #[test]
582    fn decision_of_accepts_both_decision_spellings() {
583        assert_eq!(
584            decision_of(&json!({"decision": true})).unwrap(),
585            AuthzenDecision::Permit
586        );
587        assert_eq!(
588            decision_of(&json!({"decision": false})).unwrap(),
589            AuthzenDecision::Deny("denied by the external policy decision point".into())
590        );
591        // Context is optional.
592        assert_eq!(
593            decision_of(&json!({"decision": true, "context": {"extra": 1}})).unwrap(),
594            AuthzenDecision::Permit
595        );
596    }
597}