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, GatewayError};
43use crate::plugins::resources::PluginResources;
44use crate::plugins::{Plugin, PluginExecutionError, 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 {
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
195fn 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 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 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 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 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 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 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}