featherbit/plugins/native/
acl.rs1use async_trait::async_trait;
16use bytes::Bytes;
17use std::collections::HashMap;
18
19use crate::context::{Context, GatewayError};
20use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
21
22pub struct AclPlugin {
31 allowed_by: Vec<String>,
33 denied_by: Vec<String>,
35 rejected_code: u16,
37 rejected_msg: Option<String>,
39}
40
41impl AclPlugin {
42 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
61 let string_list = |key: &str| -> Vec<String> {
62 config
63 .get(key)
64 .and_then(|v| v.as_array())
65 .map(|seq| {
66 seq.iter()
67 .filter_map(|v| v.as_str().map(String::from))
68 .collect()
69 })
70 .unwrap_or_default()
71 };
72
73 let allowed_by = string_list("allowed_by");
74 let denied_by = string_list("denied_by");
75
76 if allowed_by.is_empty() && denied_by.is_empty() {
77 return Err("acl: at least one of 'allowed_by' or 'denied_by' is required".to_string());
78 }
79
80 let rejected_code = config
81 .get("rejected_code")
82 .and_then(|v| v.as_u64())
83 .map(|c| c as u16)
84 .unwrap_or(403);
85
86 let rejected_msg = config
87 .get("rejected_msg")
88 .and_then(|v| v.as_str())
89 .map(String::from);
90
91 Ok(Self {
92 allowed_by,
93 denied_by,
94 rejected_code,
95 rejected_msg,
96 })
97 }
98
99 fn reject(&self, mut ctx: Context, status: u16, message: String) -> PluginResult {
101 ctx.response.status_code = status;
102 ctx.response.body = Bytes::from(serde_json::json!({ "message": message }).to_string());
103 ctx.response.headers.insert(
104 "content-type".to_string(),
105 vec!["application/json".to_string()],
106 );
107 Err(PluginExecutionError {
108 context: ctx,
109 error: GatewayError {
110 node_id: String::new(),
111 code: "ACL_DENIED".to_string(),
112 message,
113 metadata: HashMap::new(),
114 },
115 })
116 }
117}
118
119#[async_trait]
120impl Plugin for AclPlugin {
121 fn plugin_type(&self) -> &str {
122 "acl"
123 }
124
125 async fn execute(
126 &self,
127 ctx: Context,
128 _named_inputs: &HashMap<String, serde_json::Value>,
129 ) -> PluginResult {
130 if ctx
132 .message
133 .get("consumer.name")
134 .and_then(|v| v.as_str())
135 .is_none()
136 {
137 return self.reject(ctx, 401, "Missing authentication.".to_string());
138 }
139
140 let group = ctx
141 .message
142 .get("consumer.group")
143 .and_then(|v| v.as_str())
144 .map(String::from);
145
146 let reject_msg = || {
147 self.rejected_msg
148 .clone()
149 .unwrap_or_else(|| "The consumer is forbidden.".to_string())
150 };
151
152 if let Some(ref g) = group {
154 if self.denied_by.contains(g) {
155 return self.reject(ctx, self.rejected_code, reject_msg());
156 }
157 }
158
159 if !self.allowed_by.is_empty() {
161 let allowed = group.as_ref().is_some_and(|g| self.allowed_by.contains(g));
162 if !allowed {
163 return self.reject(ctx, self.rejected_code, reject_msg());
164 }
165 }
166
167 Ok(PluginOutput {
168 context: ctx,
169 named_outputs: HashMap::new(),
170 })
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
178
179 fn ctx(consumer_name: Option<&str>, group: Option<&str>) -> Context {
180 let mut message = HashMap::new();
181 if let Some(n) = consumer_name {
182 message.insert("consumer.name".to_string(), serde_json::json!(n));
183 }
184 if let Some(g) = group {
185 message.insert("consumer.group".to_string(), serde_json::json!(g));
186 }
187 Context {
188 request: GatewayRequest {
189 method: "GET".to_string(),
190 path: "/".to_string(),
191 host: "h".to_string(),
192 scheme: "http".to_string(),
193 headers: HashMap::new(),
194 query_params: HashMap::new(),
195 body: Bytes::new(),
196 remote_addr: "1.2.3.4:5".to_string(),
197 protocol: Protocol::Http1,
198 },
199 response: GatewayResponse {
200 status_code: 0,
201 headers: HashMap::new(),
202 body: Bytes::new(),
203 },
204 message,
205 errors: Vec::new(),
206 }
207 }
208
209 fn plugin(config: serde_json::Value) -> AclPlugin {
210 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
211 AclPlugin::from_config(&map).unwrap()
212 }
213
214 #[tokio::test]
215 async fn test_allowed_by() {
216 let p = plugin(serde_json::json!({ "allowed_by": ["partners"] }));
217 assert!(p
218 .execute(ctx(Some("alice"), Some("partners")), &HashMap::new())
219 .await
220 .is_ok());
221 let err = p
223 .execute(ctx(Some("alice"), Some("randoms")), &HashMap::new())
224 .await
225 .unwrap_err();
226 assert_eq!(err.error.code, "ACL_DENIED");
227 assert_eq!(err.context.response.status_code, 403);
228 assert!(p
230 .execute(ctx(Some("alice"), None), &HashMap::new())
231 .await
232 .is_err());
233 }
234
235 #[tokio::test]
236 async fn test_denied_by_wins() {
237 let p = plugin(serde_json::json!({
238 "allowed_by": ["partners", "banned"],
239 "denied_by": ["banned"]
240 }));
241 assert!(p
242 .execute(ctx(Some("alice"), Some("partners")), &HashMap::new())
243 .await
244 .is_ok());
245 assert!(p
247 .execute(ctx(Some("bob"), Some("banned")), &HashMap::new())
248 .await
249 .is_err());
250 }
251
252 #[tokio::test]
253 async fn test_denied_by_only() {
254 let p = plugin(serde_json::json!({ "denied_by": ["banned"] }));
255 assert!(p
257 .execute(ctx(Some("alice"), None), &HashMap::new())
258 .await
259 .is_ok());
260 assert!(p
261 .execute(ctx(Some("bob"), Some("banned")), &HashMap::new())
262 .await
263 .is_err());
264 }
265
266 #[tokio::test]
267 async fn test_no_consumer_attached_401() {
268 let p = plugin(serde_json::json!({ "allowed_by": ["partners"] }));
269 let err = p
270 .execute(ctx(None, None), &HashMap::new())
271 .await
272 .unwrap_err();
273 assert_eq!(err.context.response.status_code, 401);
274 assert_eq!(err.error.code, "ACL_DENIED");
275 }
276
277 #[test]
278 fn test_requires_a_list() {
279 let map: HashMap<String, serde_json::Value> =
280 serde_json::from_value(serde_json::json!({})).unwrap();
281 assert!(AclPlugin::from_config(&map).is_err());
282 }
283}