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};
16
17/// Injects the consumer's labels as upstream request headers.
18///
19/// For every entry in `consumer.labels`, sets the request header
20/// `<header_prefix><label_key>` to the label value (header names are
21/// lowercased, matching how featherbit stores request headers). When no
22/// consumer/labels are present the request passes through untouched.
23pub struct AttachConsumerLabelPlugin {
24    /// Prefix prepended to each label key to form the header name.
25    header_prefix: String,
26}
27
28impl AttachConsumerLabelPlugin {
29    /// Builds the plugin from node config. Never fails.
30    ///
31    /// Accepted keys:
32    /// - `header_prefix` (string, default `"X-Consumer-"`): prefix prepended to
33    ///   each label key to form the upstream header name. The combined name is
34    ///   lowercased, so a `tier` label with prefix `X-Consumer-` becomes the
35    ///   header `x-consumer-tier`.
36    ///
37    /// ```yaml
38    /// type: attach-consumer-label
39    /// config:
40    ///   header_prefix: "X-Consumer-"
41    /// ```
42    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
43        let header_prefix = config
44            .get("header_prefix")
45            .and_then(|v| v.as_str())
46            .unwrap_or("X-Consumer-")
47            .to_string();
48
49        Ok(Self { header_prefix })
50    }
51}
52
53#[async_trait]
54impl Plugin for AttachConsumerLabelPlugin {
55    fn plugin_type(&self) -> &str {
56        "attach-consumer-label"
57    }
58
59    async fn execute(
60        &self,
61        mut ctx: Context,
62        _named_inputs: &HashMap<String, serde_json::Value>,
63    ) -> 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            for (key, value) in labels {
72                if let Some(value) = value.as_str() {
73                    let header = format!("{}{}", self.header_prefix, key).to_lowercase();
74                    ctx.request.headers.insert(header, vec![value.to_string()]);
75                }
76            }
77        }
78
79        Ok(PluginOutput {
80            context: ctx,
81            named_outputs: HashMap::new(),
82        })
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
90    use bytes::Bytes;
91
92    fn ctx(labels: Option<serde_json::Value>) -> Context {
93        let mut message = HashMap::new();
94        if let Some(labels) = labels {
95            message.insert("consumer.name".to_string(), serde_json::json!("alice"));
96            message.insert("consumer.labels".to_string(), labels);
97        }
98        Context {
99            request: GatewayRequest {
100                method: "GET".to_string(),
101                path: "/".to_string(),
102                host: "h".to_string(),
103                scheme: "http".to_string(),
104                headers: HashMap::new(),
105                query_params: HashMap::new(),
106                body: Bytes::new(),
107                remote_addr: "1.2.3.4:5".to_string(),
108                protocol: Protocol::Http1,
109            },
110            response: GatewayResponse {
111                status_code: 0,
112                headers: HashMap::new(),
113                body: Bytes::new(),
114            },
115            message,
116            errors: Vec::new(),
117        }
118    }
119
120    fn plugin(config: serde_json::Value) -> AttachConsumerLabelPlugin {
121        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
122        AttachConsumerLabelPlugin::from_config(&map).unwrap()
123    }
124
125    #[tokio::test]
126    async fn test_attaches_labels_with_default_prefix() {
127        let p = plugin(serde_json::json!({}));
128        let out = p
129            .execute(
130                ctx(Some(serde_json::json!({ "tier": "gold", "region": "eu" }))),
131                &HashMap::new(),
132            )
133            .await
134            .unwrap();
135        let headers = &out.context.request.headers;
136        assert_eq!(
137            headers.get("x-consumer-tier"),
138            Some(&vec!["gold".to_string()])
139        );
140        assert_eq!(
141            headers.get("x-consumer-region"),
142            Some(&vec!["eu".to_string()])
143        );
144    }
145
146    #[tokio::test]
147    async fn test_custom_prefix() {
148        let p = plugin(serde_json::json!({ "header_prefix": "X-Label-" }));
149        let out = p
150            .execute(
151                ctx(Some(serde_json::json!({ "tier": "gold" }))),
152                &HashMap::new(),
153            )
154            .await
155            .unwrap();
156        assert_eq!(
157            out.context.request.headers.get("x-label-tier"),
158            Some(&vec!["gold".to_string()])
159        );
160    }
161
162    #[tokio::test]
163    async fn test_no_consumer_is_passthrough() {
164        let p = plugin(serde_json::json!({}));
165        let out = p.execute(ctx(None), &HashMap::new()).await.unwrap();
166        // no consumer -> no headers injected, no error
167        assert!(out
168            .context
169            .request
170            .headers
171            .keys()
172            .all(|k| !k.starts_with("x-consumer-")));
173    }
174}