1use 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
33static INSTANCE_SEQ: AtomicU64 = AtomicU64::new(0);
37
38pub struct WorkflowPlugin {
42 rules: Vec<Rule>,
43}
44
45struct Rule {
46 case: Option<Expr>,
48 action: Action,
49}
50
51enum Action {
52 Return { code: u16 },
54 LimitCount(LimitCount),
56}
57
58struct LimitCount {
59 count: u64,
60 time_window: Duration,
61 key_template: Template,
65 rejected_code: u16,
66 rejected_msg: Option<Template>,
70 counter_prefix: String,
72 store: Arc<dyn CounterStore>,
73}
74
75impl WorkflowPlugin {
76 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 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 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 .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 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 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
301fn 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 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 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 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 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 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 assert!(plugin(serde_json::json!({})).is_err());
557 assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
558 assert!(plugin(serde_json::json!({
560 "rules": [{ "actions": [["redirect", { "uri": "/x" }]] }]
561 }))
562 .is_err());
563 assert!(plugin(serde_json::json!({
565 "rules": [{ "actions": [["return", {}]] }]
566 }))
567 .is_err());
568 assert!(plugin(serde_json::json!({
570 "rules": [{ "actions": [["limit-count", { "count": 5 }]] }]
571 }))
572 .is_err());
573 assert!(plugin(serde_json::json!({
575 "rules": [{
576 "case": [["uri", "bogus", "/x"]],
577 "actions": [["return", { "code": 403 }]]
578 }]
579 }))
580 .is_err());
581 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 #[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}