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 exits on the `denied` port.
8//!
9//! Only an actual authorization verdict is a denial: `200` allows, `401`/`403`
10//! denies. A wolf-server callout that fails outright — unreachable, timed out,
11//! or answering with any other status (`5xx`, or a `404` from a mistyped
12//! `server` URL) — is a genuine infrastructure failure and stays on the
13//! `error` port (see [`classify_access_check`]).
14//!
15//! Only the `_M.rewrite` authorization path is ported. The interactive
16//! `/apisix/plugin/wolf-rbac/{login,change_pwd,user_info}` admin endpoints —
17//! which proxy credential exchange to wolf-server and mint tokens — are a
18//! session/login concern and are **not** implemented. See the Deviations in
19//! `website/docs/reference/plugins/wolf-rbac.md`.
20
21use 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
32/// The rbac-token version prefix wolf uses (`V1#appid#wolf_token`).
33const TOKEN_VERSION: &str = "V1";
34
35/// Checks a wolf RBAC token against a wolf-server `access_check` endpoint.
36pub struct WolfRbacPlugin {
37    /// wolf-server base URL (e.g. `http://127.0.0.1:12180`).
38    server: String,
39    /// Expected application id; also used as a fallback when a token omits it.
40    appid: String,
41    /// Prefix prepended to the `UserId`/`Username`/`Nickname` response headers.
42    header_prefix: String,
43    /// Whether TLS certificates are verified on the callout.
44    ssl_verify: bool,
45    /// Whole-call deadline for the wolf-server callout.
46    timeout: Duration,
47    /// Denial status code (401).
48    rejected_code: u16,
49    client: Arc<OutboundClient>,
50}
51
52/// The subset of wolf-server's `userInfo` payload the plugin propagates.
53#[derive(Debug, PartialEq)]
54struct UserInfo {
55    id: String,
56    username: String,
57    nickname: String,
58}
59
60impl WolfRbacPlugin {
61    /// Builds the plugin from node config.
62    ///
63    /// Accepted keys:
64    /// - `server` (string, default `"http://127.0.0.1:12180"`): wolf-server
65    ///   base URL that `/wolf/rbac/access_check` is called on.
66    /// - `appid` (string, default `"unset"`): application id used as the
67    ///   `appID` request argument when a token carries none.
68    /// - `header_prefix` (string, default `"X-"`): prefix for the identity
69    ///   headers injected on an allowed request.
70    /// - `ssl_verify` (bool, default `false`): verify wolf-server's TLS cert.
71    /// - `timeout_ms` (u64, default `10000`): callout deadline.
72    ///
73    /// ```yaml
74    /// type: wolf-rbac
75    /// config:
76    ///   server: http://wolf-server:12180
77    ///   appid: restful
78    ///   header_prefix: X-
79    ///   ssl_verify: false
80    /// ```
81    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    /// Builds a denial and exits on the node's `denied` port.
128    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    /// Builds a genuine infrastructure-failure `Err` (wolf-server unreachable,
143    /// timed out, or answering `access_check` with a status that is not an
144    /// authorization verdict). Unlike [`WolfRbacPlugin::reject`], this exits
145    /// through the `error` port because the node never obtained a verdict.
146    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/// What a wolf-server `access_check` reply means.
162#[derive(Debug, PartialEq, Eq)]
163enum AccessCheck {
164    /// `200` — wolf-server allowed the request.
165    Allowed,
166    /// `401`/`403` — wolf-server evaluated the token and refused it. A
167    /// deliberate, client-facing decision → the `denied` port.
168    Denied,
169    /// Anything else — `5xx`, or a `4xx` that means the *request to
170    /// wolf-server* was wrong rather than the caller's access (`404` from a
171    /// wrong `server` base URL, `400`). No verdict → the `error` port.
172    Unexpected,
173}
174
175/// Classifies a wolf-server `access_check` status.
176///
177/// The split matters: a `502` from a wolf-server behind a dead proxy, or a
178/// `404` from a mistyped `server` URL, is not "this token may not pass" —
179/// reporting it as a 401 hides a broken deployment behind a plausible denial.
180fn classify_access_check(status: u16) -> AccessCheck {
181    match status {
182        200 => AccessCheck::Allowed,
183        401 | 403 => AccessCheck::Denied,
184        _ => AccessCheck::Unexpected,
185    }
186}
187
188/// Extracts the rbac token from (in APISIX precedence order): the `rbac_token`
189/// query argument, the `Authorization` header, the `X-RBAC-Token` header, then
190/// the `x-rbac-token` cookie.
191fn 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    // cookie: x-rbac-token=<value>
205    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
216/// Parses a `V1#<appid>#<wolf_token>` rbac token into `(appid, wolf_token)`.
217/// Errors on the wrong version prefix or the wrong number of `#` segments.
218fn 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
226/// Percent-encodes a query-argument value (RFC3986 unreserved chars kept).
227fn 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
240/// Builds the `access_check` URL with the query arguments wolf-server expects.
241fn 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
258/// Extracts `data.userInfo.{id,username,nickname}` from a wolf-server response
259/// body. `nickname` falls back to `username`; a missing `username` yields
260/// `None` (no identity to propagate).
261fn 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        // A token that carries no appid falls back to the configured one.
301        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                // A genuine infrastructure failure (wolf-server unreachable),
338                // not a deliberate denial: stays on the `error` port.
339                return Err(
340                    self.callout_error(ctx, format!("request to wolf-server failed: {}", e))
341                );
342            }
343        };
344
345        // Propagate the identity (when present) before the allow/deny decision,
346        // matching APISIX which sets these headers regardless of status.
347        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            // No verdict was obtained — a broken wolf-server or a wrong
375            // `server` URL, not a decision about this caller.
376            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        // wolf_token may itself contain '#'
398        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        // query arg wins
410        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        // then Authorization header
420        assert_eq!(
421            extract_rbac_token(&headers, &HashMap::new()),
422            Some("hdr".to_string())
423        );
424
425        // then X-RBAC-Token header
426        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        // then cookie
434        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        // nothing
445        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        // nickname falls back to username
472        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        // no userInfo → None
483        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        // Parse failure is caught before any network call.
528        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        // A genuine infra failure (nothing listening on the wolf-server port)
535        // must stay a raw `Err`, unlike the deliberate denials above.
536        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    /// Only a status wolf-server uses to express an authorization verdict is a
562    /// verdict; everything else means no verdict was obtained.
563    #[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    /// Minimal one-shot HTTP server answering any request with a fixed status
578    /// line and no body. Returns its port.
579    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    /// wolf-server evaluated the token and refused it → the `denied` port.
625    #[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    /// Regression: a `500` from wolf-server used to be laundered into the same
634    /// 401 denial as a real refusal. It must exit on `error`.
635    #[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    /// A `404` from a mistyped `server` base URL is the same class of problem.
651    #[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}