Skip to main content

iac_forge/
policy.rs

1//! Policy-as-code over sexpr patterns.
2//!
3//! Compliance controls (NIST SC-7, CIS 5.x, FedRAMP, PCI-DSS §3.4, …)
4//! have historically been hand-coded Rust invariants scattered across
5//! repos. That makes them hard to review, hard to diff, and hard to
6//! audit. This module moves them into *data*: each control is a
7//! `Policy` composed of a [`Pattern`] that declares what to match and
8//! a [`Rule`] that declares what must hold where it matches.
9//!
10//! A minimal policy engine walks an IR's sexpr form, finds every
11//! subtree matching the pattern, and asserts the rule on each. The
12//! result is a `PolicyReport` — structured pass/fail findings with
13//! paths into the IR, ready for attestation.
14//!
15//! # Pattern language
16//!
17//! - `Pattern::Any` — matches anything
18//! - `Pattern::Symbol(s)` — matches only that symbol
19//! - `Pattern::String(s)` — matches only that string literal
20//! - `Pattern::Integer(i)` / `Bool(b)` / `Nil` — scalar equality
21//! - `Pattern::AnyString` / `AnySymbol` / `AnyInteger` — value classes
22//! - `Pattern::OneOf(vec)` — any of the literal string/symbol values
23//! - `Pattern::Struct { head, fields }` — matches a struct-form with
24//!   the given head symbol and the given `(field, pattern)` pairs.
25//!   Fields not named in the pattern are ignored (partial match).
26//! - `Pattern::ListHead { head, tail }` — matches a `(head …rest)` list
27//!   form where `tail` is applied element-wise (with `Pattern::Any`
28//!   tolerated as wildcard).
29//!
30//! # Rules
31//!
32//! - `Rule::Deny(reason)` — any match is a violation
33//! - `Rule::RequireField { field, pattern }` — the matched subtree must
34//!   contain a struct-form field whose value matches `pattern`
35//! - `Rule::ForbidField { field }` — the matched subtree must NOT
36//!   contain this field (or, if present, the field value must be
37//!   `Nil`)
38//!
39//! # Example
40//!
41//! ```
42//! use iac_forge::policy::{Pattern, Policy, Rule, evaluate};
43//! use iac_forge::sexpr::ToSExpr;
44//! use iac_forge::testing::{test_resource, TestAttributeBuilder};
45//! use iac_forge::ir::IacType;
46//!
47//! // Control: "every sensitive attribute must also be marked immutable"
48//! let policy = Policy {
49//!     id: "custom-1".into(),
50//!     description: "sensitive fields must be immutable".into(),
51//!     pattern: Pattern::Struct {
52//!         head: "attribute".into(),
53//!         fields: vec![("sensitive".into(), Pattern::Bool(true))],
54//!     },
55//!     rule: Rule::RequireField {
56//!         field: "immutable".into(),
57//!         pattern: Pattern::Bool(true),
58//!     },
59//! };
60//!
61//! let mut r = test_resource("x");
62//! r.attributes = vec![
63//!     TestAttributeBuilder::new("password", IacType::String)
64//!         .required().sensitive().build(),
65//! ];
66//! let report = evaluate(&[policy], &r.to_sexpr());
67//! assert!(report.has_violations());
68//! ```
69
70use crate::sexpr::SExpr;
71
72// ── Patterns ────────────────────────────────────────────────────────
73
74/// A declarative match-template over SExpr trees.
75#[derive(Debug, Clone, PartialEq)]
76pub enum Pattern {
77    /// Matches any subtree.
78    Any,
79    /// Matches the specific symbol.
80    Symbol(String),
81    /// Matches the specific string literal.
82    String(String),
83    /// Matches the specific integer.
84    Integer(i64),
85    /// Matches the specific bool.
86    Bool(bool),
87    /// Matches `nil`.
88    Nil,
89    /// Matches any symbol.
90    AnySymbol,
91    /// Matches any string literal.
92    AnyString,
93    /// Matches any integer.
94    AnyInteger,
95    /// Matches any one of the given string literals OR symbols.
96    OneOf(Vec<String>),
97    /// Matches a struct-form `(head (:field val) …)` where `head` must
98    /// equal `head` and every declared field must match (extra fields
99    /// on the target are ignored — partial match).
100    Struct {
101        head: String,
102        fields: Vec<(String, Pattern)>,
103    },
104    /// Matches a list-form `(head tail0 tail1 …)` where `head` equals
105    /// the expected head symbol and each tail pattern matches positionally.
106    ListHead { head: String, tail: Vec<Pattern> },
107}
108
109impl Pattern {
110    /// True iff this pattern matches the target.
111    #[must_use]
112    pub fn matches(&self, target: &SExpr) -> bool {
113        match (self, target) {
114            (Self::Any, _) => true,
115            (Self::Symbol(s), SExpr::Symbol(t)) => s == t,
116            (Self::String(s), SExpr::String(t)) => s == t,
117            (Self::Integer(i), SExpr::Integer(t)) => i == t,
118            (Self::Bool(b), SExpr::Bool(t)) => b == t,
119            (Self::Nil, SExpr::Nil) => true,
120            (Self::AnySymbol, SExpr::Symbol(_)) => true,
121            (Self::AnyString, SExpr::String(_)) => true,
122            (Self::AnyInteger, SExpr::Integer(_)) => true,
123            (Self::OneOf(opts), SExpr::Symbol(s) | SExpr::String(s)) => opts.iter().any(|o| o == s),
124            (Self::Struct { head, fields }, SExpr::List(items)) => {
125                let Some((target_head, tail)) = items.split_first() else {
126                    return false;
127                };
128                let Ok(name) = head_symbol(target_head) else {
129                    return false;
130                };
131                if name != head {
132                    return false;
133                }
134                let target_fields = parse_keyword_fields(tail);
135                fields
136                    .iter()
137                    .all(|(k, p)| match target_fields.get(k.as_str()) {
138                        Some(v) => p.matches(v),
139                        None => matches!(p, Pattern::Nil),
140                    })
141            }
142            (Self::ListHead { head, tail }, SExpr::List(items)) => {
143                let Some((target_head, rest)) = items.split_first() else {
144                    return false;
145                };
146                let Ok(name) = head_symbol(target_head) else {
147                    return false;
148                };
149                if name != head {
150                    return false;
151                }
152                if rest.len() != tail.len() {
153                    return false;
154                }
155                tail.iter()
156                    .zip(rest)
157                    .all(|(pat, target)| pat.matches(target))
158            }
159            _ => false,
160        }
161    }
162}
163
164fn head_symbol(s: &SExpr) -> Result<&str, ()> {
165    match s {
166        SExpr::Symbol(s) => Ok(s),
167        _ => Err(()),
168    }
169}
170
171fn parse_keyword_fields(items: &[SExpr]) -> std::collections::BTreeMap<&str, &SExpr> {
172    let mut out = std::collections::BTreeMap::new();
173    for item in items {
174        let SExpr::List(pair) = item else { continue };
175        if pair.len() != 2 {
176            continue;
177        }
178        let SExpr::Symbol(key) = &pair[0] else {
179            continue;
180        };
181        if let Some(rest) = key.strip_prefix(':') {
182            out.insert(rest, &pair[1]);
183        }
184    }
185    out
186}
187
188// ── Rules ───────────────────────────────────────────────────────────
189
190/// What must hold about a subtree once the pattern matches.
191#[derive(Debug, Clone, PartialEq)]
192pub enum Rule {
193    /// Any match is a violation. The string is the reason/diagnostic.
194    Deny(String),
195    /// The matched subtree must be a struct-form containing `field`
196    /// with a value matching `pattern`.
197    RequireField { field: String, pattern: Pattern },
198    /// The matched subtree must NOT contain `field`, or if it does, the
199    /// value must be `Nil`.
200    ForbidField { field: String },
201}
202
203// ── Policy + evaluation ────────────────────────────────────────────
204
205/// A single policy: pattern + rule + metadata.
206#[derive(Debug, Clone, PartialEq)]
207pub struct Policy {
208    /// Short machine identifier (e.g., "pci-3.4-sensitive").
209    pub id: String,
210    /// Human-readable description.
211    pub description: String,
212    /// What subtrees this policy applies to.
213    pub pattern: Pattern,
214    /// What must hold where the pattern matches.
215    pub rule: Rule,
216}
217
218/// A single policy finding — pass OR fail, with its path and reason.
219#[derive(Debug, Clone, PartialEq)]
220pub struct Finding {
221    pub policy_id: String,
222    pub path: String,
223    /// `true` = policy was satisfied at this site. `false` = violated.
224    pub satisfied: bool,
225    pub reason: String,
226}
227
228/// The full report from evaluating a set of policies.
229#[derive(Debug, Clone, Default, PartialEq)]
230pub struct PolicyReport {
231    pub findings: Vec<Finding>,
232}
233
234impl PolicyReport {
235    #[must_use]
236    pub fn has_violations(&self) -> bool {
237        self.findings.iter().any(|f| !f.satisfied)
238    }
239
240    #[must_use]
241    pub fn violations(&self) -> Vec<&Finding> {
242        self.findings.iter().filter(|f| !f.satisfied).collect()
243    }
244
245    #[must_use]
246    pub fn passes(&self) -> Vec<&Finding> {
247        self.findings.iter().filter(|f| f.satisfied).collect()
248    }
249
250    #[must_use]
251    pub fn total(&self) -> usize {
252        self.findings.len()
253    }
254}
255
256/// Evaluate a slice of policies against a target sexpr tree.
257#[must_use]
258pub fn evaluate(policies: &[Policy], target: &SExpr) -> PolicyReport {
259    let mut findings = Vec::new();
260    for policy in policies {
261        walk_and_eval(policy, target, "", &mut findings);
262    }
263    PolicyReport { findings }
264}
265
266fn walk_and_eval(policy: &Policy, target: &SExpr, path: &str, out: &mut Vec<Finding>) {
267    if policy.pattern.matches(target) {
268        eval_rule(policy, target, path, out);
269    }
270
271    // Recurse into children regardless — deep matches are expected.
272    if let SExpr::List(items) = target {
273        // Try to name children by keyword when this is a struct-form;
274        // otherwise by index.
275        let struct_fields = if let Some((head, tail)) = items.split_first() {
276            if matches!(head, SExpr::Symbol(s) if !s.starts_with(':')) {
277                // Could be a struct-form — attempt keyword labelling.
278                let head_name = match head {
279                    SExpr::Symbol(s) => s.clone(),
280                    _ => String::new(),
281                };
282                let labelled: Vec<(String, &SExpr)> = tail
283                    .iter()
284                    .enumerate()
285                    .map(|(i, child)| {
286                        if let SExpr::List(pair) = child {
287                            if pair.len() == 2 {
288                                if let SExpr::Symbol(k) = &pair[0] {
289                                    if let Some(name) = k.strip_prefix(':') {
290                                        return (name.to_string(), &pair[1]);
291                                    }
292                                }
293                            }
294                        }
295                        (format!("[{i}]"), child)
296                    })
297                    .collect();
298                Some((head_name, labelled))
299            } else {
300                None
301            }
302        } else {
303            None
304        };
305
306        if let Some((_, labelled)) = struct_fields {
307            for (name, child) in labelled {
308                let child_path = if path.is_empty() {
309                    name
310                } else {
311                    format!("{path}.{name}")
312                };
313                walk_and_eval(policy, child, &child_path, out);
314            }
315        } else {
316            for (i, child) in items.iter().enumerate() {
317                let child_path = if path.is_empty() {
318                    format!("[{i}]")
319                } else {
320                    format!("{path}[{i}]")
321                };
322                walk_and_eval(policy, child, &child_path, out);
323            }
324        }
325    }
326}
327
328fn eval_rule(policy: &Policy, target: &SExpr, path: &str, out: &mut Vec<Finding>) {
329    match &policy.rule {
330        Rule::Deny(reason) => out.push(Finding {
331            policy_id: policy.id.clone(),
332            path: path.to_string(),
333            satisfied: false,
334            reason: reason.clone(),
335        }),
336        Rule::RequireField { field, pattern } => {
337            let fields = match target {
338                SExpr::List(items) => {
339                    let tail = items.split_first().map_or(&items[..], |(_, t)| t);
340                    parse_keyword_fields(tail)
341                }
342                _ => std::collections::BTreeMap::new(),
343            };
344            let satisfied = match fields.get(field.as_str()) {
345                Some(v) => pattern.matches(v),
346                None => matches!(pattern, Pattern::Nil),
347            };
348            out.push(Finding {
349                policy_id: policy.id.clone(),
350                path: path.to_string(),
351                satisfied,
352                reason: if satisfied {
353                    format!("required field '{field}' present and matches")
354                } else {
355                    format!("required field '{field}' missing or did not match")
356                },
357            });
358        }
359        Rule::ForbidField { field } => {
360            let fields = match target {
361                SExpr::List(items) => {
362                    let tail = items.split_first().map_or(&items[..], |(_, t)| t);
363                    parse_keyword_fields(tail)
364                }
365                _ => std::collections::BTreeMap::new(),
366            };
367            let satisfied = match fields.get(field.as_str()) {
368                Some(SExpr::Nil) | None => true,
369                _ => false,
370            };
371            out.push(Finding {
372                policy_id: policy.id.clone(),
373                path: path.to_string(),
374                satisfied,
375                reason: if satisfied {
376                    format!("forbidden field '{field}' is absent or nil")
377                } else {
378                    format!("forbidden field '{field}' is present with non-nil value")
379                },
380            });
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::ir::IacType;
389    use crate::sexpr::ToSExpr;
390    use crate::testing::{TestAttributeBuilder, test_resource};
391
392    // ── Pattern primitives ──────────────────────────────────
393
394    #[test]
395    fn any_matches_everything() {
396        assert!(Pattern::Any.matches(&SExpr::Integer(42)));
397        assert!(Pattern::Any.matches(&SExpr::String("x".into())));
398        assert!(Pattern::Any.matches(&SExpr::Nil));
399    }
400
401    #[test]
402    fn symbol_exact_match() {
403        assert!(Pattern::Symbol("foo".into()).matches(&SExpr::Symbol("foo".into())));
404        assert!(!Pattern::Symbol("foo".into()).matches(&SExpr::Symbol("bar".into())));
405    }
406
407    #[test]
408    fn any_string_ignores_content() {
409        assert!(Pattern::AnyString.matches(&SExpr::String("x".into())));
410        assert!(!Pattern::AnyString.matches(&SExpr::Symbol("x".into())));
411    }
412
413    #[test]
414    fn one_of_matches_strings_and_symbols() {
415        let p = Pattern::OneOf(vec!["a".into(), "b".into()]);
416        assert!(p.matches(&SExpr::String("a".into())));
417        assert!(p.matches(&SExpr::Symbol("b".into())));
418        assert!(!p.matches(&SExpr::String("c".into())));
419    }
420
421    #[test]
422    fn bool_integer_nil() {
423        assert!(Pattern::Bool(true).matches(&SExpr::Bool(true)));
424        assert!(Pattern::Integer(42).matches(&SExpr::Integer(42)));
425        assert!(Pattern::Nil.matches(&SExpr::Nil));
426        assert!(!Pattern::Bool(true).matches(&SExpr::Bool(false)));
427    }
428
429    // ── Struct pattern ──────────────────────────────────────
430
431    #[test]
432    fn struct_pattern_matches_partial_fields() {
433        // (attribute (:sensitive true) (:immutable false)) should match
434        // a pattern that only checks :sensitive.
435        let target = SExpr::parse("(attribute (:sensitive true) (:immutable false))").unwrap();
436        let p = Pattern::Struct {
437            head: "attribute".into(),
438            fields: vec![("sensitive".into(), Pattern::Bool(true))],
439        };
440        assert!(p.matches(&target));
441    }
442
443    #[test]
444    fn struct_pattern_rejects_wrong_head() {
445        let target = SExpr::parse("(resource (:sensitive true))").unwrap();
446        let p = Pattern::Struct {
447            head: "attribute".into(),
448            fields: vec![("sensitive".into(), Pattern::Bool(true))],
449        };
450        assert!(!p.matches(&target));
451    }
452
453    #[test]
454    fn struct_pattern_field_value_mismatch() {
455        let target = SExpr::parse("(attribute (:sensitive false))").unwrap();
456        let p = Pattern::Struct {
457            head: "attribute".into(),
458            fields: vec![("sensitive".into(), Pattern::Bool(true))],
459        };
460        assert!(!p.matches(&target));
461    }
462
463    // ── ListHead pattern ────────────────────────────────────
464
465    #[test]
466    fn list_head_positional_match() {
467        let target = SExpr::parse("(list integer)").unwrap();
468        let p = Pattern::ListHead {
469            head: "list".into(),
470            tail: vec![Pattern::Symbol("integer".into())],
471        };
472        assert!(p.matches(&target));
473    }
474
475    #[test]
476    fn list_head_arity_mismatch() {
477        let target = SExpr::parse("(list integer string)").unwrap();
478        let p = Pattern::ListHead {
479            head: "list".into(),
480            tail: vec![Pattern::Symbol("integer".into())],
481        };
482        assert!(!p.matches(&target));
483    }
484
485    // ── Evaluation + rule plumbing ──────────────────────────
486
487    fn resource_with_sensitive_mutable() -> SExpr {
488        let mut r = test_resource("x");
489        r.attributes = vec![
490            TestAttributeBuilder::new("password", IacType::String)
491                .required()
492                .sensitive()
493                .build(),
494        ];
495        r.to_sexpr()
496    }
497
498    fn resource_with_sensitive_immutable() -> SExpr {
499        let mut r = test_resource("x");
500        r.attributes = vec![
501            TestAttributeBuilder::new("password", IacType::String)
502                .required()
503                .sensitive()
504                .immutable()
505                .build(),
506        ];
507        r.to_sexpr()
508    }
509
510    fn sensitive_must_be_immutable() -> Policy {
511        Policy {
512            id: "sensitive-immutable".into(),
513            description: "every sensitive attribute must be immutable".into(),
514            pattern: Pattern::Struct {
515                head: "attribute".into(),
516                fields: vec![("sensitive".into(), Pattern::Bool(true))],
517            },
518            rule: Rule::RequireField {
519                field: "immutable".into(),
520                pattern: Pattern::Bool(true),
521            },
522        }
523    }
524
525    #[test]
526    fn policy_fires_when_rule_violated() {
527        let target = resource_with_sensitive_mutable();
528        let report = evaluate(&[sensitive_must_be_immutable()], &target);
529        assert!(report.has_violations());
530        assert_eq!(report.violations().len(), 1);
531    }
532
533    #[test]
534    fn policy_passes_when_rule_satisfied() {
535        let target = resource_with_sensitive_immutable();
536        let report = evaluate(&[sensitive_must_be_immutable()], &target);
537        assert!(!report.has_violations());
538        assert_eq!(report.passes().len(), 1);
539    }
540
541    #[test]
542    fn no_match_yields_empty_report() {
543        // IacResource with NO sensitive attributes — pattern matches
544        // nothing, so no findings at all.
545        let mut r = test_resource("x");
546        r.attributes = vec![
547            TestAttributeBuilder::new("name", IacType::String)
548                .required()
549                .build(),
550        ];
551        let report = evaluate(&[sensitive_must_be_immutable()], &r.to_sexpr());
552        assert_eq!(report.total(), 0);
553    }
554
555    #[test]
556    fn deny_rule_produces_violation_on_every_match() {
557        let r = test_resource("x"); // 3 attributes by default
558        let policy = Policy {
559            id: "no-attributes".into(),
560            description: "forbid all attributes".into(),
561            pattern: Pattern::Struct {
562                head: "attribute".into(),
563                fields: vec![],
564            },
565            rule: Rule::Deny("no attributes allowed".into()),
566        };
567        let report = evaluate(&[policy], &r.to_sexpr());
568        // test_resource builds 3 attributes by default.
569        assert_eq!(report.violations().len(), 3);
570        assert!(
571            report
572                .violations()
573                .iter()
574                .all(|v| v.reason == "no attributes allowed")
575        );
576    }
577
578    #[test]
579    fn forbid_field_rule() {
580        let target = resource_with_sensitive_immutable();
581        let policy = Policy {
582            id: "no-sensitive".into(),
583            description: "no sensitive flag set".into(),
584            pattern: Pattern::Struct {
585                head: "attribute".into(),
586                fields: vec![],
587            },
588            rule: Rule::ForbidField {
589                field: "sensitive".into(),
590            },
591        };
592        let report = evaluate(&[policy], &target);
593        // The sensitive field IS present with value true, so rule fires.
594        // (Every matched attribute is evaluated — only those with
595        // :sensitive true actually violate.)
596        assert!(report.has_violations());
597    }
598
599    #[test]
600    fn multiple_policies_evaluated_independently() {
601        let target = resource_with_sensitive_mutable();
602        let p1 = sensitive_must_be_immutable();
603        let p2 = Policy {
604            id: "never-fires".into(),
605            description: "impossible pattern".into(),
606            pattern: Pattern::Symbol("nonexistent-head".into()),
607            rule: Rule::Deny("x".into()),
608        };
609        let report = evaluate(&[p1, p2], &target);
610        // p1 fires (violation), p2 never matches (no finding).
611        assert_eq!(report.findings.len(), 1);
612    }
613
614    #[test]
615    fn evaluation_is_deterministic() {
616        let target = resource_with_sensitive_mutable();
617        let a = evaluate(&[sensitive_must_be_immutable()], &target);
618        let b = evaluate(&[sensitive_must_be_immutable()], &target);
619        assert_eq!(a, b);
620    }
621
622    #[test]
623    fn finding_path_points_into_struct() {
624        let target = resource_with_sensitive_mutable();
625        let report = evaluate(&[sensitive_must_be_immutable()], &target);
626        let violation = &report.violations()[0];
627        // The matched attribute lives under resource → attributes → [0]
628        // (struct-form walk labels by keyword, list-form elements by
629        // index). We just assert the path is non-empty.
630        assert!(!violation.path.is_empty());
631        assert!(violation.path.contains("attributes"));
632    }
633
634    // ── Report API ─────────────────────────────────────────
635
636    #[test]
637    fn report_counts() {
638        let target = resource_with_sensitive_mutable();
639        let report = evaluate(&[sensitive_must_be_immutable()], &target);
640        assert_eq!(report.total(), 1);
641        assert_eq!(report.violations().len(), 1);
642        assert_eq!(report.passes().len(), 0);
643    }
644
645    #[test]
646    fn empty_report_when_no_policies() {
647        let target = resource_with_sensitive_mutable();
648        let report = evaluate(&[], &target);
649        assert_eq!(report.total(), 0);
650        assert!(!report.has_violations());
651    }
652}