featherbit/plugins/native/
authz_casbin.rs1use 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
46enum EnforcerSource {
48 Files {
50 model_path: String,
51 policy_path: String,
52 },
53 Inline { model: String, policy: String },
55}
56
57pub struct AuthzCasbinPlugin {
59 enforcer: Arc<Enforcer>,
61 username_header: String,
64}
65
66impl AuthzCasbinPlugin {
67 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 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 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
186fn 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 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 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 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 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 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 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}