1use async_trait::async_trait;
16use bytes::Bytes;
17use std::collections::HashMap;
18use std::sync::Arc;
19use std::time::Duration;
20
21use crate::context::{Context, GatewayError};
22use crate::outbound::{OutboundClient, OutboundRequest};
23use crate::plugins::resources::PluginResources;
24use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
25
26const TOKEN_VERSION: &str = "V1";
28
29pub struct WolfRbacPlugin {
31 server: String,
33 appid: String,
35 header_prefix: String,
37 ssl_verify: bool,
39 timeout: Duration,
41 rejected_code: u16,
43 client: Arc<OutboundClient>,
44}
45
46#[derive(Debug, PartialEq)]
48struct UserInfo {
49 id: String,
50 username: String,
51 nickname: String,
52}
53
54impl WolfRbacPlugin {
55 pub fn from_config(
76 config: &HashMap<String, serde_json::Value>,
77 resources: &Arc<PluginResources>,
78 ) -> Result<Self, String> {
79 let server = config
80 .get("server")
81 .and_then(|v| v.as_str())
82 .unwrap_or("http://127.0.0.1:12180")
83 .trim_end_matches('/')
84 .to_string();
85
86 let appid = config
87 .get("appid")
88 .and_then(|v| v.as_str())
89 .unwrap_or("unset")
90 .to_string();
91
92 let header_prefix = config
93 .get("header_prefix")
94 .and_then(|v| v.as_str())
95 .unwrap_or("X-")
96 .to_string();
97
98 let ssl_verify = config
99 .get("ssl_verify")
100 .and_then(|v| v.as_bool())
101 .unwrap_or(false);
102
103 let timeout = Duration::from_millis(
104 config
105 .get("timeout_ms")
106 .and_then(|v| v.as_u64())
107 .unwrap_or(10_000),
108 );
109
110 Ok(Self {
111 server,
112 appid,
113 header_prefix,
114 ssl_verify,
115 timeout,
116 rejected_code: 401,
117 client: resources.outbound.clone(),
118 })
119 }
120
121 fn reject(&self, ctx: Context, message: &str) -> PluginResult {
123 let mut ctx = ctx;
124 ctx.response.status_code = self.rejected_code;
125 ctx.response.body = Bytes::from(format!(
126 r#"{{"error": "forbidden", "message": "{}"}}"#,
127 message
128 ));
129 ctx.response.headers.insert(
130 "content-type".to_string(),
131 vec!["application/json".to_string()],
132 );
133 Err(PluginExecutionError {
134 context: ctx,
135 error: GatewayError {
136 node_id: String::new(),
137 code: "WOLF_RBAC_DENIED".to_string(),
138 message: message.to_string(),
139 metadata: HashMap::new(),
140 },
141 })
142 }
143}
144
145fn extract_rbac_token(
149 headers: &HashMap<String, Vec<String>>,
150 query: &HashMap<String, Vec<String>>,
151) -> Option<String> {
152 if let Some(v) = query.get("rbac_token").and_then(|v| v.first()) {
153 return Some(v.clone());
154 }
155 if let Some(v) = headers.get("authorization").and_then(|v| v.first()) {
156 return Some(v.clone());
157 }
158 if let Some(v) = headers.get("x-rbac-token").and_then(|v| v.first()) {
159 return Some(v.clone());
160 }
161 if let Some(cookie) = headers.get("cookie").and_then(|v| v.first()) {
163 for part in cookie.split(';') {
164 let part = part.trim();
165 if let Some(val) = part.strip_prefix("x-rbac-token=") {
166 return Some(val.to_string());
167 }
168 }
169 }
170 None
171}
172
173fn parse_rbac_token(token: &str) -> Result<(String, String), &'static str> {
176 let parts: Vec<&str> = token.splitn(3, '#').collect();
177 if parts.len() != 3 || parts[0] != TOKEN_VERSION {
178 return Err("invalid rbac token: version");
179 }
180 Ok((parts[1].to_string(), parts[2].to_string()))
181}
182
183fn percent_encode(value: &str) -> String {
185 let mut out = String::with_capacity(value.len());
186 for b in value.bytes() {
187 match b {
188 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
189 out.push(b as char)
190 }
191 _ => out.push_str(&format!("%{:02X}", b)),
192 }
193 }
194 out
195}
196
197fn build_access_check_url(
199 server: &str,
200 appid: &str,
201 action: &str,
202 res_name: &str,
203 client_ip: &str,
204) -> String {
205 format!(
206 "{}/wolf/rbac/access_check?appID={}&resName={}&action={}&clientIP={}",
207 server,
208 percent_encode(appid),
209 percent_encode(res_name),
210 percent_encode(action),
211 percent_encode(client_ip),
212 )
213}
214
215fn parse_user_info(body: &[u8]) -> Option<UserInfo> {
219 let json: serde_json::Value = serde_json::from_slice(body).ok()?;
220 let info = json.get("data")?.get("userInfo")?;
221 let username = info.get("username").and_then(|v| v.as_str())?.to_string();
222 let id = info
223 .get("id")
224 .map(|v| match v {
225 serde_json::Value::String(s) => s.clone(),
226 other => other.to_string(),
227 })
228 .unwrap_or_default();
229 let nickname = info
230 .get("nickname")
231 .and_then(|v| v.as_str())
232 .map(String::from)
233 .unwrap_or_else(|| username.clone());
234 Some(UserInfo {
235 id,
236 username,
237 nickname,
238 })
239}
240
241#[async_trait]
242impl Plugin for WolfRbacPlugin {
243 fn plugin_type(&self) -> &str {
244 "wolf-rbac"
245 }
246
247 async fn execute(
248 &self,
249 mut ctx: Context,
250 _named_inputs: &HashMap<String, serde_json::Value>,
251 ) -> PluginResult {
252 let token = match extract_rbac_token(&ctx.request.headers, &ctx.request.query_params) {
253 Some(t) => t,
254 None => return self.reject(ctx, "Missing rbac token in request"),
255 };
256
257 let (appid, wolf_token) = match parse_rbac_token(&token) {
258 Ok(pair) => pair,
259 Err(_) => return self.reject(ctx, "invalid rbac token: parse failed"),
260 };
261 let appid = if appid.is_empty() {
263 self.appid.clone()
264 } else {
265 appid
266 };
267
268 let action = ctx.request.method.clone();
269 let res_name = ctx.request.path.clone();
270 let client_ip = ctx
271 .request
272 .remote_addr
273 .rsplit_once(':')
274 .map_or(ctx.request.remote_addr.as_str(), |(ip, _)| ip)
275 .to_string();
276
277 let url = build_access_check_url(&self.server, &appid, &action, &res_name, &client_ip);
278
279 let outbound = OutboundRequest {
280 method: http::Method::GET,
281 url,
282 headers: vec![
283 ("x-rbac-token".to_string(), wolf_token),
284 (
285 "content-type".to_string(),
286 "application/json; charset=utf-8".to_string(),
287 ),
288 ],
289 body: Bytes::new(),
290 timeout: self.timeout,
291 ssl_verify: self.ssl_verify,
292 tls: None,
293 };
294
295 let response = match self.client.request(outbound).await {
296 Ok(resp) => resp,
297 Err(e) => {
298 let mut ctx = ctx;
299 ctx.response.status_code = 500;
300 return Err(PluginExecutionError {
301 context: ctx,
302 error: GatewayError {
303 node_id: String::new(),
304 code: "WOLF_RBAC_DENIED".to_string(),
305 message: format!("request to wolf-server failed: {}", e),
306 metadata: HashMap::new(),
307 },
308 });
309 }
310 };
311
312 if let Some(user) = parse_user_info(&response.body) {
315 let set = |ctx: &mut Context, suffix: &str, value: &str| {
316 let name = format!("{}{}", self.header_prefix, suffix).to_lowercase();
317 ctx.request.headers.insert(name, vec![value.to_string()]);
318 };
319 set(&mut ctx, "UserId", &user.id);
320 set(&mut ctx, "Username", &user.username);
321 set(&mut ctx, "Nickname", &percent_encode(&user.nickname));
322 ctx.message.insert(
323 "user".to_string(),
324 serde_json::Value::String(user.username.clone()),
325 );
326 ctx.message.insert(
327 "wolf_rbac.user_id".to_string(),
328 serde_json::Value::String(user.id.clone()),
329 );
330 }
331
332 if response.status == 200 {
333 Ok(PluginOutput {
334 context: ctx,
335 named_outputs: HashMap::new(),
336 })
337 } else {
338 let reason = serde_json::from_slice::<serde_json::Value>(&response.body)
339 .ok()
340 .and_then(|v| v.get("reason").and_then(|r| r.as_str()).map(String::from))
341 .unwrap_or_else(|| "access denied by wolf-server".to_string());
342 self.reject(ctx, &reason)
343 }
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn test_parse_rbac_token() {
353 assert_eq!(
354 parse_rbac_token("V1#restful#abc.def.ghi"),
355 Ok(("restful".to_string(), "abc.def.ghi".to_string()))
356 );
357 assert_eq!(
359 parse_rbac_token("V1#app#tok#en"),
360 Ok(("app".to_string(), "tok#en".to_string()))
361 );
362 assert!(parse_rbac_token("V2#app#tok").is_err());
363 assert!(parse_rbac_token("garbage").is_err());
364 assert!(parse_rbac_token("V1#onlytwo").is_err());
365 }
366
367 #[test]
368 fn test_extract_rbac_token_precedence() {
369 let mut headers = HashMap::new();
371 headers.insert("authorization".to_string(), vec!["hdr".to_string()]);
372 let mut query = HashMap::new();
373 query.insert("rbac_token".to_string(), vec!["qry".to_string()]);
374 assert_eq!(
375 extract_rbac_token(&headers, &query),
376 Some("qry".to_string())
377 );
378
379 assert_eq!(
381 extract_rbac_token(&headers, &HashMap::new()),
382 Some("hdr".to_string())
383 );
384
385 let mut headers = HashMap::new();
387 headers.insert("x-rbac-token".to_string(), vec!["xh".to_string()]);
388 assert_eq!(
389 extract_rbac_token(&headers, &HashMap::new()),
390 Some("xh".to_string())
391 );
392
393 let mut headers = HashMap::new();
395 headers.insert(
396 "cookie".to_string(),
397 vec!["foo=bar; x-rbac-token=ck; baz=1".to_string()],
398 );
399 assert_eq!(
400 extract_rbac_token(&headers, &HashMap::new()),
401 Some("ck".to_string())
402 );
403
404 assert_eq!(extract_rbac_token(&HashMap::new(), &HashMap::new()), None);
406 }
407
408 #[test]
409 fn test_build_access_check_url_encodes_args() {
410 let url =
411 build_access_check_url("http://wolf:12180", "restful", "GET", "/pet/1 2", "1.2.3.4");
412 assert_eq!(
413 url,
414 "http://wolf:12180/wolf/rbac/access_check?appID=restful&resName=%2Fpet%2F1%202&action=GET&clientIP=1.2.3.4"
415 );
416 }
417
418 #[test]
419 fn test_parse_user_info() {
420 let body =
421 br#"{"ok":true,"data":{"userInfo":{"id":123,"username":"alice","nickname":"Al"}}}"#;
422 assert_eq!(
423 parse_user_info(body),
424 Some(UserInfo {
425 id: "123".to_string(),
426 username: "alice".to_string(),
427 nickname: "Al".to_string(),
428 })
429 );
430
431 let body = br#"{"data":{"userInfo":{"id":"7","username":"bob"}}}"#;
433 assert_eq!(
434 parse_user_info(body),
435 Some(UserInfo {
436 id: "7".to_string(),
437 username: "bob".to_string(),
438 nickname: "bob".to_string(),
439 })
440 );
441
442 assert_eq!(parse_user_info(br#"{"ok":false,"reason":"denied"}"#), None);
444 assert_eq!(parse_user_info(b"not json"), None);
445 }
446
447 #[tokio::test]
448 async fn test_missing_token_rejected() {
449 let plugin =
450 WolfRbacPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
451 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
452 method: "GET".into(),
453 path: "/pet".into(),
454 host: "h".into(),
455 scheme: "http".into(),
456 headers: HashMap::new(),
457 query_params: HashMap::new(),
458 body: Bytes::new(),
459 remote_addr: "1.2.3.4:5".into(),
460 protocol: crate::context::Protocol::Http1,
461 });
462 let err = plugin.execute(ctx, &HashMap::new()).await.unwrap_err();
463 assert_eq!(err.error.code, "WOLF_RBAC_DENIED");
464 assert_eq!(err.context.response.status_code, 401);
465 }
466
467 #[tokio::test]
468 async fn test_bad_token_rejected() {
469 let plugin =
470 WolfRbacPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
471 let mut headers = HashMap::new();
472 headers.insert(
473 "x-rbac-token".to_string(),
474 vec!["not-a-valid-token".to_string()],
475 );
476 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
477 method: "GET".into(),
478 path: "/pet".into(),
479 host: "h".into(),
480 scheme: "http".into(),
481 headers,
482 query_params: HashMap::new(),
483 body: Bytes::new(),
484 remote_addr: "1.2.3.4:5".into(),
485 protocol: crate::context::Protocol::Http1,
486 });
487 assert!(plugin.execute(ctx, &HashMap::new()).await.is_err());
489 }
490}