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