1use 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
24const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
26
27const MAX_BATCH: usize = 256;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AuthzenDecision {
33 Permit,
35 Deny(String),
37}
38
39pub 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 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 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 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
157pub 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
167fn 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
195fn 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
205pub(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 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 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 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 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 assert_eq!(
339 reason_of(Some(
340 &json!({"reason_admin": {"en": "outside hours", "fr": "hors heures"}})
341 )),
342 "outside hours"
343 );
344 assert_eq!(
346 reason_of(Some(&json!({"reason_user": {"fr": "hors heures"}}))),
347 "hors heures"
348 );
349 assert_eq!(
351 reason_of(Some(&json!({"reason_user": 42}))),
352 "denied by the external policy decision point"
353 );
354 assert_eq!(
356 reason_of(Some(&json!({"reason_user": "user reason", "reason": "generic"}))),
357 "user reason"
358 );
359 assert_eq!(
361 reason_of(Some(
362 &json!({"reason_admin": "admin reason", "reason_user": "user reason"})
363 )),
364 "admin reason"
365 );
366 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 #[derive(Clone)]
381 enum Batch {
382 Echo(bool),
384 Fixed(Vec<bool>),
386 Missing,
388 }
389
390 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 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 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 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 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 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 assert_eq!(sanitize_reason("hello world 42"), "hello world 42");
559 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 assert_eq!(
593 decision_of(&json!({"decision": true, "context": {"extra": 1}})).unwrap(),
594 AuthzenDecision::Permit
595 );
596 }
597}