Skip to main content

featherbit/plugins/native/
authz_casbin.rs

1//! Embedded Casbin authorization plugin (`authz-casbin`).
2//!
3//! Ports Apache APISIX's `authz-casbin` plugin: an in-process ABAC/RBAC
4//! authorization gate backed by the [`casbin`] crate. No network calls are
5//! made — the model and policy are loaded from files on the gateway host or
6//! from inline strings, and every request is evaluated locally against the
7//! compiled [`Enforcer`].
8//!
9//! For each request the plugin derives a Casbin request tuple
10//! `(subject, object, action)` where:
11//! - **subject** is the authenticated consumer (`consumer.name` in
12//!   `context.message`) when present, otherwise the value of a configured
13//!   header (`username_header`, default `x-user`), otherwise `"anonymous"` —
14//!   mirroring APISIX's `headers[conf.username] or "anonymous"`;
15//! - **object** is the request path;
16//! - **action** is the request method.
17//!
18//! `enforcer.enforce((sub, obj, act))` decides the outcome: `true` lets the
19//! request continue through the **success** port; `false` rejects it with a
20//! `403` routed through the **error** port (code `AUTHZ_CASBIN_DENIED`).
21//!
22//! ## Enforcer construction (blocking at load)
23//!
24//! Casbin's [`Enforcer::new`] is async, but plugin `from_config` is sync and
25//! runs at config-load time. We build the enforcer on a dedicated short-lived
26//! thread that owns a current-thread Tokio runtime and `block_on`s the async
27//! construction. Running it on its own thread (rather than
28//! `Handle::block_on`/`futures::executor::block_on` on the current thread)
29//! avoids the "cannot start a runtime from within a runtime" panic when config
30//! is loaded from inside an existing Tokio context, and still gives Casbin a
31//! real Tokio runtime for the file-adapter's I/O. A bad model/policy fails
32//! fast here, at load, never at request time. The built enforcer is wrapped in
33//! an [`Arc`] and shared read-only across requests (`enforce` takes `&self`).
34
35use async_trait::async_trait;
36use bytes::Bytes;
37use std::collections::HashMap;
38use std::sync::Arc;
39
40use casbin::{CoreApi, DefaultModel, Enforcer, FileAdapter, StringAdapter};
41
42use crate::context::{Context, GatewayError};
43use crate::plugins::resources::PluginResources;
44use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
45
46/// Where the model and policy come from.
47enum EnforcerSource {
48    /// `model_path` + `policy_path`: files on the gateway host.
49    Files {
50        model_path: String,
51        policy_path: String,
52    },
53    /// `model` + `policy`: inline Casbin config / CSV policy strings.
54    Inline { model: String, policy: String },
55}
56
57/// Evaluates each request against a compiled Casbin model + policy.
58pub struct AuthzCasbinPlugin {
59    /// The compiled enforcer, shared read-only across requests.
60    enforcer: Arc<Enforcer>,
61    /// Lowercased header the subject falls back to when no consumer identity
62    /// is attached.
63    username_header: String,
64}
65
66impl AuthzCasbinPlugin {
67    /// Builds the plugin from node config, compiling the enforcer eagerly.
68    ///
69    /// Accepted keys (one of the two source pairs is required, matching
70    /// APISIX's `oneOf`):
71    /// - `model_path` (string) + `policy_path` (string): load the Casbin model
72    ///   and policy from files on the gateway host.
73    /// - `model` (string) + `policy` (string): inline Casbin model config and
74    ///   CSV policy text (loaded via Casbin's in-memory string adapter).
75    /// - `username_header` (string, default `"x-user"`): header the subject is
76    ///   read from when no consumer identity (`consumer.name`) is present;
77    ///   lowercased for the case-insensitive header map.
78    ///
79    /// Fails fast if neither source pair is fully provided or if Casbin rejects
80    /// the model/policy.
81    ///
82    /// ```yaml
83    /// # inline model + policy
84    /// - id: authz
85    ///   type: authz-casbin
86    ///   config:
87    ///     username_header: x-user
88    ///     model: |
89    ///       [request_definition]
90    ///       r = sub, obj, act
91    ///       [policy_definition]
92    ///       p = sub, obj, act
93    ///       [role_definition]
94    ///       g = _, _
95    ///       [policy_effect]
96    ///       e = some(where (p.eft == allow))
97    ///       [matchers]
98    ///       m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
99    ///     policy: |
100    ///       p, admin, /data, GET
101    ///       g, alice, admin
102    /// ```
103    ///
104    /// ```yaml
105    /// # model + policy files on the gateway host
106    /// - id: authz
107    ///   type: authz-casbin
108    ///   config:
109    ///     model_path: /etc/featherbit/model.conf
110    ///     policy_path: /etc/featherbit/policy.csv
111    ///     username_header: x-user
112    /// ```
113    pub fn from_config(
114        config: &HashMap<String, serde_json::Value>,
115        _resources: &Arc<PluginResources>,
116    ) -> Result<Self, String> {
117        let get_str = |key: &str| {
118            config
119                .get(key)
120                .and_then(|v| v.as_str())
121                .map(str::to_string)
122                .filter(|s| !s.is_empty())
123        };
124
125        let source = match (
126            get_str("model_path"),
127            get_str("policy_path"),
128            get_str("model"),
129            get_str("policy"),
130        ) {
131            (Some(model_path), Some(policy_path), _, _) => EnforcerSource::Files {
132                model_path,
133                policy_path,
134            },
135            (_, _, Some(model), Some(policy)) => EnforcerSource::Inline { model, policy },
136            _ => {
137                return Err(
138                    "authz-casbin requires either 'model_path' + 'policy_path' or \
139                     'model' + 'policy'"
140                        .to_string(),
141                )
142            }
143        };
144
145        let username_header = config
146            .get("username_header")
147            .and_then(|v| v.as_str())
148            .unwrap_or("x-user")
149            .to_lowercase();
150
151        let enforcer = build_enforcer(source)?;
152
153        Ok(Self {
154            enforcer: Arc::new(enforcer),
155            username_header,
156        })
157    }
158
159    /// Resolves the Casbin subject: the attached consumer identity if present,
160    /// else the configured header, else `"anonymous"`.
161    fn subject(&self, ctx: &Context) -> String {
162        if let Some(name) = ctx.message.get("consumer.name").and_then(|v| v.as_str()) {
163            return name.to_string();
164        }
165        ctx.request
166            .headers
167            .get(&self.username_header)
168            .and_then(|v| v.first())
169            .cloned()
170            .unwrap_or_else(|| "anonymous".to_string())
171    }
172
173    /// Builds the 403 denial carrying the context so the graph engine routes
174    /// through the error port.
175    fn deny(ctx: Context) -> PluginResult {
176        let mut ctx = ctx;
177        ctx.response.status_code = 403;
178        ctx.response.body = Bytes::from(r#"{"message":"Access Denied"}"#);
179        ctx.response.headers.insert(
180            "content-type".to_string(),
181            vec!["application/json".to_string()],
182        );
183        Err(PluginExecutionError {
184            context: ctx,
185            error: GatewayError {
186                node_id: String::new(),
187                code: "AUTHZ_CASBIN_DENIED".to_string(),
188                message: "Access denied by Casbin policy".to_string(),
189                metadata: HashMap::new(),
190            },
191        })
192    }
193}
194
195/// Compiles the enforcer on a dedicated thread with its own Tokio runtime.
196///
197/// See the module docs for why this indirection exists. Any construction
198/// failure (bad model config, missing/invalid policy file, malformed CSV) is
199/// surfaced as an `Err(String)` at config load.
200fn build_enforcer(source: EnforcerSource) -> Result<Enforcer, String> {
201    std::thread::spawn(move || -> Result<Enforcer, String> {
202        let rt = tokio::runtime::Builder::new_current_thread()
203            .enable_all()
204            .build()
205            .map_err(|e| format!("failed to build enforcer runtime: {e}"))?;
206
207        rt.block_on(async move {
208            match source {
209                EnforcerSource::Files {
210                    model_path,
211                    policy_path,
212                } => {
213                    let model = DefaultModel::from_file(&model_path)
214                        .await
215                        .map_err(|e| format!("failed to load Casbin model '{model_path}': {e}"))?;
216                    let adapter = FileAdapter::new(policy_path.clone());
217                    Enforcer::new(model, adapter)
218                        .await
219                        .map_err(|e| format!("failed to build Casbin enforcer: {e}"))
220                }
221                EnforcerSource::Inline { model, policy } => {
222                    let model = DefaultModel::from_str(&model)
223                        .await
224                        .map_err(|e| format!("failed to parse inline Casbin model: {e}"))?;
225                    let adapter = StringAdapter::new(policy);
226                    Enforcer::new(model, adapter)
227                        .await
228                        .map_err(|e| format!("failed to build Casbin enforcer: {e}"))
229                }
230            }
231        })
232    })
233    .join()
234    .map_err(|_| "Casbin enforcer build thread panicked".to_string())?
235}
236
237#[async_trait]
238impl Plugin for AuthzCasbinPlugin {
239    fn plugin_type(&self) -> &str {
240        "authz-casbin"
241    }
242
243    async fn execute(
244        &self,
245        ctx: Context,
246        _named_inputs: &HashMap<String, serde_json::Value>,
247    ) -> PluginResult {
248        let subject = self.subject(&ctx);
249        let object = ctx.request.path.clone();
250        let action = ctx.request.method.clone();
251
252        match self.enforcer.enforce((subject, object, action)) {
253            Ok(true) => Ok(PluginOutput {
254                context: ctx,
255                named_outputs: HashMap::new(),
256            }),
257            Ok(false) => Self::deny(ctx),
258            Err(e) => {
259                // An evaluation error (should not happen with a valid model)
260                // is treated as a denial, carrying detail in the error record.
261                let mut ctx = ctx;
262                ctx.response.status_code = 403;
263                ctx.response.body = Bytes::from(r#"{"message":"Access Denied"}"#);
264                ctx.response.headers.insert(
265                    "content-type".to_string(),
266                    vec!["application/json".to_string()],
267                );
268                Err(PluginExecutionError {
269                    context: ctx,
270                    error: GatewayError {
271                        node_id: String::new(),
272                        code: "AUTHZ_CASBIN_DENIED".to_string(),
273                        message: format!("Casbin enforcement error: {e}"),
274                        metadata: HashMap::new(),
275                    },
276                })
277            }
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
286
287    const RBAC_MODEL: &str = "\
288[request_definition]
289r = sub, obj, act
290[policy_definition]
291p = sub, obj, act
292[role_definition]
293g = _, _
294[policy_effect]
295e = some(where (p.eft == allow))
296[matchers]
297m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
298";
299
300    const RBAC_POLICY: &str = "\
301p, admin, /data, GET
302g, alice, admin
303";
304
305    fn inline_config() -> HashMap<String, serde_json::Value> {
306        let mut config = HashMap::new();
307        config.insert("model".to_string(), serde_json::json!(RBAC_MODEL));
308        config.insert("policy".to_string(), serde_json::json!(RBAC_POLICY));
309        config
310    }
311
312    fn ctx_for(method: &str, path: &str, user: Option<&str>, consumer: Option<&str>) -> Context {
313        let mut headers = HashMap::new();
314        if let Some(u) = user {
315            headers.insert("x-user".to_string(), vec![u.to_string()]);
316        }
317        let mut message = HashMap::new();
318        if let Some(c) = consumer {
319            message.insert("consumer.name".to_string(), serde_json::json!(c));
320        }
321        Context {
322            request: GatewayRequest {
323                method: method.to_string(),
324                path: path.to_string(),
325                host: "h".to_string(),
326                scheme: "http".to_string(),
327                headers,
328                query_params: HashMap::new(),
329                body: Bytes::new(),
330                remote_addr: "1.2.3.4:5".to_string(),
331                protocol: Protocol::Http1,
332            },
333            response: GatewayResponse {
334                status_code: 0,
335                headers: HashMap::new(),
336                body: Bytes::new(),
337            },
338            message,
339            errors: Vec::new(),
340        }
341    }
342
343    #[tokio::test]
344    async fn test_allow_via_role_header_subject() {
345        let plugin =
346            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
347        // alice -> admin, admin can GET /data
348        let out = plugin
349            .execute(
350                ctx_for("GET", "/data", Some("alice"), None),
351                &HashMap::new(),
352            )
353            .await;
354        assert!(out.is_ok());
355    }
356
357    #[tokio::test]
358    async fn test_allow_via_consumer_subject() {
359        let plugin =
360            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
361        // consumer identity wins over header; alice is admin
362        let out = plugin
363            .execute(
364                ctx_for("GET", "/data", Some("nobody"), Some("alice")),
365                &HashMap::new(),
366            )
367            .await;
368        assert!(out.is_ok());
369    }
370
371    #[tokio::test]
372    async fn test_deny_unknown_subject() {
373        let plugin =
374            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
375        // bob has no role -> denied
376        let err = plugin
377            .execute(ctx_for("GET", "/data", Some("bob"), None), &HashMap::new())
378            .await
379            .unwrap_err();
380        assert_eq!(err.context.response.status_code, 403);
381        assert_eq!(err.error.code, "AUTHZ_CASBIN_DENIED");
382    }
383
384    #[tokio::test]
385    async fn test_deny_wrong_action() {
386        let plugin =
387            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
388        // admin can GET /data but not POST it
389        let err = plugin
390            .execute(
391                ctx_for("POST", "/data", Some("alice"), None),
392                &HashMap::new(),
393            )
394            .await
395            .unwrap_err();
396        assert_eq!(err.error.code, "AUTHZ_CASBIN_DENIED");
397    }
398
399    #[test]
400    fn test_requires_a_source_pair() {
401        assert!(
402            AuthzCasbinPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
403        );
404        // model without policy is incomplete
405        let mut config = HashMap::new();
406        config.insert("model".to_string(), serde_json::json!(RBAC_MODEL));
407        assert!(AuthzCasbinPlugin::from_config(&config, &PluginResources::empty()).is_err());
408    }
409
410    #[test]
411    fn test_bad_model_fails_fast() {
412        let mut config = HashMap::new();
413        config.insert("model".to_string(), serde_json::json!("not a valid model"));
414        config.insert("policy".to_string(), serde_json::json!(""));
415        assert!(AuthzCasbinPlugin::from_config(&config, &PluginResources::empty()).is_err());
416    }
417}