Skip to main content

featherbit/plugins/native/
opa.rs

1//! Open Policy Agent authorization plugin (`opa`).
2//!
3//! Delegates the access decision for each request to an external OPA server.
4//! The plugin builds an OPA *input document* describing the request (and,
5//! optionally, the matched consumer), POSTs it to
6//! `<host>/v1/data/<policy>`, and interprets the decision under `result`:
7//! `allow: true` continues (optionally copying selected OPA-provided headers
8//! onto the request forwarded upstream), while `allow: false`/missing denies
9//! (exits on the `denied` port), honoring OPA-supplied status, headers, and
10//! reason. A callout or response-parse failure is a genuine infrastructure
11//! failure — it exits on the `error` port and blocks the request by default.
12//!
13//! Ports the APISIX `opa` plugin (and its `opa/helper.lua` input builder) onto
14//! featherbit's shared outbound HTTP client.
15//!
16//! ## Deviations from APISIX
17//!
18//! featherbit has no route/service objects, so `with_route` and `with_service`
19//! are accepted for config compatibility but are **no-ops** (no `route` /
20//! `service` object is added to the input document). The input `var` block
21//! omits `server_addr` / `server_port`, which featherbit does not track in the
22//! request context.
23
24use 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
36/// Sends an OPA input document to an external policy server and routes on the
37/// returned decision: `allow: true` continues (`success` port), `allow: false`
38/// denies (`denied` port), and a callout/parse failure exits on `error`.
39pub struct OpaPlugin {
40    /// OPA base URL (e.g. `http://opa:8181`).
41    host: String,
42    /// Decision path appended as `/v1/data/<policy>`.
43    policy: String,
44    /// TLS certificate verification for `https` callouts.
45    ssl_verify: bool,
46    /// Whole-call callout deadline.
47    timeout: Duration,
48    /// Include the matched consumer object in the input document.
49    with_consumer: bool,
50    /// OPA-response header names copied onto the request forwarded upstream on
51    /// allow (rendered per request, then lowercased). Empty means none are
52    /// copied. Supports `{{namespace.path}}` template references.
53    send_headers_upstream: Vec<Template>,
54    /// Shared pooled HTTP client (from [`PluginResources`]).
55    client: Arc<OutboundClient>,
56}
57
58/// A parsed OPA decision (the `result` object of the response).
59#[derive(Debug, PartialEq)]
60struct OpaDecision {
61    allow: bool,
62    /// Deny status from `result.status_code` (or `result.status`), if present.
63    status: Option<u16>,
64    /// Stringified `result.reason`, if present (objects are JSON-encoded).
65    reason: Option<String>,
66    /// `result.headers` as lowercased name→value pairs.
67    headers: HashMap<String, String>,
68}
69
70/// Why an OPA response could not be interpreted as a decision.
71#[derive(Debug, PartialEq)]
72enum OpaParseError {
73    /// Body was not valid JSON.
74    NotJson,
75    /// Body parsed but had no `result` object.
76    MissingResult,
77}
78
79impl OpaPlugin {
80    /// Builds the plugin from node config.
81    ///
82    /// Accepted keys:
83    /// - `host` (string, **required**): OPA base URL. Missing → config error.
84    /// - `policy` (string, **required**): decision path appended as
85    ///   `/v1/data/<policy>`. Missing → config error.
86    /// - `ssl_verify` (bool, default `true`): verify TLS certificates for
87    ///   `https` callouts.
88    /// - `timeout` (integer ms, default `3000`): whole-call callout deadline.
89    /// - `with_consumer` (bool, default `false`): include a `consumer` object
90    ///   (built from `context.message`'s `consumer.*` keys) in the input
91    ///   document.
92    /// - `with_route` / `with_service` (bool, default `false`): accepted for
93    ///   APISIX compatibility but **no-ops** — featherbit has no route/service
94    ///   objects.
95    /// - `send_headers_upstream` (array of strings, optional): OPA-response
96    ///   header names copied onto the request forwarded upstream on allow. A
97    ///   configured name absent from the OPA response removes any
98    ///   client-supplied value.
99    ///
100    /// ```yaml
101    /// type: opa
102    /// config:
103    ///   host: http://opa:8181
104    ///   policy: example/allow
105    ///   with_consumer: true
106    ///   send_headers_upstream: [x-user-id]
107    ///   ssl_verify: true
108    ///   timeout: 3000
109    /// ```
110    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        // Discard warnings here — the compile-time walk (a later task)
148        // reports well-formed-but-unknown references; execution must not.
149        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    /// Builds the OPA input document from the context, mirroring
171    /// `opa/helper.lua`'s `build_opa_input` as closely as featherbit's
172    /// [`Context`] allows. `now_unix` is injected so the (otherwise
173    /// wall-clock) `var.timestamp` is testable.
174    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    /// On an allow decision, copies the configured `send_headers_upstream`
210    /// from the OPA response onto the request forwarded upstream. A configured
211    /// header absent from the OPA response removes any client-supplied value.
212    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    /// Builds the deny response from a deny decision, honoring OPA-supplied
227    /// status (default `403`), headers, and reason (as the body), and exits
228    /// on the `denied` port.
229    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    /// Builds the `OPA_ERROR` rejection used when the callout fails or the
244    /// response cannot be interpreted as a decision.
245    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
259/// Parses an OPA response body into an [`OpaDecision`]. Pure and network-free
260/// so the decision-mapping logic is unit-testable.
261fn 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
309/// Collapses a multi-valued header/query map into single strings where there
310/// is one value and arrays where there are several — mirroring OpenResty's
311/// `core.request.headers` / `get_uri_args` shape.
312fn 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
324/// Builds the `consumer` object from `context.message`'s `consumer.*` keys,
325/// stripping the prefix. Returns `None` when no consumer is attached.
326fn 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
340/// Splits a `Host` header into `(hostname, port)`, defaulting the port from
341/// the scheme when the host carries none.
342fn 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    // Bracketed IPv6 literal: [::1]:8080
349    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        // Bare IPv6 (multiple colons) — no port.
360        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
367/// Splits a client `ip:port` into `(ip, port)`, tolerating bare IPs and
368/// bracketed IPv6.
369fn 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        // Block by default when the decision is unavailable.
419        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        // single header collapses to string, multi stays array
526        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        // consumer built from message consumer.* keys
542        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        // missing allow defaults to false (deny)
595        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}