Skip to main content

featherbit/plugins/native/
workflow.rs

1//! Workflow plugin (`workflow`) — a faithful subset of Apache APISIX's
2//! `workflow` plugin: declarative traffic rules evaluated in order, where the
3//! first matching `case` triggers its action.
4//!
5//! Supported actions (of APISIX's registrable set): `return` (reject with a
6//! status code) and `limit-count` (fixed-window rate limiting via the shared
7//! counter store).
8//!
9//! **Early-exit wiring**: a request rejected by a `return` action or an
10//! exceeded `limit-count` has the rejection already written onto
11//! `Context.response` and exits through the node's **error** port (codes
12//! `WORKFLOW_REJECTED` / `RATE_LIMITED`). Wire `error` to a pass-through path
13//! (straight to `client.in`, or an `error-handler` that preserves the
14//! prepared response). Requests that match no rule — or that pass the
15//! `limit-count` check — continue through the **success** port.
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use std::collections::HashMap;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::Arc;
22use std::time::Duration;
23
24use crate::context::{Context, GatewayError};
25use crate::plugins::resources::PluginResources;
26use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
27use crate::ratelimit::CounterStore;
28use crate::vars::{interpolate, Expr};
29
30/// Distinguishes counter namespaces between workflow node instances so two
31/// nodes with identical rules don't share windows (APISIX isolates with a
32/// per-conf `_vid`).
33static INSTANCE_SEQ: AtomicU64 = AtomicU64::new(0);
34
35/// Evaluates ordered rules; the first rule whose `case` matches applies its
36/// action. Rules without a `case` always match. No matching rule means
37/// passthrough.
38pub struct WorkflowPlugin {
39    rules: Vec<Rule>,
40}
41
42struct Rule {
43    /// One APISIX triple-array expression (rules AND-ed); `None` matches all.
44    case: Option<Expr>,
45    action: Action,
46}
47
48enum Action {
49    /// Reject with a fixed status and APISIX's exact body.
50    Return { code: u16 },
51    /// Fixed-window rate limit through the shared counter store.
52    LimitCount(LimitCount),
53}
54
55struct LimitCount {
56    count: u64,
57    time_window: Duration,
58    /// `$var` template resolved per request into the counter key.
59    key_template: String,
60    rejected_code: u16,
61    rejected_msg: Option<String>,
62    /// Namespaces this action's counters: `workflow:<instance>:<rule idx>`.
63    counter_prefix: String,
64    store: Arc<dyn CounterStore>,
65}
66
67impl WorkflowPlugin {
68    /// Builds the plugin from node config. All expressions and action
69    /// parameters are validated here at config load.
70    ///
71    /// Accepted keys:
72    /// - `rules` (array, **required**, non-empty): evaluated in order.
73    ///   - `case` (array, optional): APISIX triple-array condition, rules
74    ///     AND-ed (e.g. `[["uri", "==", "/v1"], ["arg_debug", "==", "1"]]`).
75    ///     Omit to match every request.
76    ///   - `actions` (array, **required**): `[[name, params]]`. Only the
77    ///     first action is applied (APISIX supports exactly one). Supported:
78    ///     - `"return"` — params `{code}` (integer 100-599, **required**).
79    ///       Rejects with that status and body
80    ///       `{"error_msg":"rejected by workflow"}`.
81    ///     - `"limit-count"` — params:
82    ///       - `count` (integer > 0, **required**): allowed requests per window.
83    ///       - `time_window` (number > 0, seconds, **required**).
84    ///       - `key` (string, default `"$remote_addr"`): `$var` template
85    ///         resolved per request into the counter key.
86    ///       - `rejected_code` (integer 200-599, default `503`).
87    ///       - `rejected_msg` (string, optional): when set, the rejection body
88    ///         is `{"error_msg": "<msg>"}`; empty body otherwise.
89    ///       - `policy` (string, default `"local"`): counter backend name.
90    ///
91    /// ```yaml
92    /// type: workflow
93    /// config:
94    ///   rules:
95    ///     - case:
96    ///         - ["uri", "~~", "^/admin"]
97    ///       actions:
98    ///         - ["return", { "code": 403 }]
99    ///     - actions:
100    ///         - ["limit-count", { "count": 100, "time_window": 60, "key": "$remote_addr" }]
101    /// ```
102    pub fn from_config(
103        config: &HashMap<String, serde_json::Value>,
104        resources: &Arc<PluginResources>,
105    ) -> Result<Self, String> {
106        let raw_rules = config
107            .get("rules")
108            .and_then(|v| v.as_array())
109            .ok_or("workflow requires a 'rules' array")?;
110        if raw_rules.is_empty() {
111            return Err("workflow 'rules' must not be empty".to_string());
112        }
113
114        let instance = INSTANCE_SEQ.fetch_add(1, Ordering::Relaxed);
115        let mut rules = Vec::with_capacity(raw_rules.len());
116
117        for (idx, raw) in raw_rules.iter().enumerate() {
118            let obj = raw
119                .as_object()
120                .ok_or_else(|| format!("rules[{idx}] must be an object"))?;
121
122            let case = match obj.get("case") {
123                None => None,
124                Some(v) => Some(Expr::parse(v).map_err(|e| format!("rules[{idx}].case: {e}"))?),
125            };
126
127            let actions = obj
128                .get("actions")
129                .and_then(|v| v.as_array())
130                .filter(|a| !a.is_empty())
131                .ok_or_else(|| format!("rules[{idx}] requires a non-empty 'actions' array"))?;
132
133            // Only one action is supported (matching APISIX).
134            let action_arr = actions[0]
135                .as_array()
136                .filter(|a| !a.is_empty())
137                .ok_or_else(|| format!("rules[{idx}].actions[0] must be a non-empty array"))?;
138            let name = action_arr[0]
139                .as_str()
140                .ok_or_else(|| format!("rules[{idx}]: action name must be a string"))?;
141            let empty = serde_json::Map::new();
142            let params = action_arr
143                .get(1)
144                .map(|v| {
145                    v.as_object().ok_or_else(|| {
146                        format!("rules[{idx}]: '{name}' action params must be an object")
147                    })
148                })
149                .transpose()?
150                .unwrap_or(&empty);
151
152            let action = match name {
153                "return" => {
154                    let code = params
155                        .get("code")
156                        .and_then(|v| v.as_u64())
157                        .filter(|c| (100..=599).contains(c))
158                        .ok_or_else(|| {
159                            format!("rules[{idx}]: 'return' action requires 'code' (100-599)")
160                        })? as u16;
161                    Action::Return { code }
162                }
163                "limit-count" => {
164                    let count = params
165                        .get("count")
166                        .and_then(|v| v.as_u64())
167                        .filter(|c| *c > 0)
168                        .ok_or_else(|| {
169                            format!("rules[{idx}]: 'limit-count' requires 'count' (integer > 0)")
170                        })?;
171                    let time_window = params
172                        .get("time_window")
173                        .and_then(|v| v.as_f64())
174                        .filter(|w| *w > 0.0 && w.is_finite())
175                        .ok_or_else(|| {
176                            format!(
177                                "rules[{idx}]: 'limit-count' requires 'time_window' (seconds > 0)"
178                            )
179                        })?;
180                    let key_template = params
181                        .get("key")
182                        .map(|v| {
183                            v.as_str()
184                                .map(String::from)
185                                .ok_or_else(|| format!("rules[{idx}]: 'key' must be a string"))
186                        })
187                        .transpose()?
188                        .unwrap_or_else(|| "$remote_addr".to_string());
189                    let rejected_code =
190                        params
191                            .get("rejected_code")
192                            .map(|v| {
193                                v.as_u64().filter(|c| (200..=599).contains(c)).ok_or_else(|| {
194                                format!("rules[{idx}]: 'rejected_code' must be an integer 200-599")
195                            })
196                            })
197                            .transpose()?
198                            .unwrap_or(503) as u16;
199                    let rejected_msg = params
200                        .get("rejected_msg")
201                        .map(|v| {
202                            v.as_str().map(String::from).ok_or_else(|| {
203                                format!("rules[{idx}]: 'rejected_msg' must be a string")
204                            })
205                        })
206                        .transpose()?;
207                    let policy = params
208                        .get("policy")
209                        .and_then(|v| v.as_str())
210                        .unwrap_or("local");
211                    let store = resources.counters.get(policy)?;
212
213                    Action::LimitCount(LimitCount {
214                        count,
215                        time_window: Duration::from_secs_f64(time_window),
216                        key_template,
217                        rejected_code,
218                        rejected_msg,
219                        counter_prefix: format!("workflow:{instance}:{idx}"),
220                        store,
221                    })
222                }
223                other => {
224                    return Err(format!(
225                        "rules[{idx}]: unsupported action: {other} — supported: return, limit-count"
226                    ));
227                }
228            };
229
230            rules.push(Rule { case, action });
231        }
232
233        Ok(Self { rules })
234    }
235
236    /// Writes a JSON rejection onto the response and returns the error result
237    /// so the graph engine routes through the error port.
238    fn reject(
239        mut ctx: Context,
240        status: u16,
241        body: Bytes,
242        code: &str,
243        message: &str,
244    ) -> PluginResult {
245        ctx.response.status_code = status;
246        ctx.response.body = body;
247        ctx.response.headers.insert(
248            "content-type".to_string(),
249            vec!["application/json".to_string()],
250        );
251        Err(PluginExecutionError {
252            context: ctx,
253            error: GatewayError {
254                node_id: String::new(),
255                code: code.to_string(),
256                message: message.to_string(),
257                metadata: HashMap::new(),
258            },
259        })
260    }
261}
262
263/// Sets the standard `X-RateLimit-*` response headers (APISIX's
264/// `show_limit_quota_header` behavior, always on).
265fn set_quota_headers(ctx: &mut Context, limit: u64, remaining: u64, reset: Duration) {
266    ctx.response
267        .headers
268        .insert("x-ratelimit-limit".to_string(), vec![limit.to_string()]);
269    ctx.response.headers.insert(
270        "x-ratelimit-remaining".to_string(),
271        vec![remaining.to_string()],
272    );
273    ctx.response.headers.insert(
274        "x-ratelimit-reset".to_string(),
275        vec![reset.as_secs().to_string()],
276    );
277}
278
279#[async_trait]
280impl Plugin for WorkflowPlugin {
281    fn plugin_type(&self) -> &str {
282        "workflow"
283    }
284
285    async fn execute(
286        &self,
287        mut ctx: Context,
288        _named_inputs: &HashMap<String, serde_json::Value>,
289    ) -> PluginResult {
290        for rule in &self.rules {
291            let matched = rule.case.as_ref().is_none_or(|e| e.eval(&ctx));
292            if !matched {
293                continue;
294            }
295
296            // First matching case wins.
297            match &rule.action {
298                Action::Return { code } => {
299                    return Self::reject(
300                        ctx,
301                        *code,
302                        Bytes::from_static(br#"{"error_msg":"rejected by workflow"}"#),
303                        "WORKFLOW_REJECTED",
304                        "rejected by workflow",
305                    );
306                }
307                Action::LimitCount(lc) => {
308                    let key = format!(
309                        "{}:{}",
310                        lc.counter_prefix,
311                        interpolate(&ctx, &lc.key_template)
312                    );
313                    let window = match lc
314                        .store
315                        .incr_fixed_window(&key, lc.count, lc.time_window)
316                        .await
317                    {
318                        Ok(w) => w,
319                        Err(e) => {
320                            // Counter backend failure: reject rather than fail open.
321                            return Self::reject(
322                                ctx,
323                                500,
324                                Bytes::from_static(br#"{"error_msg":"rate limit backend error"}"#),
325                                "RATE_LIMIT_ERROR",
326                                &e.to_string(),
327                            );
328                        }
329                    };
330
331                    set_quota_headers(&mut ctx, window.limit, window.remaining, window.reset);
332
333                    if window.allowed {
334                        return Ok(PluginOutput {
335                            context: ctx,
336                            named_outputs: HashMap::new(),
337                        });
338                    }
339                    let body = match &lc.rejected_msg {
340                        Some(msg) => {
341                            Bytes::from(serde_json::json!({ "error_msg": msg }).to_string())
342                        }
343                        None => Bytes::new(),
344                    };
345                    return Self::reject(
346                        ctx,
347                        lc.rejected_code,
348                        body,
349                        "RATE_LIMITED",
350                        "rejected by workflow limit-count",
351                    );
352                }
353            }
354        }
355
356        // No rule matched: passthrough.
357        Ok(PluginOutput {
358            context: ctx,
359            named_outputs: HashMap::new(),
360        })
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
368
369    fn test_ctx(path: &str) -> Context {
370        Context {
371            request: GatewayRequest {
372                method: "GET".to_string(),
373                path: path.to_string(),
374                host: "example.com".to_string(),
375                scheme: "http".to_string(),
376                headers: HashMap::new(),
377                query_params: HashMap::new(),
378                body: Bytes::new(),
379                remote_addr: "10.1.2.3:44321".to_string(),
380                protocol: Protocol::Http1,
381            },
382            response: GatewayResponse {
383                status_code: 0,
384                headers: HashMap::new(),
385                body: Bytes::new(),
386            },
387            message: HashMap::new(),
388            errors: Vec::new(),
389        }
390    }
391
392    fn plugin(config: serde_json::Value) -> Result<WorkflowPlugin, String> {
393        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
394        WorkflowPlugin::from_config(&map, &PluginResources::empty())
395    }
396
397    #[tokio::test]
398    async fn test_return_action_rejects_matching_request() {
399        let p = plugin(serde_json::json!({
400            "rules": [{
401                "case": [["uri", "~~", "^/admin"]],
402                "actions": [["return", { "code": 403 }]]
403            }]
404        }))
405        .unwrap();
406
407        let err = p
408            .execute(test_ctx("/admin/users"), &HashMap::new())
409            .await
410            .unwrap_err();
411        assert_eq!(err.error.code, "WORKFLOW_REJECTED");
412        assert_eq!(err.context.response.status_code, 403);
413        assert_eq!(
414            err.context.response.body,
415            Bytes::from_static(br#"{"error_msg":"rejected by workflow"}"#)
416        );
417
418        // Non-matching request passes through untouched.
419        let out = p
420            .execute(test_ctx("/public"), &HashMap::new())
421            .await
422            .unwrap();
423        assert_eq!(out.context.response.status_code, 0);
424    }
425
426    #[tokio::test]
427    async fn test_rule_without_case_matches_all() {
428        let p = plugin(serde_json::json!({
429            "rules": [{ "actions": [["return", { "code": 418 }]] }]
430        }))
431        .unwrap();
432        let err = p
433            .execute(test_ctx("/anything"), &HashMap::new())
434            .await
435            .unwrap_err();
436        assert_eq!(err.context.response.status_code, 418);
437    }
438
439    #[tokio::test]
440    async fn test_first_matching_rule_wins() {
441        let p = plugin(serde_json::json!({
442            "rules": [
443                { "case": [["uri", "==", "/both"]], "actions": [["return", { "code": 401 }]] },
444                { "actions": [["return", { "code": 403 }]] }
445            ]
446        }))
447        .unwrap();
448        let err = p
449            .execute(test_ctx("/both"), &HashMap::new())
450            .await
451            .unwrap_err();
452        assert_eq!(err.context.response.status_code, 401);
453        let err = p
454            .execute(test_ctx("/other"), &HashMap::new())
455            .await
456            .unwrap_err();
457        assert_eq!(err.context.response.status_code, 403);
458    }
459
460    #[tokio::test]
461    async fn test_limit_count_allows_then_rejects() {
462        let p = plugin(serde_json::json!({
463            "rules": [{
464                "actions": [["limit-count", {
465                    "count": 2,
466                    "time_window": 60,
467                    "rejected_msg": "over quota"
468                }]]
469            }]
470        }))
471        .unwrap();
472
473        for i in 0..2 {
474            let out = p
475                .execute(test_ctx("/x"), &HashMap::new())
476                .await
477                .unwrap_or_else(|_| panic!("request {i} should pass"));
478            assert_eq!(
479                out.context.response.headers.get("x-ratelimit-limit"),
480                Some(&vec!["2".to_string()])
481            );
482        }
483
484        let err = p
485            .execute(test_ctx("/x"), &HashMap::new())
486            .await
487            .unwrap_err();
488        assert_eq!(err.error.code, "RATE_LIMITED");
489        assert_eq!(err.context.response.status_code, 503);
490        assert_eq!(
491            err.context.response.body,
492            Bytes::from(r#"{"error_msg":"over quota"}"#)
493        );
494        assert_eq!(
495            err.context.response.headers.get("x-ratelimit-remaining"),
496            Some(&vec!["0".to_string()])
497        );
498    }
499
500    #[tokio::test]
501    async fn test_limit_count_key_isolates_clients() {
502        let p = plugin(serde_json::json!({
503            "rules": [{
504                "actions": [["limit-count", { "count": 1, "time_window": 60 }]]
505            }]
506        }))
507        .unwrap();
508
509        assert!(p.execute(test_ctx("/x"), &HashMap::new()).await.is_ok());
510        assert!(p.execute(test_ctx("/x"), &HashMap::new()).await.is_err());
511
512        // A different remote_addr gets its own window (default key $remote_addr).
513        let mut other = test_ctx("/x");
514        other.request.remote_addr = "192.168.9.9:1".to_string();
515        assert!(p.execute(other, &HashMap::new()).await.is_ok());
516    }
517
518    #[test]
519    fn test_config_errors() {
520        // No rules.
521        assert!(plugin(serde_json::json!({})).is_err());
522        assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
523        // Unsupported action.
524        assert!(plugin(serde_json::json!({
525            "rules": [{ "actions": [["redirect", { "uri": "/x" }]] }]
526        }))
527        .is_err());
528        // return without code.
529        assert!(plugin(serde_json::json!({
530            "rules": [{ "actions": [["return", {}]] }]
531        }))
532        .is_err());
533        // limit-count missing count/time_window.
534        assert!(plugin(serde_json::json!({
535            "rules": [{ "actions": [["limit-count", { "count": 5 }]] }]
536        }))
537        .is_err());
538        // Invalid case expression surfaces at load.
539        assert!(plugin(serde_json::json!({
540            "rules": [{
541                "case": [["uri", "bogus", "/x"]],
542                "actions": [["return", { "code": 403 }]]
543            }]
544        }))
545        .is_err());
546        // Unknown counter policy.
547        assert!(plugin(serde_json::json!({
548            "rules": [{
549                "actions": [["limit-count", { "count": 1, "time_window": 1, "policy": "redis" }]]
550            }]
551        }))
552        .is_err());
553    }
554}