1use async_trait::async_trait;
22use bytes::Bytes;
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::Duration;
26
27use crate::context::{Context, GatewayError};
28use crate::outbound::{OutboundClient, OutboundRequest};
29use crate::plugins::resources::PluginResources;
30use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
31
32const TOKEN_VERSION: &str = "V1";
34
35pub struct WolfRbacPlugin {
37 server: String,
39 appid: String,
41 header_prefix: String,
43 ssl_verify: bool,
45 timeout: Duration,
47 rejected_code: u16,
49 client: Arc<OutboundClient>,
50}
51
52#[derive(Debug, PartialEq)]
54struct UserInfo {
55 id: String,
56 username: String,
57 nickname: String,
58}
59
60impl WolfRbacPlugin {
61 pub fn from_config(
82 config: &HashMap<String, serde_json::Value>,
83 resources: &Arc<PluginResources>,
84 ) -> Result<Self, String> {
85 let server = config
86 .get("server")
87 .and_then(|v| v.as_str())
88 .unwrap_or("http://127.0.0.1:12180")
89 .trim_end_matches('/')
90 .to_string();
91
92 let appid = config
93 .get("appid")
94 .and_then(|v| v.as_str())
95 .unwrap_or("unset")
96 .to_string();
97
98 let header_prefix = config
99 .get("header_prefix")
100 .and_then(|v| v.as_str())
101 .unwrap_or("X-")
102 .to_string();
103
104 let ssl_verify = config
105 .get("ssl_verify")
106 .and_then(|v| v.as_bool())
107 .unwrap_or(false);
108
109 let timeout = Duration::from_millis(
110 config
111 .get("timeout_ms")
112 .and_then(|v| v.as_u64())
113 .unwrap_or(10_000),
114 );
115
116 Ok(Self {
117 server,
118 appid,
119 header_prefix,
120 ssl_verify,
121 timeout,
122 rejected_code: 401,
123 client: resources.outbound.clone(),
124 })
125 }
126
127 fn reject(&self, ctx: Context, message: &str) -> PluginResult {
129 let mut ctx = ctx;
130 ctx.response.status_code = self.rejected_code;
131 ctx.response.body = Bytes::from(format!(
132 r#"{{"error": "forbidden", "message": "{}"}}"#,
133 message
134 ));
135 ctx.response.headers.insert(
136 "content-type".to_string(),
137 vec!["application/json".to_string()],
138 );
139 Ok(PluginOutput::on_port(ctx, "denied"))
140 }
141
142 fn callout_error(&self, ctx: Context, message: String) -> PluginExecutionError {
147 let mut ctx = ctx;
148 ctx.response.status_code = 500;
149 PluginExecutionError {
150 context: ctx,
151 error: GatewayError {
152 node_id: String::new(),
153 code: "WOLF_RBAC_UPSTREAM_ERROR".to_string(),
154 message,
155 metadata: HashMap::new(),
156 },
157 }
158 }
159}
160
161#[derive(Debug, PartialEq, Eq)]
163enum AccessCheck {
164 Allowed,
166 Denied,
169 Unexpected,
173}
174
175fn classify_access_check(status: u16) -> AccessCheck {
181 match status {
182 200 => AccessCheck::Allowed,
183 401 | 403 => AccessCheck::Denied,
184 _ => AccessCheck::Unexpected,
185 }
186}
187
188fn extract_rbac_token(
192 headers: &HashMap<String, Vec<String>>,
193 query: &HashMap<String, Vec<String>>,
194) -> Option<String> {
195 if let Some(v) = query.get("rbac_token").and_then(|v| v.first()) {
196 return Some(v.clone());
197 }
198 if let Some(v) = headers.get("authorization").and_then(|v| v.first()) {
199 return Some(v.clone());
200 }
201 if let Some(v) = headers.get("x-rbac-token").and_then(|v| v.first()) {
202 return Some(v.clone());
203 }
204 if let Some(cookie) = headers.get("cookie").and_then(|v| v.first()) {
206 for part in cookie.split(';') {
207 let part = part.trim();
208 if let Some(val) = part.strip_prefix("x-rbac-token=") {
209 return Some(val.to_string());
210 }
211 }
212 }
213 None
214}
215
216fn parse_rbac_token(token: &str) -> Result<(String, String), &'static str> {
219 let parts: Vec<&str> = token.splitn(3, '#').collect();
220 if parts.len() != 3 || parts[0] != TOKEN_VERSION {
221 return Err("invalid rbac token: version");
222 }
223 Ok((parts[1].to_string(), parts[2].to_string()))
224}
225
226fn percent_encode(value: &str) -> String {
228 let mut out = String::with_capacity(value.len());
229 for b in value.bytes() {
230 match b {
231 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
232 out.push(b as char)
233 }
234 _ => out.push_str(&format!("%{:02X}", b)),
235 }
236 }
237 out
238}
239
240fn build_access_check_url(
242 server: &str,
243 appid: &str,
244 action: &str,
245 res_name: &str,
246 client_ip: &str,
247) -> String {
248 format!(
249 "{}/wolf/rbac/access_check?appID={}&resName={}&action={}&clientIP={}",
250 server,
251 percent_encode(appid),
252 percent_encode(res_name),
253 percent_encode(action),
254 percent_encode(client_ip),
255 )
256}
257
258fn parse_user_info(body: &[u8]) -> Option<UserInfo> {
262 let json: serde_json::Value = serde_json::from_slice(body).ok()?;
263 let info = json.get("data")?.get("userInfo")?;
264 let username = info.get("username").and_then(|v| v.as_str())?.to_string();
265 let id = info
266 .get("id")
267 .map(|v| match v {
268 serde_json::Value::String(s) => s.clone(),
269 other => other.to_string(),
270 })
271 .unwrap_or_default();
272 let nickname = info
273 .get("nickname")
274 .and_then(|v| v.as_str())
275 .map(String::from)
276 .unwrap_or_else(|| username.clone());
277 Some(UserInfo {
278 id,
279 username,
280 nickname,
281 })
282}
283
284#[async_trait]
285impl Plugin for WolfRbacPlugin {
286 fn plugin_type(&self) -> &str {
287 "wolf-rbac"
288 }
289
290 async fn execute(&self, mut ctx: Context) -> PluginResult {
291 let token = match extract_rbac_token(&ctx.request.headers, &ctx.request.query_params) {
292 Some(t) => t,
293 None => return self.reject(ctx, "Missing rbac token in request"),
294 };
295
296 let (appid, wolf_token) = match parse_rbac_token(&token) {
297 Ok(pair) => pair,
298 Err(_) => return self.reject(ctx, "invalid rbac token: parse failed"),
299 };
300 let appid = if appid.is_empty() {
302 self.appid.clone()
303 } else {
304 appid
305 };
306
307 let action = ctx.request.method.clone();
308 let res_name = ctx.request.path.clone();
309 let client_ip = ctx
310 .request
311 .remote_addr
312 .rsplit_once(':')
313 .map_or(ctx.request.remote_addr.as_str(), |(ip, _)| ip)
314 .to_string();
315
316 let url = build_access_check_url(&self.server, &appid, &action, &res_name, &client_ip);
317
318 let outbound = OutboundRequest {
319 method: http::Method::GET,
320 url,
321 headers: vec![
322 ("x-rbac-token".to_string(), wolf_token),
323 (
324 "content-type".to_string(),
325 "application/json; charset=utf-8".to_string(),
326 ),
327 ],
328 body: Bytes::new(),
329 timeout: self.timeout,
330 ssl_verify: self.ssl_verify,
331 tls: None,
332 };
333
334 let response = match self.client.request(outbound).await {
335 Ok(resp) => resp,
336 Err(e) => {
337 return Err(
340 self.callout_error(ctx, format!("request to wolf-server failed: {}", e))
341 );
342 }
343 };
344
345 if let Some(user) = parse_user_info(&response.body) {
348 let set = |ctx: &mut Context, suffix: &str, value: &str| {
349 let name = format!("{}{}", self.header_prefix, suffix).to_lowercase();
350 ctx.request.headers.insert(name, vec![value.to_string()]);
351 };
352 set(&mut ctx, "UserId", &user.id);
353 set(&mut ctx, "Username", &user.username);
354 set(&mut ctx, "Nickname", &percent_encode(&user.nickname));
355 ctx.message.insert(
356 "user".to_string(),
357 serde_json::Value::String(user.username.clone()),
358 );
359 ctx.message.insert(
360 "wolf_rbac.user_id".to_string(),
361 serde_json::Value::String(user.id.clone()),
362 );
363 }
364
365 match classify_access_check(response.status) {
366 AccessCheck::Allowed => Ok(PluginOutput::success(ctx)),
367 AccessCheck::Denied => {
368 let reason = serde_json::from_slice::<serde_json::Value>(&response.body)
369 .ok()
370 .and_then(|v| v.get("reason").and_then(|r| r.as_str()).map(String::from))
371 .unwrap_or_else(|| "access denied by wolf-server".to_string());
372 self.reject(ctx, &reason)
373 }
374 AccessCheck::Unexpected => Err(self.callout_error(
377 ctx,
378 format!(
379 "unexpected status {} from wolf-server access_check (expected 200/401/403)",
380 response.status
381 ),
382 )),
383 }
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn test_parse_rbac_token() {
393 assert_eq!(
394 parse_rbac_token("V1#restful#abc.def.ghi"),
395 Ok(("restful".to_string(), "abc.def.ghi".to_string()))
396 );
397 assert_eq!(
399 parse_rbac_token("V1#app#tok#en"),
400 Ok(("app".to_string(), "tok#en".to_string()))
401 );
402 assert!(parse_rbac_token("V2#app#tok").is_err());
403 assert!(parse_rbac_token("garbage").is_err());
404 assert!(parse_rbac_token("V1#onlytwo").is_err());
405 }
406
407 #[test]
408 fn test_extract_rbac_token_precedence() {
409 let mut headers = HashMap::new();
411 headers.insert("authorization".to_string(), vec!["hdr".to_string()]);
412 let mut query = HashMap::new();
413 query.insert("rbac_token".to_string(), vec!["qry".to_string()]);
414 assert_eq!(
415 extract_rbac_token(&headers, &query),
416 Some("qry".to_string())
417 );
418
419 assert_eq!(
421 extract_rbac_token(&headers, &HashMap::new()),
422 Some("hdr".to_string())
423 );
424
425 let mut headers = HashMap::new();
427 headers.insert("x-rbac-token".to_string(), vec!["xh".to_string()]);
428 assert_eq!(
429 extract_rbac_token(&headers, &HashMap::new()),
430 Some("xh".to_string())
431 );
432
433 let mut headers = HashMap::new();
435 headers.insert(
436 "cookie".to_string(),
437 vec!["foo=bar; x-rbac-token=ck; baz=1".to_string()],
438 );
439 assert_eq!(
440 extract_rbac_token(&headers, &HashMap::new()),
441 Some("ck".to_string())
442 );
443
444 assert_eq!(extract_rbac_token(&HashMap::new(), &HashMap::new()), None);
446 }
447
448 #[test]
449 fn test_build_access_check_url_encodes_args() {
450 let url =
451 build_access_check_url("http://wolf:12180", "restful", "GET", "/pet/1 2", "1.2.3.4");
452 assert_eq!(
453 url,
454 "http://wolf:12180/wolf/rbac/access_check?appID=restful&resName=%2Fpet%2F1%202&action=GET&clientIP=1.2.3.4"
455 );
456 }
457
458 #[test]
459 fn test_parse_user_info() {
460 let body =
461 br#"{"ok":true,"data":{"userInfo":{"id":123,"username":"alice","nickname":"Al"}}}"#;
462 assert_eq!(
463 parse_user_info(body),
464 Some(UserInfo {
465 id: "123".to_string(),
466 username: "alice".to_string(),
467 nickname: "Al".to_string(),
468 })
469 );
470
471 let body = br#"{"data":{"userInfo":{"id":"7","username":"bob"}}}"#;
473 assert_eq!(
474 parse_user_info(body),
475 Some(UserInfo {
476 id: "7".to_string(),
477 username: "bob".to_string(),
478 nickname: "bob".to_string(),
479 })
480 );
481
482 assert_eq!(parse_user_info(br#"{"ok":false,"reason":"denied"}"#), None);
484 assert_eq!(parse_user_info(b"not json"), None);
485 }
486
487 #[tokio::test]
488 async fn test_missing_token_rejected() {
489 let plugin =
490 WolfRbacPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
491 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
492 method: "GET".into(),
493 path: "/pet".into(),
494 host: "h".into(),
495 scheme: "http".into(),
496 headers: HashMap::new(),
497 query_params: HashMap::new(),
498 body: Bytes::new(),
499 remote_addr: "1.2.3.4:5".into(),
500 protocol: crate::context::Protocol::Http1,
501 });
502 let out = plugin.execute(ctx).await.unwrap();
503 assert_eq!(out.port, Some("denied"));
504 assert_eq!(out.context.response.status_code, 401);
505 }
506
507 #[tokio::test]
508 async fn test_bad_token_rejected() {
509 let plugin =
510 WolfRbacPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
511 let mut headers = HashMap::new();
512 headers.insert(
513 "x-rbac-token".to_string(),
514 vec!["not-a-valid-token".to_string()],
515 );
516 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
517 method: "GET".into(),
518 path: "/pet".into(),
519 host: "h".into(),
520 scheme: "http".into(),
521 headers,
522 query_params: HashMap::new(),
523 body: Bytes::new(),
524 remote_addr: "1.2.3.4:5".into(),
525 protocol: crate::context::Protocol::Http1,
526 });
527 let out = plugin.execute(ctx).await.unwrap();
529 assert_eq!(out.port, Some("denied"));
530 }
531
532 #[tokio::test]
533 async fn test_upstream_callout_failure_stays_on_error_port() {
534 let mut cfg = HashMap::new();
537 cfg.insert(
538 "server".to_string(),
539 serde_json::json!("http://127.0.0.1:1"),
540 );
541 cfg.insert("timeout_ms".to_string(), serde_json::json!(200));
542 let plugin = WolfRbacPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
543
544 let mut headers = HashMap::new();
545 headers.insert("x-rbac-token".to_string(), vec!["V1#app#tok".to_string()]);
546 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
547 method: "GET".into(),
548 path: "/pet".into(),
549 host: "h".into(),
550 scheme: "http".into(),
551 headers,
552 query_params: HashMap::new(),
553 body: Bytes::new(),
554 remote_addr: "1.2.3.4:5".into(),
555 protocol: crate::context::Protocol::Http1,
556 });
557 let err = plugin.execute(ctx).await.unwrap_err();
558 assert_eq!(err.error.code, "WOLF_RBAC_UPSTREAM_ERROR");
559 }
560
561 #[test]
564 fn test_classify_access_check_splits_verdicts_from_failures() {
565 assert_eq!(classify_access_check(200), AccessCheck::Allowed);
566 assert_eq!(classify_access_check(401), AccessCheck::Denied);
567 assert_eq!(classify_access_check(403), AccessCheck::Denied);
568 for status in [400u16, 404, 500, 502, 503] {
569 assert_eq!(
570 classify_access_check(status),
571 AccessCheck::Unexpected,
572 "status {status}"
573 );
574 }
575 }
576
577 async fn spawn_status_server(status_line: &'static str) -> u16 {
580 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
581 let port = listener.local_addr().unwrap().port();
582 tokio::spawn(async move {
583 if let Ok((mut stream, _)) = listener.accept().await {
584 use tokio::io::{AsyncReadExt, AsyncWriteExt};
585 let mut buf = [0u8; 4096];
586 let _ = stream.read(&mut buf).await;
587 let _ = stream
588 .write_all(
589 format!("HTTP/1.1 {status_line}\r\ncontent-length: 0\r\n\r\n").as_bytes(),
590 )
591 .await;
592 let _ = stream.shutdown().await;
593 }
594 });
595 port
596 }
597
598 fn tokened_ctx() -> Context {
599 let mut headers = HashMap::new();
600 headers.insert("x-rbac-token".to_string(), vec!["V1#app#tok".to_string()]);
601 crate::context::Context::new(crate::context::GatewayRequest {
602 method: "GET".into(),
603 path: "/pet".into(),
604 host: "h".into(),
605 scheme: "http".into(),
606 headers,
607 query_params: HashMap::new(),
608 body: Bytes::new(),
609 remote_addr: "1.2.3.4:5".into(),
610 protocol: crate::context::Protocol::Http1,
611 })
612 }
613
614 fn plugin_against(port: u16) -> WolfRbacPlugin {
615 let mut cfg = HashMap::new();
616 cfg.insert(
617 "server".to_string(),
618 serde_json::json!(format!("http://127.0.0.1:{port}")),
619 );
620 cfg.insert("timeout_ms".to_string(), serde_json::json!(2000));
621 WolfRbacPlugin::from_config(&cfg, &PluginResources::empty()).unwrap()
622 }
623
624 #[tokio::test]
626 async fn test_wolf_401_decision_is_denied() {
627 let port = spawn_status_server("401 Unauthorized").await;
628 let out = plugin_against(port).execute(tokened_ctx()).await.unwrap();
629 assert_eq!(out.port, Some("denied"));
630 assert_eq!(out.context.response.status_code, 401);
631 }
632
633 #[tokio::test]
636 async fn test_wolf_5xx_is_error_port_not_denied() {
637 let port = spawn_status_server("500 Internal Server Error").await;
638 let err = plugin_against(port)
639 .execute(tokened_ctx())
640 .await
641 .unwrap_err();
642 assert_eq!(err.error.code, "WOLF_RBAC_UPSTREAM_ERROR");
643 assert!(
644 err.error.message.contains("unexpected status 500"),
645 "{}",
646 err.error.message
647 );
648 }
649
650 #[tokio::test]
652 async fn test_wolf_404_is_error_port_not_denied() {
653 let port = spawn_status_server("404 Not Found").await;
654 let err = plugin_against(port)
655 .execute(tokened_ctx())
656 .await
657 .unwrap_err();
658 assert_eq!(err.error.code, "WOLF_RBAC_UPSTREAM_ERROR");
659 }
660}