dd_sds/match_validation/
config_v2.rs

1use serde::{Deserialize, Serialize};
2use std::{
3    collections::BTreeMap,
4    fmt::{self, Display, Formatter},
5    time::Duration,
6};
7
8use crate::HttpMethod;
9
10/// Configuration for Online Validation V2
11#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
12pub struct CustomHttpConfigV2 {
13    /// Optional match pairing configuration for validating using matches from multiple rules
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub match_pairing: Option<MatchPairingConfig>,
16
17    /// Optional list of values this rule provides to other paired validators.
18    /// Allows a rule to both self-validate with CustomHttpV2 and contribute its
19    /// match value as named template variables to other CustomHttpV2 rules.
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub provides: Option<Vec<PairedValidatorConfig>>,
22
23    /// Array of HTTP calls to attempt. Only one needs to succeed for validation.
24    #[serde(default)]
25    pub calls: Vec<HttpCallConfig>,
26}
27
28impl CustomHttpConfigV2 {
29    pub fn with_call(mut self, call: HttpCallConfig) -> Self {
30        self.calls.push(call);
31        self
32    }
33}
34
35/// Configuration for pairing matches from multiple rules together
36#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
37pub struct MatchPairingConfig {
38    /// Vendor identifier to match across rules.
39    pub kind: String,
40
41    /// Map of parameter names to template variables
42    #[serde(flatten)]
43    pub parameters: BTreeMap<String, String>,
44}
45
46impl MatchPairingConfig {
47    pub fn is_fulfilled_by(&self, template_variables: &[TemplateVariable]) -> bool {
48        self.parameters.iter().all(|(_name, template_name)| {
49            template_variables.iter().any(|v| v.name == *template_name)
50        })
51    }
52}
53
54/// A single HTTP call configuration with request and response validation
55#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
56pub struct HttpCallConfig {
57    pub request: HttpRequestConfig,
58    pub response: HttpResponseConfig,
59}
60
61/// HTTP request configuration with templating support
62#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
63pub struct HttpRequestConfig {
64    /// Endpoint URL with optional template variables
65    /// Example: "https://$CLIENT_SUBDOMAIN.vendor.com/api/0/organizations/$MATCH"
66    pub endpoint: TemplatedMatchString,
67
68    pub method: HttpMethod,
69
70    /// Optional list of templated hosts for multi-datacenter support
71    /// If specified, $HOST in endpoint will be replaced with each host
72    #[serde(default)]
73    pub hosts: Vec<TemplatedMatchString>,
74
75    /// Request headers with template variable support
76    /// Example: {"Authorization": "Basic %base64($CLIENT_ID:$MATCH)"}
77    #[serde(default)]
78    pub headers: BTreeMap<String, TemplatedMatchString>,
79
80    /// Optional request body with template variable support
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub body: Option<TemplatedMatchString>,
83
84    #[serde(default = "default_timeout")]
85    pub timeout: Duration,
86}
87
88fn default_timeout() -> Duration {
89    Duration::from_secs(3)
90}
91
92/// Response validation configuration with multiple condition support
93#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
94pub struct HttpResponseConfig {
95    /// Array of response conditions to check
96    /// Conditions are evaluated sequentially until one matches
97    pub conditions: Vec<ResponseCondition>,
98}
99
100/// A response condition that determines if a secret is valid or invalid
101#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
102pub struct ResponseCondition {
103    /// Whether this condition indicates a valid or invalid secret
104    #[serde(rename = "type")]
105    pub condition_type: ResponseConditionType,
106
107    /// Optional status code matcher
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub status_code: Option<StatusCodeMatcher>,
110
111    /// Optional raw body matcher (before parsing)
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub raw_body: Option<BodyMatcher>,
114
115    /// Optional parsed body matchers (after JSON parsing)
116    /// Maps JSON paths to matchers
117    /// Example: {"message.stack[2].success.status": BodyMatcher}
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub body: Option<BTreeMap<String, BodyMatcher>>,
120}
121
122impl ResponseCondition {
123    /// Determines if a ResponseCondition matches a given status code and body
124    /// It does this by checking against each of the optional conditions and aggregating the results.
125    ///
126    /// Consider the example of a ResponseCondition with the following conditions:
127    /// - status_code: 200
128    /// - raw_body: "success"
129    ///
130    /// If the status code is 200, the raw body is "success", ResponseCondition matches.
131    /// If the status code is 200, the raw body is "failure", then the ResponseCondition does not match.
132    ///
133    /// What happens next depends on the ResponseConditionType and whether or not the condition matched:
134    /// * Matched and Invalid -> Invalid
135    /// * Matched and Valid -> Valid
136    /// * Not matched -> NotChecked
137    pub fn matches(&self, status_code: u16, body: &str) -> ResponseConditionResult {
138        if let Some(status_code_matcher) = self.status_code.as_ref()
139            && !status_code_matcher.matches(status_code)
140        {
141            ResponseConditionResult::NotChecked
142        } else if let Some(raw_body_matcher) = self.raw_body.as_ref()
143            && !raw_body_matcher.matches(body)
144        {
145            ResponseConditionResult::NotChecked
146        } else if let Some(body_matcher) = self.body.as_ref()
147            && !matches_body(body_matcher, body)
148        {
149            ResponseConditionResult::NotChecked
150        } else {
151            self.condition_type.into()
152        }
153    }
154}
155
156fn matches_body(body_matcher: &BTreeMap<String, BodyMatcher>, body: &str) -> bool {
157    let parsed_body: serde_json::Value = match serde_json::from_str(body) {
158        Ok(value) => value,
159        Err(_) => return false,
160    };
161    for (path, matcher) in body_matcher.iter() {
162        let parts = path.split('.');
163        let mut value = &parsed_body;
164        for part in parts {
165            value = match value.get(part) {
166                Some(value) => value,
167                None => return false,
168            };
169        }
170        let value_str = match value {
171            serde_json::Value::String(s) => s.clone(),
172            other => other.to_string(),
173        };
174        if matcher.matches(&value_str) {
175            return true;
176        }
177    }
178    false
179}
180
181/// Type of response condition
182#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, Copy)]
183#[serde(rename_all = "lowercase")]
184pub enum ResponseConditionType {
185    Valid,
186    Invalid,
187}
188
189#[derive(Debug, PartialEq)]
190pub enum ResponseConditionResult {
191    Valid,
192    Invalid,
193    NotChecked,
194}
195
196impl From<ResponseConditionType> for ResponseConditionResult {
197    fn from(condition_type: ResponseConditionType) -> Self {
198        match condition_type {
199            ResponseConditionType::Valid => ResponseConditionResult::Valid,
200            ResponseConditionType::Invalid => ResponseConditionResult::Invalid,
201        }
202    }
203}
204
205/// Status code matcher supporting single, list, or range
206#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
207#[serde(untagged)]
208pub enum StatusCodeMatcher {
209    /// Single status code: 200
210    Single(u16),
211
212    /// List of status codes: [401, 403, 404]
213    List(Vec<u16>),
214
215    /// Range of status codes: {"start": 400, "end": 420}
216    Range { start: u16, end: u16 },
217}
218
219impl StatusCodeMatcher {
220    /// Check if a status code matches this matcher
221    pub fn matches(&self, status_code: u16) -> bool {
222        match self {
223            StatusCodeMatcher::Single(code) => status_code == *code,
224            StatusCodeMatcher::List(codes) => codes.contains(&status_code),
225            StatusCodeMatcher::Range { start, end } => status_code >= *start && status_code < *end,
226        }
227    }
228}
229
230/// Body content matcher
231#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
232#[serde(tag = "type", content = "config")]
233pub enum BodyMatcher {
234    /// Check that the body/field is present (not null/undefined)
235    Present,
236
237    /// Check for exact string match
238    ExactMatch(String),
239
240    /// Check if the value matches a regex pattern
241    Regex(String),
242}
243
244impl BodyMatcher {
245    pub fn matches(&self, body: &str) -> bool {
246        match self {
247            BodyMatcher::Present => !body.is_empty(),
248            BodyMatcher::ExactMatch(value) => body == *value,
249            BodyMatcher::Regex(pattern) => regex::Regex::new(pattern).unwrap().is_match(body),
250        }
251    }
252}
253
254/// Secondary validator type for rules that forward their match to a paired validator
255#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
256pub struct PairedValidatorConfig {
257    /// Vendor identifier to match the main validator
258    pub kind: String,
259
260    /// Name of the parameter this rule provides
261    /// Example: "client_id", "client_subdomain"
262    pub name: String,
263}
264
265#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
266pub struct TemplatedMatchString(pub String);
267
268impl TemplatedMatchString {
269    // pub fn with_rule_match(&self, rule_match: &RuleMatch) -> Self {
270    //     self.render("$MATCH", rule_match.match_value.as_ref().unwrap())
271    // }
272
273    // pub fn with_host(&self, host: &str) -> Self {
274    //     self.render("$HOST", host)
275    // }
276
277    pub fn with_template_variable(&self, template_variable: &TemplateVariable) -> Self {
278        self.render(
279            template_variable.name.as_str(),
280            template_variable.value.as_str(),
281        )
282    }
283
284    fn render(&self, tag: &str, value: &str) -> Self {
285        TemplatedMatchString(self.0.replace(tag, value))
286    }
287}
288
289impl Display for TemplatedMatchString {
290    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
291        write!(f, "{}", self.0)
292    }
293}
294
295#[derive(Clone, Debug, PartialEq, Eq, Hash)]
296pub struct TemplateVariable {
297    pub name: String,
298    pub value: String,
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_status_code_matcher_single() {
307        let matcher = StatusCodeMatcher::Single(200);
308        assert!(matcher.matches(200));
309        assert!(!matcher.matches(201));
310        assert!(!matcher.matches(404));
311    }
312
313    #[test]
314    fn test_status_code_matcher_list() {
315        let matcher = StatusCodeMatcher::List(vec![200, 201, 204]);
316        assert!(matcher.matches(200));
317        assert!(matcher.matches(201));
318        assert!(matcher.matches(204));
319        assert!(!matcher.matches(202));
320        assert!(!matcher.matches(404));
321    }
322
323    #[test]
324    fn test_status_code_matcher_range() {
325        let matcher = StatusCodeMatcher::Range {
326            start: 200,
327            end: 300,
328        };
329        assert!(matcher.matches(200));
330        assert!(matcher.matches(250));
331        assert!(matcher.matches(299));
332        assert!(!matcher.matches(199));
333        assert!(!matcher.matches(300));
334        assert!(!matcher.matches(404));
335    }
336
337    #[test]
338    fn test_body_matcher_present() {
339        let matcher = BodyMatcher::Present;
340        assert!(matcher.matches("test"));
341        assert!(!matcher.matches(""));
342    }
343
344    #[test]
345    fn test_body_matcher_exact_match() {
346        let matcher = BodyMatcher::ExactMatch("test".to_string());
347        assert!(matcher.matches("test"));
348        assert!(!matcher.matches("test1"));
349        assert!(!matcher.matches(""));
350    }
351
352    #[test]
353    fn test_body_matcher_regex() {
354        let matcher = BodyMatcher::Regex("test".to_string());
355        assert!(matcher.matches("test"));
356        assert!(matcher.matches("test1"));
357        assert!(!matcher.matches("different"));
358    }
359
360    fn valid_condition_with_status_and_raw_body(
361        status: StatusCodeMatcher,
362        raw_body: BodyMatcher,
363    ) -> ResponseCondition {
364        ResponseCondition {
365            condition_type: ResponseConditionType::Valid,
366            status_code: Some(status),
367            raw_body: Some(raw_body),
368            body: None,
369        }
370    }
371
372    // status_code + raw_body: all four combinations of match/miss
373
374    #[test]
375    fn test_condition_status_and_raw_body_both_match_returns_valid() {
376        let cond = valid_condition_with_status_and_raw_body(
377            StatusCodeMatcher::Single(200),
378            BodyMatcher::ExactMatch("ok".to_string()),
379        );
380        assert_eq!(cond.matches(200, "ok"), ResponseConditionResult::Valid);
381    }
382
383    #[test]
384    fn test_condition_status_matches_raw_body_does_not_returns_not_checked() {
385        let cond = valid_condition_with_status_and_raw_body(
386            StatusCodeMatcher::Single(200),
387            BodyMatcher::ExactMatch("ok".to_string()),
388        );
389        // status matches but body does not — all sub-conditions must agree
390        assert_eq!(
391            cond.matches(200, "error"),
392            ResponseConditionResult::NotChecked
393        );
394    }
395
396    #[test]
397    fn test_condition_raw_body_matches_status_does_not_returns_not_checked() {
398        let cond = valid_condition_with_status_and_raw_body(
399            StatusCodeMatcher::Single(200),
400            BodyMatcher::ExactMatch("ok".to_string()),
401        );
402        // body matches but status does not — all sub-conditions must agree
403        assert_eq!(cond.matches(401, "ok"), ResponseConditionResult::NotChecked);
404    }
405
406    #[test]
407    fn test_condition_status_and_raw_body_neither_matches_returns_not_checked() {
408        let cond = valid_condition_with_status_and_raw_body(
409            StatusCodeMatcher::Single(200),
410            BodyMatcher::ExactMatch("ok".to_string()),
411        );
412        assert_eq!(
413            cond.matches(401, "error"),
414            ResponseConditionResult::NotChecked
415        );
416    }
417
418    #[test]
419    fn test_condition_invalid_type_status_and_raw_body_both_match_returns_invalid() {
420        let cond = ResponseCondition {
421            condition_type: ResponseConditionType::Invalid,
422            status_code: Some(StatusCodeMatcher::Single(401)),
423            raw_body: Some(BodyMatcher::ExactMatch("unauthorized".to_string())),
424            body: None,
425        };
426        assert_eq!(
427            cond.matches(401, "unauthorized"),
428            ResponseConditionResult::Invalid
429        );
430    }
431
432    #[test]
433    fn test_condition_invalid_type_status_matches_raw_body_does_not_returns_not_checked() {
434        let cond = ResponseCondition {
435            condition_type: ResponseConditionType::Invalid,
436            status_code: Some(StatusCodeMatcher::Single(401)),
437            raw_body: Some(BodyMatcher::ExactMatch("unauthorized".to_string())),
438            body: None,
439        };
440        // status matches but body does not — the Invalid condition should not fire
441        assert_eq!(
442            cond.matches(401, "some other body"),
443            ResponseConditionResult::NotChecked
444        );
445    }
446
447    // status_code + body (BTreeMap) combinations
448
449    #[test]
450    fn test_condition_status_and_body_map_both_match_returns_valid() {
451        let mut body_map = BTreeMap::new();
452        body_map.insert(
453            "status".to_string(),
454            BodyMatcher::ExactMatch("active".to_string()),
455        );
456        let cond = ResponseCondition {
457            condition_type: ResponseConditionType::Valid,
458            status_code: Some(StatusCodeMatcher::Single(200)),
459            raw_body: None,
460            body: Some(body_map),
461        };
462        assert_eq!(
463            cond.matches(200, r#"{"status":"active"}"#),
464            ResponseConditionResult::Valid
465        );
466    }
467
468    #[test]
469    fn test_condition_status_matches_body_map_does_not_returns_not_checked() {
470        let mut body_map = BTreeMap::new();
471        body_map.insert(
472            "status".to_string(),
473            BodyMatcher::ExactMatch("active".to_string()),
474        );
475        let cond = ResponseCondition {
476            condition_type: ResponseConditionType::Valid,
477            status_code: Some(StatusCodeMatcher::Single(200)),
478            raw_body: None,
479            body: Some(body_map),
480        };
481        // status matches but the body field does not — condition should not fire
482        assert_eq!(
483            cond.matches(200, r#"{"status":"inactive"}"#),
484            ResponseConditionResult::NotChecked
485        );
486    }
487
488    #[test]
489    fn test_condition_body_map_matches_status_does_not_returns_not_checked() {
490        let mut body_map = BTreeMap::new();
491        body_map.insert(
492            "status".to_string(),
493            BodyMatcher::ExactMatch("active".to_string()),
494        );
495        let cond = ResponseCondition {
496            condition_type: ResponseConditionType::Valid,
497            status_code: Some(StatusCodeMatcher::Single(200)),
498            raw_body: None,
499            body: Some(body_map),
500        };
501        // body field matches but status does not — condition should not fire
502        assert_eq!(
503            cond.matches(401, r#"{"status":"active"}"#),
504            ResponseConditionResult::NotChecked
505        );
506    }
507
508    #[test]
509    fn test_custom_http_v2_config_with_provides() {
510        let config: CustomHttpConfigV2 = serde_yaml::from_str(
511            r#"
512provides:
513  - kind: "vendor_xyz"
514    name: "client_subdomain"
515calls:
516  - request:
517      endpoint: "https://example.com/validate?secret=$MATCH"
518      method: GET
519    response:
520      conditions: []
521"#,
522        )
523        .unwrap();
524
525        assert_eq!(config.provides.as_ref().map(Vec::len), Some(1));
526        let provided = &config.provides.as_ref().unwrap()[0];
527        assert_eq!(provided.kind, "vendor_xyz");
528        assert_eq!(provided.name, "client_subdomain");
529    }
530}