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;
20use crate::plugins::{Plugin, PluginOutput, PluginResult};
21use crate::vars::template::Template;
22
23/// Admits or blocks requests based on the attached consumer's group.
24///
25/// Evaluation: with no consumer attached the request is rejected `401`. The
26/// `denied_by` list is checked first (deny wins) — a consumer whose group is
27/// listed is rejected. Then, when `allowed_by` is non-empty, a consumer whose
28/// group is not listed (including a consumer with no group) is rejected. List
29/// rejections use `rejected_code` (default `403`) and are routed through the
30/// node's `denied` port.
31pub struct AclPlugin {
32    /// Groups permitted through; when non-empty, all other groups are rejected.
33    allowed_by: Vec<String>,
34    /// Groups blocked; a match rejects regardless of `allowed_by`.
35    denied_by: Vec<String>,
36    /// HTTP status used for list rejections.
37    rejected_code: u16,
38    /// Optional custom rejection message. Supports `{{namespace.path}}`
39    /// references (no legacy `$var` interpolation — this field never
40    /// supported it, so this sweep must not start).
41    rejected_msg: Option<Template>,
42}
43
44impl AclPlugin {
45    /// Builds the plugin from node config, failing fast when neither list is set.
46    ///
47    /// Accepted keys:
48    /// - `allowed_by` (array of strings): consumer groups permitted through.
49    ///   When non-empty, a consumer whose group is not listed is rejected.
50    /// - `denied_by` (array of strings): consumer groups blocked. Checked
51    ///   before `allowed_by` — deny wins.
52    /// - At least one of `allowed_by` / `denied_by` is required.
53    /// - `rejected_code` (integer, default `403`): status for list rejections.
54    /// - `rejected_msg` (string, optional): custom rejection message.
55    ///   Supports `{{namespace.path}}` references.
56    ///
57    /// ```yaml
58    /// type: acl
59    /// config:
60    ///   allowed_by: ["partners", "internal"]
61    ///   denied_by: ["banned"]
62    ///   rejected_code: 403
63    /// ```
64    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
65        let string_list = |key: &str| -> Vec<String> {
66            config
67                .get(key)
68                .and_then(|v| v.as_array())
69                .map(|seq| {
70                    seq.iter()
71                        .filter_map(|v| v.as_str().map(String::from))
72                        .collect()
73                })
74                .unwrap_or_default()
75        };
76
77        let allowed_by = string_list("allowed_by");
78        let denied_by = string_list("denied_by");
79
80        if allowed_by.is_empty() && denied_by.is_empty() {
81            return Err("acl: at least one of 'allowed_by' or 'denied_by' is required".to_string());
82        }
83
84        let rejected_code = config
85            .get("rejected_code")
86            .and_then(|v| v.as_u64())
87            .map(|c| c as u16)
88            .unwrap_or(403);
89
90        let rejected_msg = config
91            .get("rejected_msg")
92            .and_then(|v| v.as_str())
93            // Discard warnings here — the compile-time walk (a later task)
94            // reports well-formed-but-unknown references; execution must not.
95            .map(|s| Template::parse(s).0);
96
97        Ok(Self {
98            allowed_by,
99            denied_by,
100            rejected_code,
101            rejected_msg,
102        })
103    }
104
105    /// Builds a rejection carrying the context so the graph routes it through
106    /// the `denied` port.
107    fn reject(&self, mut ctx: Context, status: u16, message: String) -> PluginResult {
108        ctx.response.status_code = status;
109        ctx.response.body = Bytes::from(serde_json::json!({ "message": message }).to_string());
110        ctx.response.headers.insert(
111            "content-type".to_string(),
112            vec!["application/json".to_string()],
113        );
114        Ok(PluginOutput::on_port(ctx, "denied"))
115    }
116}
117
118#[async_trait]
119impl Plugin for AclPlugin {
120    fn plugin_type(&self) -> &str {
121        "acl"
122    }
123
124    async fn execute(&self, ctx: Context) -> PluginResult {
125        // No consumer attached at all -> authentication is missing.
126        if ctx
127            .message
128            .get("consumer.name")
129            .and_then(|v| v.as_str())
130            .is_none()
131        {
132            return self.reject(ctx, 401, "Missing authentication.".to_string());
133        }
134
135        let group = ctx
136            .message
137            .get("consumer.group")
138            .and_then(|v| v.as_str())
139            .map(String::from);
140
141        let reject_msg = |ctx: &Context| {
142            self.rejected_msg
143                .as_ref()
144                .map(|t| t.render(ctx).into_owned())
145                .unwrap_or_else(|| "The consumer is forbidden.".to_string())
146        };
147
148        // Deny wins.
149        if let Some(ref g) = group {
150            if self.denied_by.contains(g) {
151                let msg = reject_msg(&ctx);
152                return self.reject(ctx, self.rejected_code, msg);
153            }
154        }
155
156        // Allowlist: a consumer with no group, or a group not listed, is rejected.
157        if !self.allowed_by.is_empty() {
158            let allowed = group.as_ref().is_some_and(|g| self.allowed_by.contains(g));
159            if !allowed {
160                let msg = reject_msg(&ctx);
161                return self.reject(ctx, self.rejected_code, msg);
162            }
163        }
164
165        Ok(PluginOutput::success(ctx))
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
173
174    fn ctx(consumer_name: Option<&str>, group: Option<&str>) -> Context {
175        let mut message = HashMap::new();
176        if let Some(n) = consumer_name {
177            message.insert("consumer.name".to_string(), serde_json::json!(n));
178        }
179        if let Some(g) = group {
180            message.insert("consumer.group".to_string(), serde_json::json!(g));
181        }
182        Context {
183            request: GatewayRequest {
184                method: "GET".to_string(),
185                path: "/".to_string(),
186                host: "h".to_string(),
187                scheme: "http".to_string(),
188                headers: HashMap::new(),
189                query_params: HashMap::new(),
190                body: Bytes::new(),
191                remote_addr: "1.2.3.4:5".to_string(),
192                protocol: Protocol::Http1,
193            },
194            response: GatewayResponse {
195                status_code: 0,
196                headers: HashMap::new(),
197                body: Bytes::new(),
198                stream: None,
199            },
200            message,
201            errors: Vec::new(),
202        }
203    }
204
205    fn plugin(config: serde_json::Value) -> AclPlugin {
206        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
207        AclPlugin::from_config(&map).unwrap()
208    }
209
210    #[tokio::test]
211    async fn test_allowed_by() {
212        let p = plugin(serde_json::json!({ "allowed_by": ["partners"] }));
213        assert!(p
214            .execute(ctx(Some("alice"), Some("partners")))
215            .await
216            .unwrap()
217            .port
218            .is_none());
219        // group not in allowlist
220        let out = p
221            .execute(ctx(Some("alice"), Some("randoms")))
222            .await
223            .unwrap();
224        assert_eq!(out.port, Some("denied"));
225        assert_eq!(out.context.response.status_code, 403);
226        // consumer with no group is rejected under an allowlist
227        let out = p.execute(ctx(Some("alice"), None)).await.unwrap();
228        assert_eq!(out.port, Some("denied"));
229    }
230
231    #[tokio::test]
232    async fn test_denied_by_wins() {
233        let p = plugin(serde_json::json!({
234            "allowed_by": ["partners", "banned"],
235            "denied_by": ["banned"]
236        }));
237        assert!(p
238            .execute(ctx(Some("alice"), Some("partners")))
239            .await
240            .unwrap()
241            .port
242            .is_none());
243        // in allowlist but also denied -> deny wins
244        let out = p.execute(ctx(Some("bob"), Some("banned"))).await.unwrap();
245        assert_eq!(out.port, Some("denied"));
246    }
247
248    #[tokio::test]
249    async fn test_denied_by_only() {
250        let p = plugin(serde_json::json!({ "denied_by": ["banned"] }));
251        // no allowlist: everything not denied passes, incl. groupless consumers
252        assert!(p
253            .execute(ctx(Some("alice"), None))
254            .await
255            .unwrap()
256            .port
257            .is_none());
258        let out = p.execute(ctx(Some("bob"), Some("banned"))).await.unwrap();
259        assert_eq!(out.port, Some("denied"));
260    }
261
262    #[tokio::test]
263    async fn test_rejected_msg_renders_template() {
264        let p = plugin(serde_json::json!({
265            "allowed_by": ["partners"],
266            "rejected_msg": "denied group for {{request.path}}"
267        }));
268        let out = p
269            .execute(ctx(Some("alice"), Some("randoms")))
270            .await
271            .unwrap();
272        assert_eq!(out.port, Some("denied"));
273        let body: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
274        assert_eq!(body["message"], "denied group for /");
275    }
276
277    #[tokio::test]
278    async fn test_no_consumer_attached_401() {
279        let p = plugin(serde_json::json!({ "allowed_by": ["partners"] }));
280        let out = p.execute(ctx(None, None)).await.unwrap();
281        assert_eq!(out.port, Some("denied"));
282        assert_eq!(out.context.response.status_code, 401);
283    }
284
285    #[test]
286    fn test_requires_a_list() {
287        let map: HashMap<String, serde_json::Value> =
288            serde_json::from_value(serde_json::json!({})).unwrap();
289        assert!(AclPlugin::from_config(&map).is_err());
290    }
291}