Skip to main content

featherbit/plugins/native/
acl.rs

1//! Group-based access control plugin (`acl`).
2//!
3//! Restricts which already-authenticated consumers may reach a route, keyed on
4//! the consumer's *group*. It does not authenticate — an upstream auth node
5//! (e.g. `key-auth`) must have attached the consumer identity first, writing
6//! `consumer.name` and (optionally) `consumer.group` into `context.message`.
7//! This plugin then admits or blocks the request based on that group.
8//!
9//! APISIX 3.17's `acl` matches arbitrary consumer *labels* (`allow_labels` /
10//! `deny_labels`, each a map of label→values). featherbit models a consumer's
11//! membership as a single `consumer.group`, so this port implements the
12//! classic group-allowlist form: `allowed_by` / `denied_by` are lists of group
13//! names. See the deviation note in the docs.
14
15use 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
22/// Admits or blocks requests based on the attached consumer's group.
23///
24/// Evaluation: with no consumer attached the request is rejected `401`. The
25/// `denied_by` list is checked first (deny wins) — a consumer whose group is
26/// listed is rejected. Then, when `allowed_by` is non-empty, a consumer whose
27/// group is not listed (including a consumer with no group) is rejected. List
28/// rejections use `rejected_code` (default `403`) and carry error code
29/// `ACL_DENIED`.
30pub struct AclPlugin {
31    /// Groups permitted through; when non-empty, all other groups are rejected.
32    allowed_by: Vec<String>,
33    /// Groups blocked; a match rejects regardless of `allowed_by`.
34    denied_by: Vec<String>,
35    /// HTTP status used for list rejections.
36    rejected_code: u16,
37    /// Optional custom rejection message.
38    rejected_msg: Option<String>,
39}
40
41impl AclPlugin {
42    /// Builds the plugin from node config, failing fast when neither list is set.
43    ///
44    /// Accepted keys:
45    /// - `allowed_by` (array of strings): consumer groups permitted through.
46    ///   When non-empty, a consumer whose group is not listed is rejected.
47    /// - `denied_by` (array of strings): consumer groups blocked. Checked
48    ///   before `allowed_by` — deny wins.
49    /// - At least one of `allowed_by` / `denied_by` is required.
50    /// - `rejected_code` (integer, default `403`): status for list rejections.
51    /// - `rejected_msg` (string, optional): custom rejection message.
52    ///
53    /// ```yaml
54    /// type: acl
55    /// config:
56    ///   allowed_by: ["partners", "internal"]
57    ///   denied_by: ["banned"]
58    ///   rejected_code: 403
59    /// ```
60    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    /// Builds a rejection carrying the context so the graph routes the error port.
100    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        // No consumer attached at all -> authentication is missing.
131        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        // Deny wins.
153        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        // Allowlist: a consumer with no group, or a group not listed, is rejected.
160        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        // group not in allowlist
222        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        // consumer with no group is rejected under an allowlist
229        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        // in allowlist but also denied -> deny wins
246        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        // no allowlist: everything not denied passes, incl. groupless consumers
256        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}