1use std::time::Duration;
11
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14use uuid::Uuid;
15
16use crate::cache::TtlCache;
17use crate::error::Error;
18use crate::evaluation::{
19 EvaluationRequest, EvaluationResponse, EvaluationsRequest, EvaluationsResponse,
20};
21use crate::http::{HttpClient, HttpResponse, Method};
22use crate::search::{
23 ActionSearchRequest, ActionSearchResponse, ResourceSearchRequest, ResourceSearchResponse,
24 SubjectSearchRequest, SubjectSearchResponse,
25};
26
27const AUTHZEN_WELL_KNOWN_PATH: &str = "/.well-known/authzen-configuration";
28
29#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub struct PdpConfiguration {
38 pub policy_decision_point: String,
41 pub access_evaluation_endpoint: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub access_evaluations_endpoint: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub search_subject_endpoint: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub search_resource_endpoint: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub search_action_endpoint: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub capabilities: Option<Vec<String>>,
58 #[serde(skip_serializing_if = "Option::is_none")]
63 pub signed_metadata: Option<String>,
64}
65
66pub struct AuthZenClient<C: HttpClient> {
71 http: C,
72 cache: TtlCache<PdpConfiguration>,
73}
74
75impl<C: HttpClient> AuthZenClient<C> {
76 #[must_use]
81 pub fn new(http: C, cache_ttl: Duration) -> Self {
82 Self { http, cache: TtlCache::new(cache_ttl) }
83 }
84
85 pub async fn discover(&self, pdp_id: &str) -> Result<PdpConfiguration, Error> {
91 let url = Self::build_discovery_url(pdp_id)?;
92 let resp = self.unauthenticated_get(&url).await?;
93 let config: PdpConfiguration =
94 serde_json::from_slice(&resp.body).map_err(Error::InvalidResponse)?;
95
96 Self::validate_pdp_match(pdp_id, &config)?;
97 self.cache.insert(pdp_id.to_owned(), config.clone()).await;
98
99 Ok(config)
100 }
101
102 pub async fn get_pdp_config(&self, pdp_id: &str) -> Result<PdpConfiguration, Error> {
104 if let Some(config) = self.cache.get(pdp_id).await {
105 return Ok(config);
106 }
107
108 self.discover(pdp_id).await
109 }
110
111 pub async fn invalidate_pdp_config(&self, pdp_id: &str) -> bool {
115 let existed = self.cache.get(pdp_id).await.is_some();
116 self.cache.invalidate(pdp_id).await;
117 existed
118 }
119
120 pub async fn evaluate(
127 &self,
128 pdp_id: &str,
129 token: &str,
130 request: &EvaluationRequest,
131 ) -> Result<EvaluationResponse, Error> {
132 let url = self.resolve_required_endpoint(pdp_id, |c| &c.access_evaluation_endpoint).await?;
133 self.post_json(&url, token, request).await
134 }
135
136 pub async fn evaluate_batch(
141 &self,
142 pdp_id: &str,
143 token: &str,
144 request: &EvaluationsRequest,
145 ) -> Result<EvaluationsResponse, Error> {
146 let url = self
147 .resolve_optional_endpoint(pdp_id, |c| c.access_evaluations_endpoint.as_ref(), "/access/v1/evaluations")
148 .await?;
149 self.post_json(&url, token, request).await
150 }
151
152 pub async fn search_subjects(
157 &self,
158 pdp_id: &str,
159 token: &str,
160 request: &SubjectSearchRequest,
161 ) -> Result<SubjectSearchResponse, Error> {
162 let url = self
163 .resolve_optional_endpoint(pdp_id, |c| c.search_subject_endpoint.as_ref(), "/access/v1/search/subject")
164 .await?;
165 self.post_json(&url, token, request).await
166 }
167
168 pub async fn search_resources(
173 &self,
174 pdp_id: &str,
175 token: &str,
176 request: &ResourceSearchRequest,
177 ) -> Result<ResourceSearchResponse, Error> {
178 let url = self
179 .resolve_optional_endpoint(pdp_id, |c| c.search_resource_endpoint.as_ref(), "/access/v1/search/resource")
180 .await?;
181 self.post_json(&url, token, request).await
182 }
183
184 pub async fn search_actions(
189 &self,
190 pdp_id: &str,
191 token: &str,
192 request: &ActionSearchRequest,
193 ) -> Result<ActionSearchResponse, Error> {
194 let url = self
195 .resolve_optional_endpoint(pdp_id, |c| c.search_action_endpoint.as_ref(), "/access/v1/search/action")
196 .await?;
197 self.post_json(&url, token, request).await
198 }
199
200 fn validate_pdp_url(pdp_id: &str) -> Result<url::Url, Error> {
201 let parsed =
202 url::Url::parse(pdp_id).map_err(|e| Error::InvalidPdpUrl(e.to_string()))?;
203
204 if parsed.scheme() != "https" {
205 return Err(Error::InvalidPdpUrl(format!(
206 "scheme must be https, got {}",
207 parsed.scheme()
208 )));
209 }
210
211 if parsed.query().is_some() || parsed.fragment().is_some() {
212 return Err(Error::InvalidPdpUrl(
213 "PDP URL must not contain query or fragment".to_owned(),
214 ));
215 }
216
217 Ok(parsed)
218 }
219
220 fn build_discovery_url(pdp_id: &str) -> Result<String, Error> {
221 let parsed = Self::validate_pdp_url(pdp_id)?;
222
223 let path = parsed.path().trim_end_matches('/');
224 let mut discovery = parsed.clone();
225 discovery.set_path(&format!("{}{}", path, AUTHZEN_WELL_KNOWN_PATH));
226
227 Ok(discovery.to_string())
228 }
229
230 fn validate_pdp_match(expected: &str, config: &PdpConfiguration) -> Result<(), Error> {
231 let expected_normalized = expected.trim_end_matches('/');
232 let got_normalized = config.policy_decision_point.trim_end_matches('/');
233
234 if expected_normalized != got_normalized {
235 return Err(Error::PdpMismatch {
236 expected: expected.to_owned(),
237 got: config.policy_decision_point.clone(),
238 });
239 }
240
241 Ok(())
242 }
243
244 async fn resolve_required_endpoint(
246 &self,
247 pdp_id: &str,
248 extract: fn(&PdpConfiguration) -> &String,
249 ) -> Result<String, Error> {
250 let config =
251 self.cache.get(pdp_id).await.ok_or_else(|| Error::NotCached(pdp_id.to_owned()))?;
252
253 Ok(extract(&config).clone())
254 }
255
256 async fn resolve_optional_endpoint(
263 &self,
264 pdp_id: &str,
265 extract: fn(&PdpConfiguration) -> Option<&String>,
266 default_path: &str,
267 ) -> Result<String, Error> {
268 let config =
269 self.cache.get(pdp_id).await.ok_or_else(|| Error::NotCached(pdp_id.to_owned()))?;
270
271 if let Some(url) = extract(&config) {
272 return Ok(url.clone());
273 }
274
275 let mut base = Self::validate_pdp_url(&config.policy_decision_point)?;
277 let path = base.path().trim_end_matches('/');
278 base.set_path(&format!("{}{}", path, default_path));
279 Ok(base.to_string())
280 }
281
282 async fn authenticated_request(
289 &self,
290 method: Method,
291 url: &str,
292 token: &str,
293 body: Option<Vec<u8>>,
294 ) -> Result<HttpResponse, Error> {
295 let auth = format!("Bearer {}", token);
296 let request_id = Uuid::new_v4().to_string();
297 let headers = [
298 ("authorization", auth.as_str()),
299 ("x-request-id", request_id.as_str()),
300 ];
301
302 let resp = self.http.request(method, url, &headers, body).await?;
303
304 if resp.status >= 400 {
305 return Err(Error::HttpStatus {
306 status: resp.status,
307 body: String::from_utf8_lossy(&resp.body).into_owned(),
308 });
309 }
310
311 Ok(resp)
312 }
313
314 async fn unauthenticated_get(&self, url: &str) -> Result<HttpResponse, Error> {
318 let request_id = Uuid::new_v4().to_string();
319 let headers = [("x-request-id", request_id.as_str())];
320 let resp = self.http.request(Method::Get, url, &headers, None).await?;
321
322 if resp.status >= 400 {
323 return Err(Error::HttpStatus {
324 status: resp.status,
325 body: String::from_utf8_lossy(&resp.body).into_owned(),
326 });
327 }
328
329 Ok(resp)
330 }
331
332 async fn post_json<T: DeserializeOwned, B: Serialize>(
334 &self,
335 url: &str,
336 token: &str,
337 body: &B,
338 ) -> Result<T, Error> {
339 let bytes = serde_json::to_vec(body).map_err(Error::Serialization)?;
340 let resp = self.authenticated_request(Method::Post, url, token, Some(bytes)).await?;
341 serde_json::from_slice(&resp.body).map_err(Error::InvalidResponse)
342 }
343}