1use async_trait::async_trait;
24use bytes::Bytes;
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::time::{Duration, SystemTime, UNIX_EPOCH};
28
29use crate::context::{Context, GatewayError};
30use crate::outbound::{OutboundClient, OutboundRequest};
31use crate::plugins::resources::PluginResources;
32use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
33
34pub struct OpaPlugin {
38 host: String,
40 policy: String,
42 ssl_verify: bool,
44 timeout: Duration,
46 with_consumer: bool,
48 send_headers_upstream: Vec<String>,
51 client: Arc<OutboundClient>,
53}
54
55#[derive(Debug, PartialEq)]
57struct OpaDecision {
58 allow: bool,
59 status: Option<u16>,
61 reason: Option<String>,
63 headers: HashMap<String, String>,
65}
66
67#[derive(Debug, PartialEq)]
69enum OpaParseError {
70 NotJson,
72 MissingResult,
74}
75
76impl OpaPlugin {
77 pub fn from_config(
108 config: &HashMap<String, serde_json::Value>,
109 resources: &Arc<PluginResources>,
110 ) -> Result<Self, String> {
111 let host = config
112 .get("host")
113 .and_then(|v| v.as_str())
114 .filter(|s| !s.is_empty())
115 .ok_or_else(|| "opa plugin requires 'host'".to_string())?
116 .trim_end_matches('/')
117 .to_string();
118
119 let policy = config
120 .get("policy")
121 .and_then(|v| v.as_str())
122 .filter(|s| !s.is_empty())
123 .ok_or_else(|| "opa plugin requires 'policy'".to_string())?
124 .trim_start_matches('/')
125 .to_string();
126
127 let ssl_verify = config
128 .get("ssl_verify")
129 .and_then(|v| v.as_bool())
130 .unwrap_or(true);
131
132 let timeout = Duration::from_millis(
133 config
134 .get("timeout")
135 .and_then(|v| v.as_u64())
136 .unwrap_or(3000),
137 );
138
139 let with_consumer = config
140 .get("with_consumer")
141 .and_then(|v| v.as_bool())
142 .unwrap_or(false);
143
144 let send_headers_upstream = config
145 .get("send_headers_upstream")
146 .and_then(|v| v.as_array())
147 .map(|seq| {
148 seq.iter()
149 .filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
150 .collect()
151 })
152 .unwrap_or_default();
153
154 Ok(Self {
155 host,
156 policy,
157 ssl_verify,
158 timeout,
159 with_consumer,
160 send_headers_upstream,
161 client: resources.outbound.clone(),
162 })
163 }
164
165 fn build_opa_input(&self, ctx: &Context, now_unix: u64) -> serde_json::Value {
170 let (host, port) = split_host_port(&ctx.request.host, &ctx.request.scheme);
171 let (remote_addr, remote_port) = split_addr_port(&ctx.request.remote_addr);
172
173 let request = serde_json::json!({
174 "scheme": ctx.request.scheme,
175 "method": ctx.request.method,
176 "host": host,
177 "port": port,
178 "path": ctx.request.path,
179 "headers": collapse_multi(&ctx.request.headers),
180 "query": collapse_multi(&ctx.request.query_params),
181 });
182
183 let mut var = serde_json::Map::new();
184 var.insert("remote_addr".to_string(), serde_json::json!(remote_addr));
185 if let Some(p) = remote_port {
186 var.insert("remote_port".to_string(), serde_json::json!(p));
187 }
188 var.insert("timestamp".to_string(), serde_json::json!(now_unix));
189
190 let mut input = serde_json::Map::new();
191 input.insert("type".to_string(), serde_json::json!("http"));
192 input.insert("request".to_string(), request);
193 input.insert("var".to_string(), serde_json::Value::Object(var));
194
195 if self.with_consumer {
196 if let Some(consumer) = build_consumer(ctx) {
197 input.insert("consumer".to_string(), consumer);
198 }
199 }
200
201 serde_json::json!({ "input": serde_json::Value::Object(input) })
202 }
203
204 fn apply_allow(&self, ctx: &mut Context, decision: &OpaDecision) {
208 for name in &self.send_headers_upstream {
209 match decision.headers.get(name) {
210 Some(value) => {
211 ctx.request
212 .headers
213 .insert(name.clone(), vec![value.clone()]);
214 }
215 None => {
216 ctx.request.headers.remove(name);
217 }
218 }
219 }
220 }
221
222 fn build_deny(&self, mut ctx: Context, decision: &OpaDecision) -> PluginExecutionError {
225 let status = decision.status.unwrap_or(403);
226 ctx.response.status_code = status;
227 if let Some(reason) = &decision.reason {
228 ctx.response.body = Bytes::from(reason.clone());
229 }
230 for (name, value) in &decision.headers {
231 ctx.response
232 .headers
233 .insert(name.clone(), vec![value.clone()]);
234 }
235 PluginExecutionError {
236 context: ctx,
237 error: GatewayError {
238 node_id: String::new(),
239 code: "OPA_DENIED".to_string(),
240 message: format!("OPA denied the request ({})", status),
241 metadata: HashMap::new(),
242 },
243 }
244 }
245
246 fn build_error(&self, mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
249 ctx.response.status_code = status;
250 PluginExecutionError {
251 context: ctx,
252 error: GatewayError {
253 node_id: String::new(),
254 code: "OPA_ERROR".to_string(),
255 message,
256 metadata: HashMap::new(),
257 },
258 }
259 }
260}
261
262fn parse_opa_response(body: &[u8]) -> Result<OpaDecision, OpaParseError> {
265 let data: serde_json::Value =
266 serde_json::from_slice(body).map_err(|_| OpaParseError::NotJson)?;
267 let result = data.get("result").ok_or(OpaParseError::MissingResult)?;
268
269 let allow = result
270 .get("allow")
271 .and_then(|v| v.as_bool())
272 .unwrap_or(false);
273
274 let status = result
275 .get("status_code")
276 .or_else(|| result.get("status"))
277 .and_then(|v| v.as_u64())
278 .map(|n| n as u16);
279
280 let reason = result.get("reason").and_then(|v| match v {
281 serde_json::Value::Null => None,
282 serde_json::Value::String(s) => Some(s.clone()),
283 other => Some(other.to_string()),
284 });
285
286 let headers = result
287 .get("headers")
288 .and_then(|v| v.as_object())
289 .map(|obj| {
290 obj.iter()
291 .filter_map(|(k, v)| {
292 let value = match v {
293 serde_json::Value::String(s) => s.clone(),
294 serde_json::Value::Number(n) => n.to_string(),
295 serde_json::Value::Bool(b) => b.to_string(),
296 _ => return None,
297 };
298 Some((k.to_lowercase(), value))
299 })
300 .collect()
301 })
302 .unwrap_or_default();
303
304 Ok(OpaDecision {
305 allow,
306 status,
307 reason,
308 headers,
309 })
310}
311
312fn collapse_multi(map: &HashMap<String, Vec<String>>) -> serde_json::Value {
316 let mut out = serde_json::Map::new();
317 for (k, values) in map {
318 let value = match values.as_slice() {
319 [single] => serde_json::json!(single),
320 _ => serde_json::json!(values),
321 };
322 out.insert(k.clone(), value);
323 }
324 serde_json::Value::Object(out)
325}
326
327fn build_consumer(ctx: &Context) -> Option<serde_json::Value> {
330 let mut obj = serde_json::Map::new();
331 for (key, value) in &ctx.message {
332 if let Some(field) = key.strip_prefix("consumer.") {
333 obj.insert(field.to_string(), value.clone());
334 }
335 }
336 if obj.is_empty() {
337 None
338 } else {
339 Some(serde_json::Value::Object(obj))
340 }
341}
342
343fn split_host_port(host: &str, scheme: &str) -> (String, u16) {
346 let default_port = if scheme.eq_ignore_ascii_case("https") {
347 443
348 } else {
349 80
350 };
351 if let Some(stripped) = host.strip_prefix('[') {
353 if let Some((h, rest)) = stripped.split_once(']') {
354 let port = rest
355 .strip_prefix(':')
356 .and_then(|p| p.parse().ok())
357 .unwrap_or(default_port);
358 return (h.to_string(), port);
359 }
360 }
361 match host.rsplit_once(':') {
362 Some((h, port)) if !h.contains(':') => {
364 (h.to_string(), port.parse().unwrap_or(default_port))
365 }
366 _ => (host.to_string(), default_port),
367 }
368}
369
370fn split_addr_port(addr: &str) -> (String, Option<u16>) {
373 if let Some(stripped) = addr.strip_prefix('[') {
374 if let Some((ip, rest)) = stripped.split_once(']') {
375 return (
376 ip.to_string(),
377 rest.strip_prefix(':').and_then(|p| p.parse().ok()),
378 );
379 }
380 }
381 match addr.rsplit_once(':') {
382 Some((ip, port)) if !ip.contains(':') => (ip.to_string(), port.parse().ok()),
383 _ => (addr.to_string(), None),
384 }
385}
386
387#[async_trait]
388impl Plugin for OpaPlugin {
389 fn plugin_type(&self) -> &str {
390 "opa"
391 }
392
393 async fn execute(
394 &self,
395 mut ctx: Context,
396 _named_inputs: &HashMap<String, serde_json::Value>,
397 ) -> PluginResult {
398 let now = SystemTime::now()
399 .duration_since(UNIX_EPOCH)
400 .map(|d| d.as_secs())
401 .unwrap_or(0);
402 let input = self.build_opa_input(&ctx, now);
403 let body = match serde_json::to_vec(&input) {
404 Ok(b) => Bytes::from(b),
405 Err(e) => {
406 return Err(self.build_error(
407 ctx,
408 503,
409 format!("failed to encode OPA input: {}", e),
410 ))
411 }
412 };
413
414 let url = format!("{}/v1/data/{}", self.host, self.policy);
415 let request = OutboundRequest {
416 method: http::Method::POST,
417 url,
418 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
419 body,
420 timeout: self.timeout,
421 ssl_verify: self.ssl_verify,
422 tls: None,
423 };
424
425 let response = match self.client.request(request).await {
427 Ok(resp) => resp,
428 Err(e) => return Err(self.build_error(ctx, 403, format!("OPA callout failed: {}", e))),
429 };
430
431 let decision = match parse_opa_response(&response.body) {
432 Ok(d) => d,
433 Err(e) => {
434 let message = match e {
435 OpaParseError::NotJson => "OPA response was not valid JSON".to_string(),
436 OpaParseError::MissingResult => {
437 "OPA response missing 'result' field".to_string()
438 }
439 };
440 return Err(self.build_error(ctx, 503, message));
441 }
442 };
443
444 if !decision.allow {
445 return Err(self.build_deny(ctx, &decision));
446 }
447
448 self.apply_allow(&mut ctx, &decision);
449 Ok(PluginOutput {
450 context: ctx,
451 named_outputs: HashMap::new(),
452 })
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
460
461 fn test_ctx() -> Context {
462 let mut headers = HashMap::new();
463 headers.insert("accept".to_string(), vec!["application/json".to_string()]);
464 headers.insert(
465 "x-multi".to_string(),
466 vec!["a".to_string(), "b".to_string()],
467 );
468 let mut query = HashMap::new();
469 query.insert("q".to_string(), vec!["1".to_string()]);
470 let mut message = HashMap::new();
471 message.insert("consumer.name".to_string(), serde_json::json!("alice"));
472 message.insert(
473 "consumer.auth_type".to_string(),
474 serde_json::json!("key-auth"),
475 );
476 Context {
477 request: GatewayRequest {
478 method: "GET".to_string(),
479 path: "/api/users".to_string(),
480 host: "example.com:8080".to_string(),
481 scheme: "http".to_string(),
482 headers,
483 query_params: query,
484 body: Bytes::new(),
485 remote_addr: "10.0.0.7:5555".to_string(),
486 protocol: Protocol::Http1,
487 },
488 response: GatewayResponse {
489 status_code: 0,
490 headers: HashMap::new(),
491 body: Bytes::new(),
492 },
493 message,
494 errors: Vec::new(),
495 }
496 }
497
498 fn plugin(config: serde_json::Value) -> OpaPlugin {
499 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
500 OpaPlugin::from_config(&map, &PluginResources::empty()).unwrap()
501 }
502
503 #[test]
504 fn test_requires_host_and_policy() {
505 assert!(OpaPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
506 let mut map = HashMap::new();
507 map.insert("host".to_string(), serde_json::json!("http://opa"));
508 assert!(OpaPlugin::from_config(&map, &PluginResources::empty()).is_err());
509 map.insert("policy".to_string(), serde_json::json!("p/allow"));
510 assert!(OpaPlugin::from_config(&map, &PluginResources::empty()).is_ok());
511 }
512
513 #[test]
514 fn test_host_and_policy_trimmed() {
515 let p = plugin(serde_json::json!({ "host": "http://opa/", "policy": "/p/allow" }));
516 assert_eq!(p.host, "http://opa");
517 assert_eq!(p.policy, "p/allow");
518 }
519
520 #[test]
521 fn test_build_input_shape() {
522 let p = plugin(serde_json::json!({
523 "host": "http://opa",
524 "policy": "p/allow",
525 "with_consumer": true
526 }));
527 let input = p.build_opa_input(&test_ctx(), 1234);
528 let req = &input["input"]["request"];
529 assert_eq!(req["scheme"], serde_json::json!("http"));
530 assert_eq!(req["method"], serde_json::json!("GET"));
531 assert_eq!(req["host"], serde_json::json!("example.com"));
532 assert_eq!(req["port"], serde_json::json!(8080));
533 assert_eq!(req["path"], serde_json::json!("/api/users"));
534 assert_eq!(
536 req["headers"]["accept"],
537 serde_json::json!("application/json")
538 );
539 assert_eq!(req["headers"]["x-multi"], serde_json::json!(["a", "b"]));
540 assert_eq!(req["query"]["q"], serde_json::json!("1"));
541 assert_eq!(
542 input["input"]["var"]["remote_addr"],
543 serde_json::json!("10.0.0.7")
544 );
545 assert_eq!(
546 input["input"]["var"]["remote_port"],
547 serde_json::json!(5555)
548 );
549 assert_eq!(input["input"]["var"]["timestamp"], serde_json::json!(1234));
550 assert_eq!(
552 input["input"]["consumer"]["name"],
553 serde_json::json!("alice")
554 );
555 assert_eq!(
556 input["input"]["consumer"]["auth_type"],
557 serde_json::json!("key-auth")
558 );
559 }
560
561 #[test]
562 fn test_build_input_omits_consumer_when_disabled() {
563 let p = plugin(serde_json::json!({ "host": "http://opa", "policy": "p" }));
564 let input = p.build_opa_input(&test_ctx(), 0);
565 assert!(input["input"].get("consumer").is_none());
566 }
567
568 #[test]
569 fn test_parse_allow() {
570 let d = parse_opa_response(br#"{"result": {"allow": true}}"#).unwrap();
571 assert!(d.allow);
572 assert_eq!(d.status, None);
573 }
574
575 #[test]
576 fn test_parse_deny_with_details() {
577 let body = br#"{"result": {"allow": false, "status_code": 401,
578 "reason": "no", "headers": {"WWW-Authenticate": "Bearer"}}}"#;
579 let d = parse_opa_response(body).unwrap();
580 assert!(!d.allow);
581 assert_eq!(d.status, Some(401));
582 assert_eq!(d.reason.as_deref(), Some("no"));
583 assert_eq!(
584 d.headers.get("www-authenticate"),
585 Some(&"Bearer".to_string())
586 );
587 }
588
589 #[test]
590 fn test_parse_object_reason_encoded() {
591 let d =
592 parse_opa_response(br#"{"result": {"allow": false, "reason": {"m": "x"}}}"#).unwrap();
593 assert_eq!(d.reason.as_deref(), Some("{\"m\":\"x\"}"));
594 }
595
596 #[test]
597 fn test_parse_errors() {
598 assert_eq!(parse_opa_response(b"not json"), Err(OpaParseError::NotJson));
599 assert_eq!(
600 parse_opa_response(br#"{"foo": 1}"#),
601 Err(OpaParseError::MissingResult)
602 );
603 let d = parse_opa_response(br#"{"result": {}}"#).unwrap();
605 assert!(!d.allow);
606 }
607
608 #[test]
609 fn test_build_deny_applies_status_headers_reason() {
610 let p = plugin(serde_json::json!({ "host": "http://opa", "policy": "p" }));
611 let decision = OpaDecision {
612 allow: false,
613 status: Some(401),
614 reason: Some("denied".to_string()),
615 headers: HashMap::from([("x-why".to_string(), "policy".to_string())]),
616 };
617 let err = p.build_deny(test_ctx(), &decision);
618 assert_eq!(err.error.code, "OPA_DENIED");
619 assert_eq!(err.context.response.status_code, 401);
620 assert_eq!(err.context.response.body, Bytes::from_static(b"denied"));
621 assert_eq!(
622 err.context.response.headers.get("x-why"),
623 Some(&vec!["policy".to_string()])
624 );
625 }
626
627 #[test]
628 fn test_apply_allow_sets_and_removes() {
629 let p = plugin(serde_json::json!({
630 "host": "http://opa",
631 "policy": "p",
632 "send_headers_upstream": ["X-User-Id", "X-Absent"]
633 }));
634 let mut ctx = test_ctx();
635 ctx.request
636 .headers
637 .insert("x-absent".to_string(), vec!["stale".to_string()]);
638 let decision = OpaDecision {
639 allow: true,
640 status: None,
641 reason: None,
642 headers: HashMap::from([("x-user-id".to_string(), "u42".to_string())]),
643 };
644 p.apply_allow(&mut ctx, &decision);
645 assert_eq!(
646 ctx.request.headers.get("x-user-id"),
647 Some(&vec!["u42".to_string()])
648 );
649 assert!(!ctx.request.headers.contains_key("x-absent"));
650 }
651
652 #[test]
653 fn test_split_host_port() {
654 assert_eq!(split_host_port("h:8080", "http"), ("h".to_string(), 8080));
655 assert_eq!(split_host_port("h", "https"), ("h".to_string(), 443));
656 assert_eq!(split_host_port("h", "http"), ("h".to_string(), 80));
657 assert_eq!(
658 split_host_port("[::1]:9000", "http"),
659 ("::1".to_string(), 9000)
660 );
661 }
662}