Skip to main content

composite_engine/
engine.rs

1use cel_engine::CelEngine;
2use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
3use rego_engine::RegoEngine;
4use rules::{RuleInfo, RuleMetadataEntry};
5use schema_validator::SchemaValidator;
6use std::collections::HashMap;
7use std::sync::Arc;
8use template_model::SemanticModel;
9use validation_engine::{
10    CompositeEngineConfig, EngineConfig, ValidateConfig, ValidationEngine, ValidationError, build_rule_list,
11};
12use web_time::Instant;
13
14/// A validation engine that composes two engines: one owns the built-in rules,
15/// the other evaluates the caller-supplied Rego rules.
16///
17/// The built-in rules are always evaluated by the CEL engine, which also
18/// evaluates custom CEL rules (a CEL-engine feature) and Guard rules (which every
19/// engine evaluates through the shared Guard evaluator, so the built-in engine is
20/// as good a host as any). Custom Rego rules need a Rego engine, so one is built
21/// in external-only mode - contributing no built-in findings of its own - and
22/// only when the configuration supplies Rego rules. Findings from both are
23/// concatenated; the surrounding validation pipeline performs the single finalize
24/// pass (dedup, sort, filter, enrich).
25pub struct CompositeEngine {
26    builtin_engine: CelEngine,
27    external_engine: Option<RegoEngine>,
28    init_metric: PhaseMetric,
29}
30
31impl CompositeEngine {
32    /// Builds the composite engine from a [`CompositeEngineConfig`]. The built-in
33    /// engine is always constructed; the external engine is constructed only when
34    /// the configuration supplies custom Rego rules.
35    pub fn new(config: CompositeEngineConfig) -> anyhow::Result<Self> {
36        let start = Instant::now();
37        let (builtin_config, external_config) = split_configs(config);
38        let builtin_engine = CelEngine::new(builtin_config)?;
39        let external_engine = external_config.map(RegoEngine::new_external_only).transpose()?;
40        Ok(Self { builtin_engine, external_engine, init_metric: phase_metric(start) })
41    }
42
43    /// Constructs the composite engine reusing an already-built
44    /// [`SchemaValidator`], so both inner engines share its overlay catalog and
45    /// schema-metadata catalog rather than re-resolving the overlay schemas.
46    ///
47    /// This entry point is intended for language bindings and the CLI, which
48    /// construct a `SchemaValidator` once and share it with the engine.
49    #[doc(hidden)]
50    pub fn new_with_schema_validator(
51        config: CompositeEngineConfig,
52        validator: &SchemaValidator,
53    ) -> anyhow::Result<Self> {
54        let start = Instant::now();
55        let config = CompositeEngineConfig { schema_validator_config: None, ..config };
56        let (builtin_config, external_config) = split_configs(config);
57        let builtin_engine = CelEngine::new_with_schema_validator(builtin_config, validator)?;
58        let external_engine = external_config
59            .map(|config| RegoEngine::new_external_only_with_schema_validator(config, validator))
60            .transpose()?;
61        Ok(Self { builtin_engine, external_engine, init_metric: phase_metric(start) })
62    }
63}
64
65/// Splits a composite configuration into the built-in engine's config and an
66/// optional external-engine config. Rule sources are moved rather than cloned;
67/// the schema config is cloned only when both engines need it. Custom CEL rules
68/// and Guard rules go to the built-in CEL engine - CEL custom rules are a
69/// CEL-engine feature, and Guard rules evaluate identically in every engine, so
70/// they never justify constructing a second engine. Only custom Rego rules go to
71/// the external engine.
72fn split_configs(config: CompositeEngineConfig) -> (EngineConfig, Option<EngineConfig>) {
73    let CompositeEngineConfig { rego_rules, cel_rules, guard_rules, schema_validator_config } = config;
74    let external_config = (!rego_rules.is_empty()).then(|| EngineConfig {
75        custom_rules: rego_rules,
76        guard_rules: Vec::new(),
77        schema_validator_config: schema_validator_config.clone(),
78    });
79    let builtin_config = EngineConfig { custom_rules: cel_rules, guard_rules, schema_validator_config };
80    (builtin_config, external_config)
81}
82
83impl ValidationEngine for CompositeEngine {
84    fn engine_name(&self) -> &str {
85        "composite"
86    }
87
88    fn evaluate_rules(
89        &self,
90        model: &Arc<SemanticModel>,
91        config: &ValidateConfig,
92    ) -> Result<Vec<Diagnostic>, ValidationError> {
93        // The built-in engine runs first and owns every built-in rule along with
94        // the custom CEL and Guard rules. The external engine runs second and
95        // contributes only custom Rego findings, so it is evaluated even when
96        // built-ins are disabled. Findings are concatenated only; the surrounding
97        // pipeline finalizes them once.
98        let mut diagnostics = self.builtin_engine.evaluate_rules(model, config)?;
99        if let Some(external_engine) = &self.external_engine {
100            diagnostics.extend(external_engine.evaluate_rules(model, config)?);
101        }
102        Ok(diagnostics)
103    }
104
105    fn list_rules(&self) -> Vec<RuleInfo> {
106        build_rule_list(self.builtin_engine.rule_metadata(), &self.external_rule_metadata())
107    }
108
109    fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
110        self.builtin_engine.rule_metadata()
111    }
112
113    /// The external rule metadata is the union of both engines' external rules:
114    /// custom CEL and Guard rules from the built-in engine and custom Rego rules
115    /// from the external engine. The two sets have disjoint rule IDs, so a plain
116    /// merge cannot drop or overwrite a rule.
117    fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
118        let mut merged = self.builtin_engine.external_rule_metadata();
119        if let Some(external_engine) = &self.external_engine {
120            merged.extend(external_engine.external_rule_metadata());
121        }
122        merged
123    }
124
125    fn init_metric(&self) -> &PhaseMetric {
126        &self.init_metric
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use rules::{RuleOrigin, Severity};
134    use std::collections::HashSet;
135    use validation_engine::ExternalRuleSource;
136
137    const BUCKET_TEMPLATE: &str = r#"
138AWSTemplateFormatVersion: "2010-09-09"
139Resources:
140  MyBucket:
141    Type: AWS::S3::Bucket
142    Properties:
143      BucketName: my-bucket
144"#;
145
146    const BUCKET_WITHOUT_NAME_TEMPLATE: &str = r#"
147AWSTemplateFormatVersion: "2010-09-09"
148Resources:
149  MyBucket:
150    Type: AWS::S3::Bucket
151"#;
152
153    fn model(yaml: &str) -> Arc<SemanticModel> {
154        Arc::new(SemanticModel::from_bytes(yaml.as_bytes()).expect("template must parse"))
155    }
156
157    /// A comparable projection of the fields that define a diagnostic's identity:
158    /// rule ID, severity, source location, property path, and message. `Diagnostic`
159    /// does not implement `PartialEq`, and these are its observable outcome.
160    type Fingerprint = (String, Severity, Option<(u32, u32, u32, u32)>, Option<String>, String);
161
162    fn fingerprints(diagnostics: &[Diagnostic]) -> Vec<Fingerprint> {
163        let mut projected: Vec<Fingerprint> = diagnostics
164            .iter()
165            .map(|d| {
166                let location = d.location.as_ref().map(|s| (s.start_line, s.start_column, s.end_line, s.end_column));
167                (d.rule_id.clone(), d.severity, location, d.property_path.clone(), d.message.clone())
168            })
169            .collect();
170        projected.sort();
171        projected
172    }
173
174    fn custom_rego_rule() -> ExternalRuleSource {
175        ExternalRuleSource {
176            name: "composite_custom.rego".into(),
177            content: r#"
178package composite_custom
179import rego.v1
180
181violation contains v if {
182    some name, res in input.resources
183    res.resourceType == "AWS::S3::Bucket"
184    v := {"rule_id": "COMPOSITE_CUSTOM", "severity": "error", "message": "custom rego fired", "resource_id": name}
185}
186"#
187            .into(),
188        }
189    }
190
191    fn bucket_name_exists_guard() -> ExternalRuleSource {
192        ExternalRuleSource {
193            name: "bucket_name.guard".into(),
194            content: r#"
195rule check_bucket_name {
196    AWS::S3::Bucket {
197        Properties.BucketName EXISTS
198        <<BucketName must be specified>>
199    }
200}
201"#
202            .into(),
203        }
204    }
205
206    /// A custom CEL rule that fires on every S3 bucket, exercising the
207    /// composite's CEL-custom-rule path (evaluated by the built-in engine).
208    fn custom_cel_rule() -> ExternalRuleSource {
209        ExternalRuleSource {
210            name: "composite_custom.json".into(),
211            content: r#"{"rules": [{
212                "rule_id": "COMPOSITE_CEL",
213                "severity": "ERROR",
214                "resource_type": "AWS::S3::Bucket",
215                "expression": "true",
216                "message": "custom cel fired"
217            }]}"#
218                .into(),
219        }
220    }
221
222    fn builtin_only_diagnostics(model: &Arc<SemanticModel>, config: &ValidateConfig) -> Vec<Diagnostic> {
223        CelEngine::new(EngineConfig::default())
224            .expect("built-in engine builds")
225            .evaluate_rules(model, config)
226            .expect("built-in engine evaluates")
227    }
228
229    #[test]
230    fn without_external_rules_no_external_engine_and_builtins_match_cel() {
231        let composite = CompositeEngine::new(CompositeEngineConfig::default()).expect("composite builds");
232        assert!(composite.external_engine.is_none(), "with no external rules there must be no external engine");
233
234        let model = model(BUCKET_TEMPLATE);
235        let config = ValidateConfig::default();
236        let composite_diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
237
238        assert_eq!(
239            fingerprints(&composite_diags),
240            fingerprints(&builtin_only_diagnostics(&model, &config)),
241            "with no external rules the composite must produce exactly the built-in diagnostics"
242        );
243    }
244
245    #[test]
246    fn custom_rego_adds_one_finding_and_builtins_appear_exactly_once() {
247        let composite = CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([custom_rego_rule()]))
248            .expect("composite builds");
249        assert!(composite.external_engine.is_some(), "custom rego rules must construct the external engine");
250
251        let model = model(BUCKET_TEMPLATE);
252        let config = ValidateConfig::default();
253        let composite_diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
254
255        let custom: Vec<&Diagnostic> = composite_diags.iter().filter(|d| d.rule_id == "COMPOSITE_CUSTOM").collect();
256        assert_eq!(custom.len(), 1, "the custom rego rule must contribute exactly one finding");
257        assert_eq!(custom[0].severity, Severity::Error);
258        assert_eq!(custom[0].source, RuleOrigin::Custom);
259        assert_eq!(custom[0].message, "custom rego fired");
260
261        // The built-in portion must equal a standalone built-in run exactly - each
262        // built-in appears once, with no duplication introduced by the composite.
263        let builtins: Vec<Diagnostic> =
264            composite_diags.iter().filter(|d| d.rule_id != "COMPOSITE_CUSTOM").cloned().collect();
265        assert_eq!(
266            fingerprints(&builtins),
267            fingerprints(&builtin_only_diagnostics(&model, &config)),
268            "built-in diagnostics must appear exactly once, matching a standalone built-in run"
269        );
270    }
271
272    #[test]
273    fn disabling_builtins_returns_only_external_findings() {
274        let composite = CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([custom_rego_rule()]))
275            .expect("composite builds");
276        let model = model(BUCKET_TEMPLATE);
277        let config = ValidateConfig { disable_builtin_rules: true, ..ValidateConfig::default() };
278
279        let diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
280        assert_eq!(diags.len(), 1, "only the external finding must remain when built-ins are disabled");
281        assert_eq!(diags[0].rule_id, "COMPOSITE_CUSTOM");
282        assert_eq!(diags[0].source, RuleOrigin::Custom);
283    }
284
285    #[test]
286    fn custom_cel_adds_one_finding_and_builtins_appear_exactly_once() {
287        let composite = CompositeEngine::new(CompositeEngineConfig::new().with_cel_rules([custom_cel_rule()]))
288            .expect("composite builds");
289        // A CEL custom rule is owned by the built-in engine, so it needs no
290        // external engine.
291        assert!(composite.external_engine.is_none(), "a CEL-only custom rule must not construct the external engine");
292
293        let model = model(BUCKET_TEMPLATE);
294        let config = ValidateConfig::default();
295        let composite_diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
296
297        let custom: Vec<&Diagnostic> = composite_diags.iter().filter(|d| d.rule_id == "COMPOSITE_CEL").collect();
298        assert_eq!(custom.len(), 1, "the custom cel rule must contribute exactly one finding");
299        assert_eq!(custom[0].severity, Severity::Error);
300        assert_eq!(custom[0].source, RuleOrigin::Custom);
301        assert_eq!(custom[0].message, "custom cel fired");
302
303        let builtins: Vec<Diagnostic> =
304            composite_diags.iter().filter(|d| d.rule_id != "COMPOSITE_CEL").cloned().collect();
305        assert_eq!(
306            fingerprints(&builtins),
307            fingerprints(&builtin_only_diagnostics(&model, &config)),
308            "built-in diagnostics must appear exactly once alongside the custom CEL finding"
309        );
310    }
311
312    #[test]
313    fn cel_rego_and_guard_custom_rules_all_fire_together() {
314        let composite = CompositeEngine::new(
315            CompositeEngineConfig::new()
316                .with_cel_rules([custom_cel_rule()])
317                .with_rego_rules([custom_rego_rule()])
318                .with_guard_rules([bucket_name_exists_guard()]),
319        )
320        .expect("composite builds");
321        assert!(composite.external_engine.is_some(), "custom Rego rules must construct the external engine");
322
323        // BUCKET_WITHOUT_NAME_TEMPLATE has no BucketName, so the guard EXISTS
324        // check fires while the CEL and Rego rules match any S3 bucket.
325        let model = model(BUCKET_WITHOUT_NAME_TEMPLATE);
326        let diags = composite.evaluate_rules(&model, &ValidateConfig::default()).expect("composite evaluates");
327
328        assert_eq!(
329            diags.iter().filter(|d| d.rule_id == "COMPOSITE_CEL").count(),
330            1,
331            "the custom CEL rule must fire once"
332        );
333        assert_eq!(
334            diags.iter().filter(|d| d.rule_id == "COMPOSITE_CUSTOM").count(),
335            1,
336            "the custom Rego rule must fire once"
337        );
338        assert_eq!(
339            diags.iter().filter(|d| d.rule_id == "check_bucket_name").count(),
340            1,
341            "the Guard rule must fire once"
342        );
343    }
344
345    #[test]
346    fn list_rules_includes_cel_custom_rules() {
347        let composite = CompositeEngine::new(CompositeEngineConfig::new().with_cel_rules([custom_cel_rule()]))
348            .expect("composite builds");
349        let ids: HashSet<String> = composite.list_rules().into_iter().map(|r| r.id).collect();
350        assert!(ids.contains("COMPOSITE_CEL"), "composite listRules must include the custom CEL rule");
351    }
352
353    #[test]
354    fn guard_exists_does_not_fire_when_property_present() {
355        let composite =
356            CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([bucket_name_exists_guard()]))
357                .expect("composite builds");
358        assert!(
359            composite.external_engine.is_none(),
360            "Guard rules are evaluated by the built-in engine and must not construct the external engine"
361        );
362        let diags = composite.evaluate_rules(&model(BUCKET_TEMPLATE), &ValidateConfig::default()).expect("evaluates");
363        assert!(
364            !diags.iter().any(|d| d.rule_id == "check_bucket_name"),
365            "the guard EXISTS check must be satisfied when BucketName is present; got: {:?}",
366            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
367        );
368    }
369
370    #[test]
371    fn guard_exists_fires_once_when_property_absent() {
372        let composite =
373            CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([bucket_name_exists_guard()]))
374                .expect("composite builds");
375        let diags = composite
376            .evaluate_rules(&model(BUCKET_WITHOUT_NAME_TEMPLATE), &ValidateConfig::default())
377            .expect("evaluates");
378
379        let guard: Vec<&Diagnostic> = diags.iter().filter(|d| d.rule_id == "check_bucket_name").collect();
380        assert_eq!(guard.len(), 1, "the guard rule must fire once when BucketName is absent");
381        assert_eq!(guard[0].source, RuleOrigin::Guard);
382    }
383
384    #[test]
385    fn guard_nested_exists_matches_nested_property_presence() {
386        let guard_rule = ExternalRuleSource {
387            name: "nested_exists.guard".into(),
388            content: r#"
389rule check_versioning_status {
390    AWS::S3::Bucket {
391        Properties.VersioningConfiguration.Status EXISTS
392        <<Versioning status must be specified>>
393    }
394}
395"#
396            .into(),
397        };
398        let composite = CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([guard_rule]))
399            .expect("composite builds");
400        let present = model(
401            r#"
402Resources:
403  MyBucket:
404    Type: AWS::S3::Bucket
405    Properties:
406      VersioningConfiguration:
407        Status: Enabled
408"#,
409        );
410
411        let present_diagnostics = composite.evaluate_rules(&present, &ValidateConfig::default()).expect("evaluates");
412        assert!(
413            !present_diagnostics.iter().any(|diagnostic| diagnostic.rule_id == "check_versioning_status"),
414            "nested EXISTS must pass when the nested property is present"
415        );
416
417        let absent_diagnostics = composite
418            .evaluate_rules(&model(BUCKET_WITHOUT_NAME_TEMPLATE), &ValidateConfig::default())
419            .expect("evaluates");
420        assert_eq!(
421            absent_diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "check_versioning_status").count(),
422            1,
423            "nested EXISTS must fire once when the nested property is absent"
424        );
425    }
426
427    #[test]
428    fn list_rules_includes_builtins_and_external_rules() {
429        let composite =
430            CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([bucket_name_exists_guard()]))
431                .expect("composite builds");
432
433        let composite_ids: HashSet<String> = composite.list_rules().into_iter().map(|r| r.id).collect();
434        let builtin_ids: HashSet<String> = CelEngine::new(EngineConfig::default())
435            .expect("built-in engine builds")
436            .list_rules()
437            .into_iter()
438            .map(|r| r.id)
439            .collect();
440
441        assert!(!builtin_ids.is_empty(), "sanity: the built-in engine advertises rules");
442        assert!(builtin_ids.is_subset(&composite_ids), "composite listRules must include every built-in rule");
443        assert!(
444            composite_ids.contains("check_bucket_name"),
445            "composite listRules must include the external guard rule"
446        );
447    }
448
449    #[test]
450    fn init_metric_spans_both_engine_constructions() {
451        let composite = CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([custom_rego_rule()]))
452            .expect("composite builds");
453        let external = composite.external_engine.as_ref().expect("external engine constructed");
454
455        assert!(composite.init_metric().duration_ms > 0.0, "the init metric must be recorded");
456        assert!(
457            composite.init_metric().duration_ms >= external.init_metric().duration_ms,
458            "the composite init metric must span the external engine's construction, not replace it"
459        );
460    }
461}