Skip to main content

featherbit/plugins/native/
consumer_restriction.rs

1//! Consumer allow/deny list plugin (`consumer-restriction`).
2//!
3//! Restricts which already-authenticated consumers may reach a route. It does
4//! not authenticate — an upstream auth node (e.g. `key-auth`) must have
5//! attached the consumer identity first (the `consumer.*` keys in
6//! `context.message`). This plugin then matches the consumer's name or group
7//! against a configured whitelist/blacklist and, optionally, restricts a named
8//! consumer to specific HTTP methods.
9
10use async_trait::async_trait;
11use bytes::Bytes;
12use std::collections::HashMap;
13
14use crate::context::{Context, GatewayError};
15use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
16
17/// What consumer attribute the lists match against.
18#[derive(Debug, Clone, Copy, PartialEq)]
19enum RestrictionType {
20    /// Match `consumer.name`.
21    ConsumerName,
22    /// Match `consumer.group`.
23    ConsumerGroupId,
24}
25
26/// A per-consumer HTTP-method allowlist entry.
27struct AllowedByMethod {
28    /// Consumer name this restriction applies to.
29    user: String,
30    /// Uppercased HTTP methods the consumer may use.
31    methods: Vec<String>,
32}
33
34/// Restricts access based on the attached consumer's name or group.
35///
36/// Evaluation mirrors APISIX: the blacklist is checked first (a match
37/// rejects), then the whitelist (a non-match rejects), then — only for
38/// consumers not already cleared by the whitelist — `allowed_by_methods`. When
39/// no consumer is attached the request is rejected `401`; list rejections use
40/// `rejected_code` (default `403`). All rejections carry error code
41/// `CONSUMER_RESTRICTED`.
42pub struct ConsumerRestrictionPlugin {
43    /// Which consumer attribute the lists match (`consumer_name` / `consumer_group_id`).
44    restriction_type: RestrictionType,
45    /// When non-empty, the value must be present or the request is rejected.
46    whitelist: Vec<String>,
47    /// When non-empty, a matching value is rejected.
48    blacklist: Vec<String>,
49    /// Per-consumer HTTP-method allowlists.
50    allowed_by_methods: Vec<AllowedByMethod>,
51    /// HTTP status used for list rejections.
52    rejected_code: u16,
53    /// Optional custom rejection message.
54    rejected_msg: Option<String>,
55}
56
57impl ConsumerRestrictionPlugin {
58    /// Builds the plugin from node config, failing fast on invalid combinations.
59    ///
60    /// Accepted keys:
61    /// - `type` (string, default `"consumer_name"`): the consumer attribute the
62    ///   lists match. Supported: `consumer_name` (matches `consumer.name`) and
63    ///   `consumer_group_id` (matches `consumer.group`). `service_id` and
64    ///   `route_id` exist in APISIX but have no featherbit analogue and are
65    ///   rejected at config load.
66    /// - `whitelist` (array of strings): values allowed through; a consumer
67    ///   whose value is absent is rejected. Mutually exclusive with `blacklist`.
68    /// - `blacklist` (array of strings): values rejected on match.
69    /// - `allowed_by_methods` (array of `{ user, methods }`): restricts the
70    ///   named consumer to the listed HTTP methods.
71    /// - At least one of `whitelist` / `blacklist` / `allowed_by_methods` is
72    ///   required.
73    /// - `rejected_code` (integer, default `403`): status for list rejections.
74    /// - `rejected_msg` (string, optional): custom rejection message.
75    ///
76    /// ```yaml
77    /// type: consumer-restriction
78    /// config:
79    ///   type: consumer_name
80    ///   whitelist: ["alice", "bob"]
81    ///   allowed_by_methods:
82    ///     - user: alice
83    ///       methods: ["GET", "POST"]
84    ///   rejected_code: 403
85    ///   rejected_msg: "You shall not pass"
86    /// ```
87    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
88        let restriction_type = match config
89            .get("type")
90            .and_then(|v| v.as_str())
91            .unwrap_or("consumer_name")
92        {
93            "consumer_name" => RestrictionType::ConsumerName,
94            "consumer_group_id" => RestrictionType::ConsumerGroupId,
95            other @ ("service_id" | "route_id") => {
96                return Err(format!(
97                    "consumer-restriction: type '{other}' is not supported in featherbit; \
98                     use 'consumer_name' or 'consumer_group_id'"
99                ));
100            }
101            other => {
102                return Err(format!(
103                    "consumer-restriction: unknown type '{other}'; \
104                     expected 'consumer_name' or 'consumer_group_id'"
105                ));
106            }
107        };
108
109        let string_list = |key: &str| -> Vec<String> {
110            config
111                .get(key)
112                .and_then(|v| v.as_array())
113                .map(|seq| {
114                    seq.iter()
115                        .filter_map(|v| v.as_str().map(String::from))
116                        .collect()
117                })
118                .unwrap_or_default()
119        };
120
121        let whitelist = string_list("whitelist");
122        let blacklist = string_list("blacklist");
123
124        if !whitelist.is_empty() && !blacklist.is_empty() {
125            return Err(
126                "consumer-restriction: set only one of 'whitelist' or 'blacklist'".to_string(),
127            );
128        }
129
130        let allowed_by_methods = config
131            .get("allowed_by_methods")
132            .and_then(|v| v.as_array())
133            .map(|seq| {
134                seq.iter()
135                    .filter_map(|entry| {
136                        let user = entry.get("user")?.as_str()?.to_string();
137                        let methods = entry
138                            .get("methods")?
139                            .as_array()?
140                            .iter()
141                            .filter_map(|m| m.as_str().map(|s| s.to_uppercase()))
142                            .collect();
143                        Some(AllowedByMethod { user, methods })
144                    })
145                    .collect::<Vec<_>>()
146            })
147            .unwrap_or_default();
148
149        if whitelist.is_empty() && blacklist.is_empty() && allowed_by_methods.is_empty() {
150            return Err(
151                "consumer-restriction: at least one of 'whitelist', 'blacklist' or \
152                 'allowed_by_methods' is required"
153                    .to_string(),
154            );
155        }
156
157        let rejected_code = config
158            .get("rejected_code")
159            .and_then(|v| v.as_u64())
160            .map(|c| c as u16)
161            .unwrap_or(403);
162
163        let rejected_msg = config
164            .get("rejected_msg")
165            .and_then(|v| v.as_str())
166            .map(String::from);
167
168        Ok(Self {
169            restriction_type,
170            whitelist,
171            blacklist,
172            allowed_by_methods,
173            rejected_code,
174            rejected_msg,
175        })
176    }
177
178    /// The `context.message` key holding the value this instance matches on.
179    fn value_key(&self) -> &'static str {
180        match self.restriction_type {
181            RestrictionType::ConsumerName => "consumer.name",
182            RestrictionType::ConsumerGroupId => "consumer.group",
183        }
184    }
185
186    /// Human label for the configured type, used in default messages.
187    fn type_label(&self) -> &'static str {
188        match self.restriction_type {
189            RestrictionType::ConsumerName => "consumer_name",
190            RestrictionType::ConsumerGroupId => "consumer_group_id",
191        }
192    }
193
194    /// Builds a rejection carrying the context so the graph routes the error port.
195    fn reject(&self, mut ctx: Context, status: u16, message: String) -> PluginResult {
196        ctx.response.status_code = status;
197        ctx.response.body = Bytes::from(serde_json::json!({ "message": message }).to_string());
198        ctx.response.headers.insert(
199            "content-type".to_string(),
200            vec!["application/json".to_string()],
201        );
202        Err(PluginExecutionError {
203            context: ctx,
204            error: GatewayError {
205                node_id: String::new(),
206                code: "CONSUMER_RESTRICTED".to_string(),
207                message,
208                metadata: HashMap::new(),
209            },
210        })
211    }
212}
213
214#[async_trait]
215impl Plugin for ConsumerRestrictionPlugin {
216    fn plugin_type(&self) -> &str {
217        "consumer-restriction"
218    }
219
220    async fn execute(
221        &self,
222        ctx: Context,
223        _named_inputs: &HashMap<String, serde_json::Value>,
224    ) -> PluginResult {
225        let value = ctx
226            .message
227            .get(self.value_key())
228            .and_then(|v| v.as_str())
229            .map(String::from);
230
231        let value = match value {
232            Some(v) => v,
233            None => {
234                let msg = format!(
235                    "The request is rejected, please check the {} for this request",
236                    self.type_label()
237                );
238                return self.reject(ctx, 401, msg);
239            }
240        };
241
242        let default_reject_msg = || {
243            self.rejected_msg
244                .clone()
245                .unwrap_or_else(|| format!("The {} is forbidden.", self.type_label()))
246        };
247
248        // Blacklist first.
249        if !self.blacklist.is_empty() && self.blacklist.contains(&value) {
250            return self.reject(ctx, self.rejected_code, default_reject_msg());
251        }
252
253        // Whitelist.
254        let mut whitelisted = false;
255        if !self.whitelist.is_empty() {
256            whitelisted = self.whitelist.contains(&value);
257            if !whitelisted {
258                return self.reject(ctx, self.rejected_code, default_reject_msg());
259            }
260        }
261
262        // Per-consumer method restriction (skipped when already whitelisted).
263        if !self.allowed_by_methods.is_empty() && !whitelisted {
264            let method = ctx.request.method.to_uppercase();
265            let entry = self.allowed_by_methods.iter().find(|e| e.user == value);
266            if let Some(entry) = entry {
267                if !entry.methods.contains(&method) {
268                    return self.reject(ctx, self.rejected_code, default_reject_msg());
269                }
270            }
271        }
272
273        Ok(PluginOutput {
274            context: ctx,
275            named_outputs: HashMap::new(),
276        })
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
284
285    fn ctx(method: &str, consumer_name: Option<&str>, group: Option<&str>) -> Context {
286        let mut message = HashMap::new();
287        if let Some(n) = consumer_name {
288            message.insert("consumer.name".to_string(), serde_json::json!(n));
289        }
290        if let Some(g) = group {
291            message.insert("consumer.group".to_string(), serde_json::json!(g));
292        }
293        Context {
294            request: GatewayRequest {
295                method: method.to_string(),
296                path: "/".to_string(),
297                host: "h".to_string(),
298                scheme: "http".to_string(),
299                headers: HashMap::new(),
300                query_params: HashMap::new(),
301                body: Bytes::new(),
302                remote_addr: "1.2.3.4:5".to_string(),
303                protocol: Protocol::Http1,
304            },
305            response: GatewayResponse {
306                status_code: 0,
307                headers: HashMap::new(),
308                body: Bytes::new(),
309            },
310            message,
311            errors: Vec::new(),
312        }
313    }
314
315    fn plugin(config: serde_json::Value) -> ConsumerRestrictionPlugin {
316        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
317        ConsumerRestrictionPlugin::from_config(&map).unwrap()
318    }
319
320    #[tokio::test]
321    async fn test_consumer_restriction_whitelist() {
322        let p = plugin(serde_json::json!({ "whitelist": ["alice", "bob"] }));
323        assert!(p
324            .execute(ctx("GET", Some("alice"), None), &HashMap::new())
325            .await
326            .is_ok());
327        let err = p
328            .execute(ctx("GET", Some("mallory"), None), &HashMap::new())
329            .await
330            .unwrap_err();
331        assert_eq!(err.error.code, "CONSUMER_RESTRICTED");
332        assert_eq!(err.context.response.status_code, 403);
333    }
334
335    #[tokio::test]
336    async fn test_consumer_restriction_blacklist() {
337        let p = plugin(serde_json::json!({ "blacklist": ["mallory"] }));
338        assert!(p
339            .execute(ctx("GET", Some("alice"), None), &HashMap::new())
340            .await
341            .is_ok());
342        assert!(p
343            .execute(ctx("GET", Some("mallory"), None), &HashMap::new())
344            .await
345            .is_err());
346    }
347
348    #[tokio::test]
349    async fn test_consumer_restriction_group() {
350        let p =
351            plugin(serde_json::json!({ "type": "consumer_group_id", "whitelist": ["partners"] }));
352        assert!(p
353            .execute(ctx("GET", Some("alice"), Some("partners")), &HashMap::new())
354            .await
355            .is_ok());
356        assert!(p
357            .execute(ctx("GET", Some("alice"), Some("randoms")), &HashMap::new())
358            .await
359            .is_err());
360    }
361
362    #[tokio::test]
363    async fn test_no_consumer_attached_401() {
364        let p = plugin(serde_json::json!({ "whitelist": ["alice"] }));
365        let err = p
366            .execute(ctx("GET", None, None), &HashMap::new())
367            .await
368            .unwrap_err();
369        assert_eq!(err.context.response.status_code, 401);
370        assert_eq!(err.error.code, "CONSUMER_RESTRICTED");
371    }
372
373    #[tokio::test]
374    async fn test_allowed_by_methods() {
375        let p = plugin(serde_json::json!({
376            "allowed_by_methods": [{ "user": "alice", "methods": ["GET"] }]
377        }));
378        // alice restricted to GET
379        assert!(p
380            .execute(ctx("GET", Some("alice"), None), &HashMap::new())
381            .await
382            .is_ok());
383        assert!(p
384            .execute(ctx("POST", Some("alice"), None), &HashMap::new())
385            .await
386            .is_err());
387        // bob has no entry -> unrestricted
388        assert!(p
389            .execute(ctx("DELETE", Some("bob"), None), &HashMap::new())
390            .await
391            .is_ok());
392    }
393
394    #[tokio::test]
395    async fn test_whitelist_bypasses_method_check() {
396        let p = plugin(serde_json::json!({
397            "whitelist": ["alice"],
398            "allowed_by_methods": [{ "user": "alice", "methods": ["GET"] }]
399        }));
400        // whitelisted -> method restriction skipped
401        assert!(p
402            .execute(ctx("POST", Some("alice"), None), &HashMap::new())
403            .await
404            .is_ok());
405    }
406
407    #[test]
408    fn test_config_validation() {
409        // service_id rejected
410        let map: HashMap<String, serde_json::Value> =
411            serde_json::from_value(serde_json::json!({ "type": "service_id", "whitelist": ["x"] }))
412                .unwrap();
413        assert!(ConsumerRestrictionPlugin::from_config(&map).is_err());
414
415        // both lists rejected
416        let map: HashMap<String, serde_json::Value> =
417            serde_json::from_value(serde_json::json!({ "whitelist": ["a"], "blacklist": ["b"] }))
418                .unwrap();
419        assert!(ConsumerRestrictionPlugin::from_config(&map).is_err());
420
421        // nothing configured rejected
422        let map: HashMap<String, serde_json::Value> =
423            serde_json::from_value(serde_json::json!({})).unwrap();
424        assert!(ConsumerRestrictionPlugin::from_config(&map).is_err());
425    }
426}