Skip to main content

faucet_core/policy/
compile.rs

1//! Compiled form of a [`PolicySpec`]: regexes built once, labels resolvable
2//! by column name and by value.
3
4use super::spec::{PolicyRule, PolicySpec};
5use crate::masking::{Detector, detect};
6use regex::Regex;
7use std::collections::{BTreeSet, HashSet};
8
9/// One compiled classification.
10#[derive(Debug, Clone)]
11pub struct CompiledClassification {
12    pub label: String,
13    pub fields: HashSet<String>,
14    pub field_pattern: Option<Regex>,
15    pub value_detector: Option<Detector>,
16}
17
18/// A validated, compiled policy.
19#[derive(Debug, Clone)]
20pub struct CompiledPolicy {
21    classifications: Vec<CompiledClassification>,
22    rules: Vec<PolicyRule>,
23}
24
25impl CompiledPolicy {
26    /// Validate and compile. Every defect surfaces here, at config load.
27    pub fn compile(spec: &PolicySpec) -> Result<Self, String> {
28        spec.validate()?;
29        let classifications = spec
30            .classifications
31            .iter()
32            .map(|c| {
33                Ok(CompiledClassification {
34                    label: c.label.clone(),
35                    fields: c.fields.iter().cloned().collect(),
36                    field_pattern: match &c.field_pattern {
37                        Some(p) => Some(Regex::new(p).map_err(|e| e.to_string())?),
38                        None => None,
39                    },
40                    value_detector: c.value_detector,
41                })
42            })
43            .collect::<Result<Vec<_>, String>>()?;
44        Ok(Self {
45            classifications,
46            rules: spec.rules.clone(),
47        })
48    }
49
50    pub fn rules(&self) -> &[PolicyRule] {
51        &self.rules
52    }
53
54    pub fn classifications(&self) -> &[CompiledClassification] {
55        &self.classifications
56    }
57
58    /// Labels a column earns from its **name**: an exact `fields` entry
59    /// matches the full dot-path or its leaf key (`ssn` covers `user.ssn`),
60    /// `field_pattern` is tested against the full dot-path.
61    pub fn labels_for_name(&self, name: &str) -> BTreeSet<String> {
62        let leaf = name.rsplit('.').next().unwrap_or(name);
63        self.classifications
64            .iter()
65            .filter(|c| {
66                c.fields.contains(name)
67                    || c.fields.contains(leaf)
68                    || c.field_pattern.as_ref().is_some_and(|re| re.is_match(name))
69            })
70            .map(|c| c.label.clone())
71            .collect()
72    }
73
74    /// Labels a scalar string **value** earns from the value detectors.
75    pub fn labels_for_value(&self, value: &str) -> BTreeSet<String> {
76        self.classifications
77            .iter()
78            .filter(|c| c.value_detector.is_some_and(|d| detect::detects(d, value)))
79            .map(|c| c.label.clone())
80            .collect()
81    }
82
83    /// The value-detector classifications (the runtime backstop's work list).
84    pub fn value_detectors(&self) -> impl Iterator<Item = (&str, Detector)> {
85        self.classifications
86            .iter()
87            .filter_map(|c| c.value_detector.map(|d| (c.label.as_str(), d)))
88    }
89
90    /// Whether any rule governs `label` at all.
91    pub fn governs(&self, label: &str) -> bool {
92        self.rules.iter().any(|r| r.when.label == label)
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use serde_json::json;
100
101    fn policy() -> CompiledPolicy {
102        let spec: PolicySpec = serde_json::from_value(json!({
103            "classifications": [
104                {"label": "pii", "fields": ["email", "user.phone"], "value_detector": "email"},
105                {"label": "pii", "field_pattern": "(?i)ssn$", "value_detector": "ssn"},
106                {"label": "finance", "field_pattern": "^amount"}
107            ],
108            "rules": [
109                {"name": "r", "when": {"label": "pii"}, "deny": true}
110            ]
111        }))
112        .unwrap();
113        CompiledPolicy::compile(&spec).unwrap()
114    }
115
116    #[test]
117    fn labels_by_name_and_pattern() {
118        let p = policy();
119        assert_eq!(p.labels_for_name("email"), ["pii".to_string()].into());
120        assert_eq!(p.labels_for_name("user.phone"), ["pii".to_string()].into());
121        assert_eq!(
122            p.labels_for_name("contact.email"),
123            ["pii".to_string()].into(),
124            "a bare field name covers the leaf key"
125        );
126        assert_eq!(
127            p.labels_for_name("customer_SSN"),
128            ["pii".to_string()].into()
129        );
130        assert_eq!(
131            p.labels_for_name("amount_cents"),
132            ["finance".to_string()].into()
133        );
134        assert!(p.labels_for_name("id").is_empty());
135    }
136
137    #[test]
138    fn labels_by_value_use_the_masking_detectors() {
139        let p = policy();
140        assert_eq!(p.labels_for_value("a@b.io"), ["pii".to_string()].into());
141        assert_eq!(
142            p.labels_for_value("123-45-6789"),
143            ["pii".to_string()].into()
144        );
145        assert!(p.labels_for_value("hello").is_empty());
146        assert_eq!(p.value_detectors().count(), 2);
147        assert!(p.governs("pii") && !p.governs("finance"));
148        assert_eq!(p.rules().len(), 1);
149        assert_eq!(p.classifications().len(), 3);
150    }
151
152    #[test]
153    fn compile_rejects_an_invalid_spec() {
154        let spec: PolicySpec = serde_json::from_value(json!({
155            "classifications": [{"label": "x", "field_pattern": "("}],
156            "rules": []
157        }))
158        .unwrap();
159        assert!(CompiledPolicy::compile(&spec).is_err());
160    }
161}