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