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 dedicated **`denied`** port.
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;
43use crate::plugins::resources::PluginResources;
44use crate::plugins::{Plugin, 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 and exits on the `denied` port.
174    fn deny(ctx: Context) -> PluginResult {
175        let mut ctx = ctx;
176        ctx.response.status_code = 403;
177        ctx.response.body = Bytes::from(r#"{"message":"Access Denied"}"#);
178        ctx.response.headers.insert(
179            "content-type".to_string(),
180            vec!["application/json".to_string()],
181        );
182        Ok(PluginOutput::on_port(ctx, "denied"))
183    }
184}
185
186/// Compiles the enforcer on a dedicated thread with its own Tokio runtime.
187///
188/// See the module docs for why this indirection exists. Any construction
189/// failure (bad model config, missing/invalid policy file, malformed CSV) is
190/// surfaced as an `Err(String)` at config load.
191fn build_enforcer(source: EnforcerSource) -> Result<Enforcer, String> {
192    std::thread::spawn(move || -> Result<Enforcer, String> {
193        let rt = tokio::runtime::Builder::new_current_thread()
194            .enable_all()
195            .build()
196            .map_err(|e| format!("failed to build enforcer runtime: {e}"))?;
197
198        rt.block_on(async move {
199            match source {
200                EnforcerSource::Files {
201                    model_path,
202                    policy_path,
203                } => {
204                    let model = DefaultModel::from_file(&model_path)
205                        .await
206                        .map_err(|e| format!("failed to load Casbin model '{model_path}': {e}"))?;
207                    let adapter = FileAdapter::new(policy_path.clone());
208                    Enforcer::new(model, adapter)
209                        .await
210                        .map_err(|e| format!("failed to build Casbin enforcer: {e}"))
211                }
212                EnforcerSource::Inline { model, policy } => {
213                    let model = DefaultModel::from_str(&model)
214                        .await
215                        .map_err(|e| format!("failed to parse inline Casbin model: {e}"))?;
216                    let adapter = StringAdapter::new(policy);
217                    Enforcer::new(model, adapter)
218                        .await
219                        .map_err(|e| format!("failed to build Casbin enforcer: {e}"))
220                }
221            }
222        })
223    })
224    .join()
225    .map_err(|_| "Casbin enforcer build thread panicked".to_string())?
226}
227
228#[async_trait]
229impl Plugin for AuthzCasbinPlugin {
230    fn plugin_type(&self) -> &str {
231        "authz-casbin"
232    }
233
234    async fn execute(&self, ctx: Context) -> PluginResult {
235        let subject = self.subject(&ctx);
236        let object = ctx.request.path.clone();
237        let action = ctx.request.method.clone();
238
239        match self.enforcer.enforce((subject, object, action)) {
240            Ok(true) => Ok(PluginOutput::success(ctx)),
241            Ok(false) => Self::deny(ctx),
242            // An evaluation error (should not happen with a valid model) is
243            // treated as a denial, same as an explicit `false` decision, but
244            // logged so a genuine enforcement bug doesn't look identical to a
245            // policy denial in the logs.
246            Err(e) => {
247                tracing::warn!("Casbin enforcement error, denying request: {e}");
248                Self::deny(ctx)
249            }
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
258
259    const RBAC_MODEL: &str = "\
260[request_definition]
261r = sub, obj, act
262[policy_definition]
263p = sub, obj, act
264[role_definition]
265g = _, _
266[policy_effect]
267e = some(where (p.eft == allow))
268[matchers]
269m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
270";
271
272    const RBAC_POLICY: &str = "\
273p, admin, /data, GET
274g, alice, admin
275";
276
277    fn inline_config() -> HashMap<String, serde_json::Value> {
278        let mut config = HashMap::new();
279        config.insert("model".to_string(), serde_json::json!(RBAC_MODEL));
280        config.insert("policy".to_string(), serde_json::json!(RBAC_POLICY));
281        config
282    }
283
284    fn ctx_for(method: &str, path: &str, user: Option<&str>, consumer: Option<&str>) -> Context {
285        let mut headers = HashMap::new();
286        if let Some(u) = user {
287            headers.insert("x-user".to_string(), vec![u.to_string()]);
288        }
289        let mut message = HashMap::new();
290        if let Some(c) = consumer {
291            message.insert("consumer.name".to_string(), serde_json::json!(c));
292        }
293        Context {
294            request: GatewayRequest {
295                method: method.to_string(),
296                path: path.to_string(),
297                host: "h".to_string(),
298                scheme: "http".to_string(),
299                headers,
300                query_params: HashMap::new(),
301                body: Bytes::new(),
302                remote_addr: "1.2.3.4:5".to_string(),
303                protocol: Protocol::Http1,
304            },
305            response: GatewayResponse {
306                status_code: 0,
307                headers: HashMap::new(),
308                body: Bytes::new(),
309                stream: None,
310            },
311            message,
312            errors: Vec::new(),
313        }
314    }
315
316    #[tokio::test]
317    async fn test_allow_via_role_header_subject() {
318        let plugin =
319            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
320        // alice -> admin, admin can GET /data
321        let out = plugin
322            .execute(ctx_for("GET", "/data", Some("alice"), None))
323            .await;
324        assert!(out.is_ok());
325    }
326
327    #[tokio::test]
328    async fn test_allow_via_consumer_subject() {
329        let plugin =
330            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
331        // consumer identity wins over header; alice is admin
332        let out = plugin
333            .execute(ctx_for("GET", "/data", Some("nobody"), Some("alice")))
334            .await;
335        assert!(out.is_ok());
336    }
337
338    #[tokio::test]
339    async fn test_deny_unknown_subject() {
340        let plugin =
341            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
342        // bob has no role -> denied
343        let out = plugin
344            .execute(ctx_for("GET", "/data", Some("bob"), None))
345            .await
346            .unwrap();
347        assert_eq!(out.port, Some("denied"));
348        assert_eq!(out.context.response.status_code, 403);
349    }
350
351    #[tokio::test]
352    async fn test_deny_wrong_action() {
353        let plugin =
354            AuthzCasbinPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
355        // admin can GET /data but not POST it
356        let out = plugin
357            .execute(ctx_for("POST", "/data", Some("alice"), None))
358            .await
359            .unwrap();
360        assert_eq!(out.port, Some("denied"));
361    }
362
363    #[test]
364    fn test_requires_a_source_pair() {
365        assert!(
366            AuthzCasbinPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
367        );
368        // model without policy is incomplete
369        let mut config = HashMap::new();
370        config.insert("model".to_string(), serde_json::json!(RBAC_MODEL));
371        assert!(AuthzCasbinPlugin::from_config(&config, &PluginResources::empty()).is_err());
372    }
373
374    #[test]
375    fn test_bad_model_fails_fast() {
376        let mut config = HashMap::new();
377        config.insert("model".to_string(), serde_json::json!("not a valid model"));
378        config.insert("policy".to_string(), serde_json::json!(""));
379        assert!(AuthzCasbinPlugin::from_config(&config, &PluginResources::empty()).is_err());
380    }
381}