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
25fn uses_narrowed_phone(spec: &PolicySpec) -> bool {
26    spec.classifications
27        .iter()
28        .any(|c| c.value_detector == Some(Detector::Phone))
29}
30
31impl CompiledPolicy {
32    /// Validate and compile. Every defect surfaces here, at config load.
33    pub fn compile(spec: &PolicySpec) -> Result<Self, String> {
34        spec.validate()?;
35        if uses_narrowed_phone(spec) {
36            crate::masking::compile::warn_phone_narrowed();
37        }
38        let classifications = spec
39            .classifications
40            .iter()
41            .map(|c| {
42                Ok(CompiledClassification {
43                    label: c.label.clone(),
44                    fields: c.fields.iter().cloned().collect(),
45                    field_pattern: match &c.field_pattern {
46                        Some(p) => Some(Regex::new(p).map_err(|e| e.to_string())?),
47                        None => None,
48                    },
49                    value_detector: c.value_detector,
50                })
51            })
52            .collect::<Result<Vec<_>, String>>()?;
53        Ok(Self {
54            classifications,
55            rules: spec.rules.clone(),
56        })
57    }
58
59    pub fn rules(&self) -> &[PolicyRule] {
60        &self.rules
61    }
62
63    /// Whether any rule quarantines at run time (`on_runtime: quarantine`).
64    pub fn quarantines(&self) -> bool {
65        self.rules
66            .iter()
67            .any(|r| r.on_runtime == super::spec::RuntimeAction::Quarantine)
68    }
69
70    pub fn classifications(&self) -> &[CompiledClassification] {
71        &self.classifications
72    }
73
74    /// Labels a column earns from its **name**: an exact `fields` entry
75    /// matches the full dot-path or its leaf key (`ssn` covers `user.ssn`),
76    /// `field_pattern` is tested against the full dot-path.
77    pub fn labels_for_name(&self, name: &str) -> BTreeSet<String> {
78        let leaf = name.rsplit('.').next().unwrap_or(name);
79        self.classifications
80            .iter()
81            .filter(|c| {
82                c.fields.contains(name)
83                    || c.fields.contains(leaf)
84                    || c.field_pattern.as_ref().is_some_and(|re| re.is_match(name))
85            })
86            .map(|c| c.label.clone())
87            .collect()
88    }
89
90    /// Labels a scalar string **value** earns from the value detectors.
91    pub fn labels_for_value(&self, value: &str) -> BTreeSet<String> {
92        self.classifications
93            .iter()
94            .filter(|c| c.value_detector.is_some_and(|d| detect::detects(d, value)))
95            .map(|c| c.label.clone())
96            .collect()
97    }
98
99    /// [`labels_for_value`](Self::labels_for_value) over any JSON scalar: a
100    /// number is classified by the card detector too, as masking does.
101    pub fn labels_for_scalar(&self, value: &serde_json::Value) -> BTreeSet<String> {
102        self.classifications
103            .iter()
104            .filter(|c| {
105                c.value_detector
106                    .is_some_and(|d| detect::detects_value(d, value))
107            })
108            .map(|c| c.label.clone())
109            .collect()
110    }
111
112    /// The value-detector classifications (the runtime backstop's work list).
113    pub fn value_detectors(&self) -> impl Iterator<Item = (&str, Detector)> {
114        self.classifications
115            .iter()
116            .filter_map(|c| c.value_detector.map(|d| (c.label.as_str(), d)))
117    }
118
119    /// Whether any rule governs `label` at all.
120    pub fn governs(&self, label: &str) -> bool {
121        self.rules.iter().any(|r| r.when.label == label)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use serde_json::json;
129
130    fn policy() -> CompiledPolicy {
131        let spec: PolicySpec = serde_json::from_value(json!({
132            "classifications": [
133                {"label": "pii", "fields": ["email", "user.phone"], "value_detector": "email"},
134                {"label": "pii", "field_pattern": "(?i)ssn$", "value_detector": "ssn"},
135                {"label": "finance", "field_pattern": "^amount"}
136            ],
137            "rules": [
138                {"name": "r", "when": {"label": "pii"}, "deny": true}
139            ]
140        }))
141        .unwrap();
142        CompiledPolicy::compile(&spec).unwrap()
143    }
144
145    #[test]
146    fn labels_by_name_and_pattern() {
147        let p = policy();
148        assert_eq!(p.labels_for_name("email"), ["pii".to_string()].into());
149        assert_eq!(p.labels_for_name("user.phone"), ["pii".to_string()].into());
150        assert_eq!(
151            p.labels_for_name("contact.email"),
152            ["pii".to_string()].into(),
153            "a bare field name covers the leaf key"
154        );
155        assert_eq!(
156            p.labels_for_name("customer_SSN"),
157            ["pii".to_string()].into()
158        );
159        assert_eq!(
160            p.labels_for_name("amount_cents"),
161            ["finance".to_string()].into()
162        );
163        assert!(p.labels_for_name("id").is_empty());
164    }
165
166    #[test]
167    fn labels_by_value_use_the_masking_detectors() {
168        let p = policy();
169        assert_eq!(p.labels_for_value("a@b.io"), ["pii".to_string()].into());
170        assert_eq!(
171            p.labels_for_value("123-45-6789"),
172            ["pii".to_string()].into()
173        );
174        assert!(p.labels_for_value("hello").is_empty());
175        assert_eq!(p.value_detectors().count(), 2);
176        assert!(p.governs("pii") && !p.governs("finance"));
177        assert_eq!(p.rules().len(), 1);
178        assert_eq!(p.classifications().len(), 3);
179    }
180
181    #[test]
182    fn a_phone_classification_triggers_the_narrowed_matcher_warning() {
183        let spec = |d: &str| -> PolicySpec {
184            serde_json::from_value(serde_json::json!({
185                "classifications": [{"label": "pii", "value_detector": d}],
186                "rules": [{"name": "r", "when": {"label": "pii"}, "deny": true}]
187            }))
188            .unwrap()
189        };
190        assert!(uses_narrowed_phone(&spec("phone")));
191        assert!(!uses_narrowed_phone(&spec("phone_loose")));
192        assert!(CompiledPolicy::compile(&spec("phone")).is_ok());
193    }
194
195    #[test]
196    fn compile_rejects_an_invalid_spec() {
197        let spec: PolicySpec = serde_json::from_value(json!({
198            "classifications": [{"label": "x", "field_pattern": "("}],
199            "rules": []
200        }))
201        .unwrap();
202        assert!(CompiledPolicy::compile(&spec).is_err());
203    }
204}