featherbit/plugins/native/
consumer_restriction.rs1use 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#[derive(Debug, Clone, Copy, PartialEq)]
20enum RestrictionType {
21 ConsumerName,
23 ConsumerGroupId,
25}
26
27struct AllowedByMethod {
29 user: String,
31 methods: Vec<String>,
33}
34
35pub struct ConsumerRestrictionPlugin {
44 restriction_type: RestrictionType,
46 whitelist: Vec<String>,
48 blacklist: Vec<String>,
50 allowed_by_methods: Vec<AllowedByMethod>,
52 rejected_code: u16,
54 rejected_msg: Option<Template>,
58}
59
60impl ConsumerRestrictionPlugin {
61 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 .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 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 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 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 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 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 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 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 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 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 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 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 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}