1use std::collections::HashMap;
38use std::sync::{Arc, Mutex};
39use std::time::{Duration, Instant};
40
41type CacheKey = String;
44
45struct CachedDecision {
47 decision: bool,
48 expires_at: Instant,
49}
50
51#[derive(Clone)]
62pub struct OpaClient {
63 endpoint: String,
64 cache: Arc<Mutex<HashMap<CacheKey, CachedDecision>>>,
65 cache_ttl: Duration,
66 http_timeout: Duration,
67 ssrf_filter: crate::config::ssrf::SsrfFilter,
70 bundle_revision: Arc<Mutex<Option<String>>>,
73 fail_open: bool,
79}
80
81#[derive(Clone, Debug)]
83pub struct OpaRequest {
84 pub input: serde_json::Value,
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum OpaDecision {
91 Allow,
92 Deny,
93}
94
95#[derive(Debug)]
97pub enum OpaError {
98 Http(String),
100 Status(u16, String),
102 ResponseParse(String),
104}
105
106impl std::fmt::Display for OpaError {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 match self {
109 Self::Http(s) => write!(f, "OPA HTTP error: {s}"),
110 Self::Status(code, body) => write!(f, "OPA returned {code}: {body}"),
111 Self::ResponseParse(s) => write!(f, "OPA response parse error: {s}"),
112 }
113 }
114}
115
116impl std::error::Error for OpaError {}
117
118impl OpaClient {
119 pub fn new(endpoint: String, cache_ttl: Duration, http_timeout: Duration) -> Self {
126 Self {
127 endpoint,
128 cache: Arc::new(Mutex::new(HashMap::new())),
129 cache_ttl,
130 http_timeout,
131 ssrf_filter: crate::config::ssrf::SsrfFilter::disabled(),
132 bundle_revision: Arc::new(Mutex::new(None)),
133 fail_open: true,
134 }
135 }
136
137 pub fn with_fail_open(mut self, fail_open: bool) -> Self {
141 self.fail_open = fail_open;
142 self
143 }
144
145 pub fn with_ssrf_filter(mut self, filter: crate::config::ssrf::SsrfFilter) -> Self {
150 self.ssrf_filter = filter;
151 self
152 }
153
154 pub async fn is_authorized(&self, req: &OpaRequest) -> Result<OpaDecision, OpaError> {
162 let key = self.cache_key(req);
163
164 {
166 let cache = self.cache.lock().unwrap();
167 if let Some(entry) = cache.get(&key) {
168 if entry.expires_at > Instant::now() {
169 return Ok(if entry.decision {
170 OpaDecision::Allow
171 } else {
172 OpaDecision::Deny
173 });
174 }
175 }
176 }
177
178 let decision = match self.call_opa(req).await {
181 Ok(d) => d,
182 Err(e) => {
183 if self.fail_open {
184 tracing::warn!(
185 code = "opa_outage_fail_open",
186 error = %e,
187 "OPA callout failed; failing open (allow) per outage policy"
188 );
189 return Ok(OpaDecision::Allow);
190 } else {
191 return Err(e);
192 }
193 }
194 };
195
196 {
198 let mut cache = self.cache.lock().unwrap();
199 cache.insert(
200 key,
201 CachedDecision {
202 decision: matches!(decision, OpaDecision::Allow),
203 expires_at: Instant::now() + self.cache_ttl,
204 },
205 );
206 }
207
208 Ok(decision)
209 }
210
211 async fn call_opa(&self, req: &OpaRequest) -> Result<OpaDecision, OpaError> {
213 let body = serde_json::json!({ "input": req.input });
214 let body_str = serde_json::to_string(&body)
215 .map_err(|e| OpaError::ResponseParse(format!("serialize request: {e}")))?;
216
217 let response = async_post(
218 &self.endpoint,
219 &body_str,
220 self.http_timeout,
221 &self.ssrf_filter,
222 )
223 .await?;
224
225 let result: serde_json::Value = serde_json::from_str(&response)
226 .map_err(|e| OpaError::ResponseParse(format!("parse response: {e}")))?;
227
228 let allowed = result
229 .get("result")
230 .and_then(|v| v.as_bool())
231 .ok_or_else(|| OpaError::ResponseParse("missing 'result' field".to_string()))?;
232
233 Ok(if allowed {
234 OpaDecision::Allow
235 } else {
236 OpaDecision::Deny
237 })
238 }
239
240 fn cache_key(&self, req: &OpaRequest) -> CacheKey {
242 format!(
243 "{}:{}",
244 self.endpoint,
245 serde_json::to_string(&req.input).unwrap_or_default()
246 )
247 }
248
249 pub fn clear_cache(&self) {
251 self.cache.lock().unwrap().clear();
252 }
253
254 pub fn cache_size(&self) -> usize {
256 self.cache.lock().unwrap().len()
257 }
258
259 pub async fn download_bundle(&self, bundle_url: &str) -> Result<bool, OpaError> {
269 let response = async_get(bundle_url, self.http_timeout, &self.ssrf_filter).await?;
270 let bundle: serde_json::Value = serde_json::from_str(&response)
271 .map_err(|e| OpaError::ResponseParse(format!("parse bundle: {e}")))?;
272
273 let revision = bundle
274 .get("revision")
275 .and_then(|v| v.as_str())
276 .unwrap_or("");
277
278 let mut changed = false;
279 {
280 let mut rev_guard = self.bundle_revision.lock().unwrap();
281 if rev_guard.as_deref() != Some(revision) {
282 changed = true;
283 *rev_guard = Some(revision.to_string());
284 }
285 }
286
287 if changed {
288 self.clear_cache();
289 }
290
291 Ok(changed)
292 }
293
294 pub fn bundle_revision(&self) -> Option<String> {
296 self.bundle_revision.lock().unwrap().clone()
297 }
298}
299
300async fn async_post(
305 url: &str,
306 body: &str,
307 timeout: Duration,
308 ssrf_filter: &crate::config::ssrf::SsrfFilter,
309) -> Result<String, OpaError> {
310 let (scheme, host, port, path) = parse_url(url)?;
311
312 if ssrf_filter.is_enabled() {
314 let addrs = tokio::net::lookup_host(format!("{}:{}", host, port))
315 .await
316 .map_err(|e| OpaError::Http(format!("DNS resolution failed: {e}")))?;
317 for addr in addrs {
318 if let Err(reason) = ssrf_filter.check(addr.ip()) {
319 return Err(OpaError::Http(format!(
320 "SSRF egress filter rejected OPA target: {reason}"
321 )));
322 }
323 }
324 }
325
326 let connect_addr = format!("{}:{}", host, port);
327
328 let stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&connect_addr))
330 .await
331 .map_err(|_| OpaError::Http(format!("connect timeout to {}", connect_addr)))?
332 .map_err(|e| OpaError::Http(format!("connect: {e}")))?;
333
334 let _ = stream.set_nodelay(true);
335
336 let request = format!(
338 "POST {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
339 body.len()
340 );
341
342 if scheme == "https" {
343 let tls_stream = tls_handshake(stream, &host).await?;
344 do_http_io(tls_stream, &request, timeout).await
345 } else {
346 do_http_io(stream, &request, timeout).await
347 }
348}
349
350async fn async_get(
352 url: &str,
353 timeout: Duration,
354 ssrf_filter: &crate::config::ssrf::SsrfFilter,
355) -> Result<String, OpaError> {
356 let (scheme, host, port, path) = parse_url(url)?;
357
358 if ssrf_filter.is_enabled() {
359 let addrs = tokio::net::lookup_host(format!("{}:{}", host, port))
360 .await
361 .map_err(|e| OpaError::Http(format!("DNS resolution failed: {e}")))?;
362 for addr in addrs {
363 if let Err(reason) = ssrf_filter.check(addr.ip()) {
364 return Err(OpaError::Http(format!(
365 "SSRF egress filter rejected OPA target: {reason}"
366 )));
367 }
368 }
369 }
370
371 let connect_addr = format!("{}:{}", host, port);
372 let stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&connect_addr))
373 .await
374 .map_err(|_| OpaError::Http(format!("connect timeout to {}", connect_addr)))?
375 .map_err(|e| OpaError::Http(format!("connect: {e}")))?;
376
377 let _ = stream.set_nodelay(true);
378
379 let request = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
380
381 if scheme == "https" {
382 let tls_stream = tls_handshake(stream, &host).await?;
383 do_http_io(tls_stream, &request, timeout).await
384 } else {
385 do_http_io(stream, &request, timeout).await
386 }
387}
388
389async fn do_http_io<S>(stream: S, request: &str, timeout: Duration) -> Result<String, OpaError>
392where
393 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
394{
395 use tokio::io::{AsyncReadExt, AsyncWriteExt};
396 let (mut reader, mut writer) = tokio::io::split(stream);
397 writer
398 .write_all(request.as_bytes())
399 .await
400 .map_err(|e| OpaError::Http(format!("write: {e}")))?;
401 writer.shutdown().await.ok();
402
403 let mut response = Vec::new();
404 tokio::time::timeout(timeout, reader.read_to_end(&mut response))
405 .await
406 .map_err(|_| OpaError::Http("response timeout".to_string()))?
407 .map_err(|e| OpaError::Http(format!("read: {e}")))?;
408
409 parse_http_response(&response)
410}
411
412async fn tls_handshake(
416 stream: tokio::net::TcpStream,
417 server_name: &str,
418) -> Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>, OpaError> {
419 let root_store = rustls::RootCertStore::empty();
420 let config = rustls::ClientConfig::builder()
421 .with_root_certificates(root_store)
422 .with_no_client_auth();
423 let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
424 let server_name = rustls::pki_types::ServerName::try_from(server_name.to_string())
425 .map_err(|e| OpaError::Http(format!("invalid server name: {e}")))?;
426 connector
427 .connect(server_name, stream)
428 .await
429 .map_err(|e| OpaError::Http(format!("TLS handshake: {e}")))
430}
431
432fn parse_http_response(response: &[u8]) -> Result<String, OpaError> {
434 let response_str = String::from_utf8_lossy(response);
435 let body_start = response_str
436 .find("\r\n\r\n")
437 .ok_or_else(|| OpaError::Http("no body in response".to_string()))?;
438 let body = &response_str[body_start + 4..];
439
440 let status_line = response_str.lines().next().unwrap_or("");
441 if !status_line.contains(" 200 ") {
442 let code = status_line
443 .split_whitespace()
444 .nth(1)
445 .and_then(|s| s.parse::<u16>().ok())
446 .unwrap_or(0);
447 return Err(OpaError::Status(code, body.to_string()));
448 }
449
450 Ok(body.to_string())
451}
452
453fn parse_url(url: &str) -> Result<(&str, String, u16, String), OpaError> {
456 let (scheme, rest) = url
457 .split_once("://")
458 .ok_or_else(|| OpaError::Http("URL must start with http:// or https://".to_string()))?;
459 if scheme != "http" && scheme != "https" {
460 return Err(OpaError::Http(format!(
461 "unsupported scheme: {scheme} (use http or https)"
462 )));
463 }
464
465 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
466 let path = if path.is_empty() {
467 "/".to_string()
468 } else {
469 format!("/{path}")
470 };
471
472 let (host, port) = if let Some((h, p)) = authority.rsplit_once(':') {
473 let port: u16 = p
474 .parse()
475 .map_err(|_| OpaError::Http(format!("invalid port in URL: {p}")))?;
476 (h.to_string(), port)
477 } else {
478 (
479 authority.to_string(),
480 if scheme == "https" { 443 } else { 80 },
481 )
482 };
483
484 Ok((scheme, host, port, path))
485}
486
487#[cfg(test)]
492mod tests {
493 use super::*;
494 use std::time::Duration;
495
496 #[test]
497 fn cache_key_is_deterministic() {
498 let client = OpaClient::new(
499 "http://opa:8181/v1/data/dwara/allow".to_string(),
500 Duration::from_secs(60),
501 Duration::from_secs(5),
502 );
503 let req = OpaRequest {
504 input: serde_json::json!({"user": "alice", "action": "read"}),
505 };
506 let key1 = client.cache_key(&req);
507 let key2 = client.cache_key(&req);
508 assert_eq!(key1, key2);
509 }
510
511 #[test]
512 fn cache_key_differs_for_different_inputs() {
513 let client = OpaClient::new(
514 "http://opa:8181/v1/data/dwara/allow".to_string(),
515 Duration::from_secs(60),
516 Duration::from_secs(5),
517 );
518 let req1 = OpaRequest {
519 input: serde_json::json!({"user": "alice"}),
520 };
521 let req2 = OpaRequest {
522 input: serde_json::json!({"user": "bob"}),
523 };
524 assert_ne!(client.cache_key(&req1), client.cache_key(&req2));
525 }
526
527 #[test]
528 fn clear_cache_empties_the_cache() {
529 let client = OpaClient::new(
530 "http://opa:8181/v1/data/dwara/allow".to_string(),
531 Duration::from_secs(60),
532 Duration::from_secs(5),
533 );
534 {
535 let mut cache = client.cache.lock().unwrap();
536 cache.insert(
537 "test-key".to_string(),
538 CachedDecision {
539 decision: true,
540 expires_at: Instant::now() + Duration::from_secs(60),
541 },
542 );
543 }
544 assert_eq!(client.cache_size(), 1);
545 client.clear_cache();
546 assert_eq!(client.cache_size(), 0);
547 }
548
549 #[test]
550 fn parse_url_http() {
551 let (scheme, host, port, path) = parse_url("http://opa:8181/v1/data/allow").unwrap();
552 assert_eq!(scheme, "http");
553 assert_eq!(host, "opa");
554 assert_eq!(port, 8181);
555 assert_eq!(path, "/v1/data/allow");
556 }
557
558 #[test]
559 fn parse_url_https_default_port() {
560 let (scheme, host, port, path) = parse_url("https://opa.example.com/v1/data").unwrap();
561 assert_eq!(scheme, "https");
562 assert_eq!(host, "opa.example.com");
563 assert_eq!(port, 443);
564 assert_eq!(path, "/v1/data");
565 }
566
567 #[test]
568 fn parse_url_http_default_port() {
569 let (scheme, host, port, _path) = parse_url("http://opa.example.com/v1/data").unwrap();
570 assert_eq!(scheme, "http");
571 assert_eq!(host, "opa.example.com");
572 assert_eq!(port, 80);
573 }
574
575 #[test]
576 fn bundle_revision_starts_none() {
577 let client = OpaClient::new(
578 "http://opa:8181/v1/data/dwara/allow".to_string(),
579 Duration::from_secs(60),
580 Duration::from_secs(5),
581 );
582 assert_eq!(client.bundle_revision(), None);
583 }
584}