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