1use crate::masking::{Detector, MaskAction};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9pub const MASK_ACTIONS: [&str; 4] = ["redact", "hash", "tokenize", "partial"];
11
12#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
18#[serde(deny_unknown_fields)]
19pub struct PolicySpec {
20 #[serde(default = "default_version")]
22 pub version: u32,
23
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub description: Option<String>,
27
28 #[serde(default)]
30 pub classifications: Vec<Classification>,
31
32 #[serde(default)]
34 pub rules: Vec<PolicyRule>,
35}
36
37fn default_version() -> u32 {
38 1
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
45#[serde(deny_unknown_fields)]
46pub struct Classification {
47 pub label: String,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub description: Option<String>,
52
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub fields: Vec<String>,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub field_pattern: Option<String>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub value_detector: Option<Detector>,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
70#[serde(deny_unknown_fields)]
71pub struct PolicyRule {
72 pub name: String,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub description: Option<String>,
77
78 pub when: RuleWhen,
80
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
85 pub require: BTreeMap<String, Vec<String>>,
86
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub mask: Vec<String>,
92
93 #[serde(default)]
96 pub deny: bool,
97
98 #[serde(default)]
102 pub on_runtime: RuntimeAction,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
107#[serde(deny_unknown_fields)]
108pub struct RuleWhen {
109 pub label: String,
111
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub sink_kind: Vec<String>,
115
116 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
119 pub sink: BTreeMap<String, Vec<String>>,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
124#[serde(rename_all = "snake_case")]
125pub enum RuntimeAction {
126 #[default]
128 Fail,
129 Quarantine,
131}
132
133impl RuntimeAction {
134 pub fn as_str(self) -> &'static str {
135 match self {
136 RuntimeAction::Fail => "fail",
137 RuntimeAction::Quarantine => "quarantine",
138 }
139 }
140}
141
142impl PolicySpec {
143 pub fn validate(&self) -> Result<(), String> {
146 if self.version != 1 {
147 return Err(format!("unsupported policy version {}", self.version));
148 }
149 let mut labels = std::collections::BTreeSet::new();
150 for (i, c) in self.classifications.iter().enumerate() {
151 if c.label.trim().is_empty() {
152 return Err(format!("classifications[{i}]: label is empty"));
153 }
154 if c.fields.is_empty() && c.field_pattern.is_none() && c.value_detector.is_none() {
155 return Err(format!(
156 "classification '{}': set fields, field_pattern, or value_detector",
157 c.label
158 ));
159 }
160 if let Some(p) = &c.field_pattern {
161 regex::Regex::new(p).map_err(|e| {
162 format!("classification '{}': invalid field_pattern: {e}", c.label)
163 })?;
164 }
165 if let Some(f) = c.fields.iter().find(|f| f.trim().is_empty()) {
166 return Err(format!(
167 "classification '{}': fields contains an empty name {f:?}",
168 c.label
169 ));
170 }
171 labels.insert(c.label.as_str());
172 }
173 let mut names = std::collections::BTreeSet::new();
174 for (i, r) in self.rules.iter().enumerate() {
175 if r.name.trim().is_empty() {
176 return Err(format!("rules[{i}]: name is empty"));
177 }
178 if !names.insert(r.name.as_str()) {
179 return Err(format!("rule '{}' is declared twice", r.name));
180 }
181 if !labels.contains(r.when.label.as_str()) {
182 return Err(format!(
183 "rule '{}': label '{}' has no classification",
184 r.name, r.when.label
185 ));
186 }
187 if !r.deny && r.require.is_empty() && r.mask.is_empty() {
188 return Err(format!(
189 "rule '{}': set `deny: true`, at least one `require` attribute, or a `mask` \
190 list (the column may reach the sink only masked)",
191 r.name
192 ));
193 }
194 for (attr, allowed) in &r.require {
195 if attr.trim().is_empty() {
196 return Err(format!("rule '{}': require has an empty attribute", r.name));
197 }
198 if allowed.is_empty() {
199 return Err(format!(
200 "rule '{}': require.{attr} lists no allowed values",
201 r.name
202 ));
203 }
204 }
205 for (attr, allowed) in &r.when.sink {
206 if attr.trim().is_empty() || allowed.is_empty() {
207 return Err(format!(
208 "rule '{}': when.sink.{attr} needs an attribute and at least one value",
209 r.name
210 ));
211 }
212 }
213 for m in &r.mask {
214 if !MASK_ACTIONS.contains(&m.as_str()) {
215 return Err(format!(
216 "rule '{}': mask action {m:?} is not one of {}",
217 r.name,
218 MASK_ACTIONS.join(", ")
219 ));
220 }
221 }
222 }
223 Ok(())
224 }
225
226 pub fn merge(mut self, other: PolicySpec) -> Result<PolicySpec, String> {
230 for r in &other.rules {
231 if self.rules.iter().any(|m| m.name == r.name) {
232 return Err(format!(
233 "rule '{}' is declared by two policy sources",
234 r.name
235 ));
236 }
237 }
238 self.classifications.extend(other.classifications);
239 self.rules.extend(other.rules);
240 if self.description.is_none() {
241 self.description = other.description;
242 }
243 Ok(self)
244 }
245
246 pub fn has_value_detectors(&self) -> bool {
249 self.classifications
250 .iter()
251 .any(|c| c.value_detector.is_some())
252 }
253}
254
255pub fn mask_action_label(action: &MaskAction) -> &'static str {
257 action.label()
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use serde_json::json;
264
265 fn spec(v: serde_json::Value) -> PolicySpec {
266 serde_json::from_value(v).unwrap()
267 }
268
269 fn valid() -> PolicySpec {
270 spec(json!({
271 "classifications": [
272 {"label": "pii", "fields": ["email"], "value_detector": "email"},
273 {"label": "finance", "field_pattern": "^(amount|iban)$"}
274 ],
275 "rules": [
276 {"name": "pii-eu", "when": {"label": "pii"}, "require": {"residency": ["eu"]}, "mask": ["hash", "redact"]},
277 {"name": "no-finance-to-files", "when": {"label": "finance", "sink_kind": ["jsonl"]}, "deny": true, "on_runtime": "quarantine"}
278 ]
279 }))
280 }
281
282 #[test]
283 fn a_valid_policy_parses_with_defaults() {
284 let p = valid();
285 assert!(p.validate().is_ok());
286 assert_eq!(p.version, 1);
287 assert!(p.has_value_detectors());
288 assert_eq!(p.rules[0].on_runtime, RuntimeAction::Fail);
289 assert_eq!(p.rules[1].on_runtime, RuntimeAction::Quarantine);
290 assert_eq!(RuntimeAction::Quarantine.as_str(), "quarantine");
291 assert_eq!(mask_action_label(&MaskAction::Hash), "hash");
292 }
293
294 #[test]
295 fn validation_names_each_defect() {
296 let bad = |f: fn(&mut PolicySpec)| {
297 let mut p = valid();
298 f(&mut p);
299 p.validate().unwrap_err()
300 };
301 assert!(bad(|p| p.version = 2).contains("version"));
302 assert!(bad(|p| p.classifications[0].label = " ".into()).contains("label is empty"));
303 assert!(
304 bad(|p| p.classifications[1] = Classification {
305 label: "x".into(),
306 ..Default::default()
307 })
308 .contains("set fields")
309 );
310 assert!(
311 bad(|p| p.classifications[1].field_pattern = Some("(".into()))
312 .contains("invalid field_pattern")
313 );
314 assert!(bad(|p| p.classifications[0].fields = vec!["".into()]).contains("empty name"));
315 assert!(bad(|p| p.rules[0].name = "".into()).contains("name is empty"));
316 assert!(bad(|p| p.rules[1].name = "pii-eu".into()).contains("declared twice"));
317 assert!(bad(|p| p.rules[0].when.label = "nope".into()).contains("no classification"));
318 assert!(
319 bad(|p| {
320 p.rules[0].require.clear();
321 p.rules[0].mask.clear();
322 })
323 .contains("deny: true")
324 );
325 assert!(
326 bad(|p| {
327 p.rules[0].require.insert("".into(), vec!["x".into()]);
328 })
329 .contains("empty attribute")
330 );
331 assert!(
332 bad(|p| {
333 p.rules[0].require.insert("region".into(), vec![]);
334 })
335 .contains("no allowed values")
336 );
337 assert!(
338 bad(|p| {
339 p.rules[0].when.sink.insert("env".into(), vec![]);
340 })
341 .contains("when.sink")
342 );
343 assert!(bad(|p| p.rules[0].mask = vec!["encrypt".into()]).contains("not one of"));
344 }
345
346 #[test]
347 fn merge_appends_and_refuses_duplicate_rule_names() {
348 let a = valid();
349 let b = spec(json!({
350 "description": "team policy",
351 "classifications": [{"label": "health", "fields": ["diagnosis"]}],
352 "rules": [{"name": "health-masked", "when": {"label": "health"}, "deny": true, "mask": ["redact"]}]
353 }));
354 let merged = a.clone().merge(b.clone()).unwrap();
355 assert_eq!(merged.classifications.len(), 3);
356 assert_eq!(merged.rules.len(), 3);
357 assert_eq!(merged.description.as_deref(), Some("team policy"));
358 assert!(merged.validate().is_ok());
359 let err = a.merge(valid()).unwrap_err();
360 assert!(err.contains("two policy sources"), "{err}");
361 }
362
363 #[test]
364 fn unknown_keys_are_rejected() {
365 assert!(serde_json::from_value::<PolicySpec>(json!({"rulez": []})).is_err());
366 assert!(
367 serde_json::from_value::<PolicyRule>(
368 json!({"name": "r", "when": {"label": "x"}, "bogus": 1})
369 )
370 .is_err()
371 );
372 }
373}