featherbit/plugins/native/
consumer_restriction.rs1use 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#[derive(Debug, Clone, Copy, PartialEq)]
19enum RestrictionType {
20 ConsumerName,
22 ConsumerGroupId,
24}
25
26struct AllowedByMethod {
28 user: String,
30 methods: Vec<String>,
32}
33
34pub struct ConsumerRestrictionPlugin {
43 restriction_type: RestrictionType,
45 whitelist: Vec<String>,
47 blacklist: Vec<String>,
49 allowed_by_methods: Vec<AllowedByMethod>,
51 rejected_code: u16,
53 rejected_msg: Option<String>,
55}
56
57impl ConsumerRestrictionPlugin {
58 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 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 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 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 if !self.blacklist.is_empty() && self.blacklist.contains(&value) {
250 return self.reject(ctx, self.rejected_code, default_reject_msg());
251 }
252
253 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 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 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 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 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 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 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 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}