Skip to main content

featherbit/plugins/native/
attach_consumer_label.rs

1//! Consumer-label header injection plugin (`attach-consumer-label`).
2//!
3//! Copies the attached consumer's labels into upstream request headers so
4//! backends can see who is calling without re-reading credentials. It does not
5//! authenticate — an upstream auth node (e.g. `key-auth`) must have attached
6//! the consumer identity first, writing `consumer.labels` into
7//! `context.message`. Each label `k=v` becomes a request header
8//! `<header_prefix><k>: v`. With no consumer attached it is a pure passthrough.
9//! It never rejects.
10
11use async_trait::async_trait;
12use std::collections::HashMap;
13
14use crate::context::Context;
15use crate::plugins::{Plugin, PluginOutput, PluginResult};
16use crate::vars::template::Template;
17
18/// Injects the consumer's labels as upstream request headers.
19///
20/// For every entry in `consumer.labels`, sets the request header
21/// `<header_prefix><label_key>` to the label value (header names are
22/// lowercased, matching how featherbit stores request headers). When no
23/// consumer/labels are present the request passes through untouched.
24pub struct AttachConsumerLabelPlugin {
25    /// Prefix prepended to each label key to form the header name. Supports
26    /// `{{namespace.path}}` template references, rendered per request.
27    header_prefix: Template,
28}
29
30impl AttachConsumerLabelPlugin {
31    /// Builds the plugin from node config. Never fails.
32    ///
33    /// Accepted keys:
34    /// - `header_prefix` (string, default `"X-Consumer-"`): prefix prepended to
35    ///   each label key to form the upstream header name. The combined name is
36    ///   lowercased, so a `tier` label with prefix `X-Consumer-` becomes the
37    ///   header `x-consumer-tier`. Supports `{{namespace.path}}` references.
38    ///
39    /// ```yaml
40    /// type: attach-consumer-label
41    /// config:
42    ///   header_prefix: "X-Consumer-"
43    /// ```
44    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
45        let header_prefix = config
46            .get("header_prefix")
47            .and_then(|v| v.as_str())
48            .unwrap_or("X-Consumer-");
49        // Discard warnings here — the compile-time walk (a later task)
50        // reports well-formed-but-unknown references; execution must not.
51        let header_prefix = Template::parse(header_prefix).0;
52
53        Ok(Self { header_prefix })
54    }
55}
56
57#[async_trait]
58impl Plugin for AttachConsumerLabelPlugin {
59    fn plugin_type(&self) -> &str {
60        "attach-consumer-label"
61    }
62
63    async fn execute(&self, mut ctx: Context) -> PluginResult {
64        // Only act when a consumer with labels is attached; otherwise passthrough.
65        if let Some(labels) = ctx
66            .message
67            .get("consumer.labels")
68            .and_then(|v| v.as_object())
69            .cloned()
70        {
71            let prefix = self.header_prefix.render(&ctx).into_owned();
72            for (key, value) in labels {
73                if let Some(value) = value.as_str() {
74                    let header = format!("{}{}", prefix, key).to_lowercase();
75                    ctx.request.headers.insert(header, vec![value.to_string()]);
76                }
77            }
78        }
79
80        Ok(PluginOutput::success(ctx))
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
88    use bytes::Bytes;
89
90    fn ctx(labels: Option<serde_json::Value>) -> Context {
91        let mut message = HashMap::new();
92        if let Some(labels) = labels {
93            message.insert("consumer.name".to_string(), serde_json::json!("alice"));
94            message.insert("consumer.labels".to_string(), labels);
95        }
96        Context {
97            request: GatewayRequest {
98                method: "GET".to_string(),
99                path: "/".to_string(),
100                host: "h".to_string(),
101                scheme: "http".to_string(),
102                headers: HashMap::new(),
103                query_params: HashMap::new(),
104                body: Bytes::new(),
105                remote_addr: "1.2.3.4:5".to_string(),
106                protocol: Protocol::Http1,
107            },
108            response: GatewayResponse {
109                status_code: 0,
110                headers: HashMap::new(),
111                body: Bytes::new(),
112                stream: None,
113            },
114            message,
115            errors: Vec::new(),
116        }
117    }
118
119    fn plugin(config: serde_json::Value) -> AttachConsumerLabelPlugin {
120        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
121        AttachConsumerLabelPlugin::from_config(&map).unwrap()
122    }
123
124    #[tokio::test]
125    async fn test_attaches_labels_with_default_prefix() {
126        let p = plugin(serde_json::json!({}));
127        let out = p
128            .execute(ctx(Some(
129                serde_json::json!({ "tier": "gold", "region": "eu" }),
130            )))
131            .await
132            .unwrap();
133        let headers = &out.context.request.headers;
134        assert_eq!(
135            headers.get("x-consumer-tier"),
136            Some(&vec!["gold".to_string()])
137        );
138        assert_eq!(
139            headers.get("x-consumer-region"),
140            Some(&vec!["eu".to_string()])
141        );
142    }
143
144    #[tokio::test]
145    async fn test_custom_prefix() {
146        let p = plugin(serde_json::json!({ "header_prefix": "X-Label-" }));
147        let out = p
148            .execute(ctx(Some(serde_json::json!({ "tier": "gold" }))))
149            .await
150            .unwrap();
151        assert_eq!(
152            out.context.request.headers.get("x-label-tier"),
153            Some(&vec!["gold".to_string()])
154        );
155    }
156
157    #[tokio::test]
158    async fn test_header_prefix_renders_template() {
159        let p = plugin(serde_json::json!({ "header_prefix": "X-{{request.headers.x-realm}}-" }));
160        let mut c = ctx(Some(serde_json::json!({ "tier": "gold" })));
161        c.request
162            .headers
163            .insert("x-realm".to_string(), vec!["Eu".to_string()]);
164        let out = p.execute(c).await.unwrap();
165        assert_eq!(
166            out.context.request.headers.get("x-eu-tier"),
167            Some(&vec!["gold".to_string()])
168        );
169    }
170
171    #[tokio::test]
172    async fn test_no_consumer_is_passthrough() {
173        let p = plugin(serde_json::json!({}));
174        let out = p.execute(ctx(None)).await.unwrap();
175        // no consumer -> no headers injected, no error
176        assert!(out
177            .context
178            .request
179            .headers
180            .keys()
181            .all(|k| !k.starts_with("x-consumer-")));
182    }
183}