Skip to main content

featherbit/plugins/native/
wolf_rbac.rs

1//! Wolf-RBAC authorization plugin (`wolf-rbac`) — token-check subset.
2//!
3//! Port of the request-time authorization core of APISIX's `wolf-rbac` plugin.
4//! On each request it extracts the caller's wolf RBAC token, parses it, and asks
5//! the wolf-server whether that token may perform the request's method on the
6//! request's path. On allow it copies the returned user identity into request
7//! headers and `context.message`; on deny it rejects with `WOLF_RBAC_DENIED`.
8//!
9//! Only the `_M.rewrite` authorization path is ported. The interactive
10//! `/apisix/plugin/wolf-rbac/{login,change_pwd,user_info}` admin endpoints —
11//! which proxy credential exchange to wolf-server and mint tokens — are a
12//! session/login concern and are **not** implemented. See the Deviations in
13//! `website/docs/reference/plugins/wolf-rbac.md`.
14
15use 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
26/// The rbac-token version prefix wolf uses (`V1#appid#wolf_token`).
27const TOKEN_VERSION: &str = "V1";
28
29/// Checks a wolf RBAC token against a wolf-server `access_check` endpoint.
30pub struct WolfRbacPlugin {
31    /// wolf-server base URL (e.g. `http://127.0.0.1:12180`).
32    server: String,
33    /// Expected application id; also used as a fallback when a token omits it.
34    appid: String,
35    /// Prefix prepended to the `UserId`/`Username`/`Nickname` response headers.
36    header_prefix: String,
37    /// Whether TLS certificates are verified on the callout.
38    ssl_verify: bool,
39    /// Whole-call deadline for the wolf-server callout.
40    timeout: Duration,
41    /// Denial status code (401).
42    rejected_code: u16,
43    client: Arc<OutboundClient>,
44}
45
46/// The subset of wolf-server's `userInfo` payload the plugin propagates.
47#[derive(Debug, PartialEq)]
48struct UserInfo {
49    id: String,
50    username: String,
51    nickname: String,
52}
53
54impl WolfRbacPlugin {
55    /// Builds the plugin from node config.
56    ///
57    /// Accepted keys:
58    /// - `server` (string, default `"http://127.0.0.1:12180"`): wolf-server
59    ///   base URL that `/wolf/rbac/access_check` is called on.
60    /// - `appid` (string, default `"unset"`): application id used as the
61    ///   `appID` request argument when a token carries none.
62    /// - `header_prefix` (string, default `"X-"`): prefix for the identity
63    ///   headers injected on an allowed request.
64    /// - `ssl_verify` (bool, default `false`): verify wolf-server's TLS cert.
65    /// - `timeout_ms` (u64, default `10000`): callout deadline.
66    ///
67    /// ```yaml
68    /// type: wolf-rbac
69    /// config:
70    ///   server: http://wolf-server:12180
71    ///   appid: restful
72    ///   header_prefix: X-
73    ///   ssl_verify: false
74    /// ```
75    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    /// Builds a denial routed through the node's error port.
122    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
145/// Extracts the rbac token from (in APISIX precedence order): the `rbac_token`
146/// query argument, the `Authorization` header, the `X-RBAC-Token` header, then
147/// the `x-rbac-token` cookie.
148fn 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    // cookie: x-rbac-token=<value>
162    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
173/// Parses a `V1#<appid>#<wolf_token>` rbac token into `(appid, wolf_token)`.
174/// Errors on the wrong version prefix or the wrong number of `#` segments.
175fn 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
183/// Percent-encodes a query-argument value (RFC3986 unreserved chars kept).
184fn 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
197/// Builds the `access_check` URL with the query arguments wolf-server expects.
198fn 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
215/// Extracts `data.userInfo.{id,username,nickname}` from a wolf-server response
216/// body. `nickname` falls back to `username`; a missing `username` yields
217/// `None` (no identity to propagate).
218fn 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        // A token that carries no appid falls back to the configured one.
262        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        // Propagate the identity (when present) before the allow/deny decision,
313        // matching APISIX which sets these headers regardless of status.
314        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        // wolf_token may itself contain '#'
358        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        // query arg wins
370        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        // then Authorization header
380        assert_eq!(
381            extract_rbac_token(&headers, &HashMap::new()),
382            Some("hdr".to_string())
383        );
384
385        // then X-RBAC-Token header
386        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        // then cookie
394        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        // nothing
405        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        // nickname falls back to username
432        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        // no userInfo → None
443        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        // Parse failure is caught before any network call.
488        assert!(plugin.execute(ctx, &HashMap::new()).await.is_err());
489    }
490}