faucet_core/policy/
compile.rs1use super::spec::{PolicyRule, PolicySpec};
5use crate::masking::{Detector, detect};
6use regex::Regex;
7use std::collections::{BTreeSet, HashSet};
8
9#[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#[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 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 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 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 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 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 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 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}