use crate::masking::{Detector, MaskAction};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const MASK_ACTIONS: [&str; 4] = ["redact", "hash", "tokenize", "partial"];
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PolicySpec {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub classifications: Vec<Classification>,
#[serde(default)]
pub rules: Vec<PolicyRule>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Classification {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_detector: Option<Detector>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PolicyRule {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub when: RuleWhen,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub require: BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mask: Vec<String>,
#[serde(default)]
pub deny: bool,
#[serde(default)]
pub on_runtime: RuntimeAction,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuleWhen {
pub label: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sink_kind: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub sink: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeAction {
#[default]
Fail,
Quarantine,
}
impl RuntimeAction {
pub fn as_str(self) -> &'static str {
match self {
RuntimeAction::Fail => "fail",
RuntimeAction::Quarantine => "quarantine",
}
}
}
impl PolicySpec {
pub fn validate(&self) -> Result<(), String> {
if self.version != 1 {
return Err(format!("unsupported policy version {}", self.version));
}
let mut labels = std::collections::BTreeSet::new();
for (i, c) in self.classifications.iter().enumerate() {
if c.label.trim().is_empty() {
return Err(format!("classifications[{i}]: label is empty"));
}
if c.fields.is_empty() && c.field_pattern.is_none() && c.value_detector.is_none() {
return Err(format!(
"classification '{}': set fields, field_pattern, or value_detector",
c.label
));
}
if let Some(p) = &c.field_pattern {
regex::Regex::new(p).map_err(|e| {
format!("classification '{}': invalid field_pattern: {e}", c.label)
})?;
}
if let Some(f) = c.fields.iter().find(|f| f.trim().is_empty()) {
return Err(format!(
"classification '{}': fields contains an empty name {f:?}",
c.label
));
}
labels.insert(c.label.as_str());
}
let mut names = std::collections::BTreeSet::new();
for (i, r) in self.rules.iter().enumerate() {
if r.name.trim().is_empty() {
return Err(format!("rules[{i}]: name is empty"));
}
if !names.insert(r.name.as_str()) {
return Err(format!("rule '{}' is declared twice", r.name));
}
if !labels.contains(r.when.label.as_str()) {
return Err(format!(
"rule '{}': label '{}' has no classification",
r.name, r.when.label
));
}
if !r.deny && r.require.is_empty() && r.mask.is_empty() {
return Err(format!(
"rule '{}': set `deny: true`, at least one `require` attribute, or a `mask` \
list (the column may reach the sink only masked)",
r.name
));
}
for (attr, allowed) in &r.require {
if attr.trim().is_empty() {
return Err(format!("rule '{}': require has an empty attribute", r.name));
}
if allowed.is_empty() {
return Err(format!(
"rule '{}': require.{attr} lists no allowed values",
r.name
));
}
}
for (attr, allowed) in &r.when.sink {
if attr.trim().is_empty() || allowed.is_empty() {
return Err(format!(
"rule '{}': when.sink.{attr} needs an attribute and at least one value",
r.name
));
}
}
for m in &r.mask {
if !MASK_ACTIONS.contains(&m.as_str()) {
return Err(format!(
"rule '{}': mask action {m:?} is not one of {}",
r.name,
MASK_ACTIONS.join(", ")
));
}
}
}
Ok(())
}
pub fn merge(mut self, other: PolicySpec) -> Result<PolicySpec, String> {
for r in &other.rules {
if self.rules.iter().any(|m| m.name == r.name) {
return Err(format!(
"rule '{}' is declared by two policy sources",
r.name
));
}
}
self.classifications.extend(other.classifications);
self.rules.extend(other.rules);
if self.description.is_none() {
self.description = other.description;
}
Ok(self)
}
pub fn has_value_detectors(&self) -> bool {
self.classifications
.iter()
.any(|c| c.value_detector.is_some())
}
}
pub fn mask_action_label(action: &MaskAction) -> &'static str {
action.label()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn spec(v: serde_json::Value) -> PolicySpec {
serde_json::from_value(v).unwrap()
}
fn valid() -> PolicySpec {
spec(json!({
"classifications": [
{"label": "pii", "fields": ["email"], "value_detector": "email"},
{"label": "finance", "field_pattern": "^(amount|iban)$"}
],
"rules": [
{"name": "pii-eu", "when": {"label": "pii"}, "require": {"residency": ["eu"]}, "mask": ["hash", "redact"]},
{"name": "no-finance-to-files", "when": {"label": "finance", "sink_kind": ["jsonl"]}, "deny": true, "on_runtime": "quarantine"}
]
}))
}
#[test]
fn a_valid_policy_parses_with_defaults() {
let p = valid();
assert!(p.validate().is_ok());
assert_eq!(p.version, 1);
assert!(p.has_value_detectors());
assert_eq!(p.rules[0].on_runtime, RuntimeAction::Fail);
assert_eq!(p.rules[1].on_runtime, RuntimeAction::Quarantine);
assert_eq!(RuntimeAction::Quarantine.as_str(), "quarantine");
assert_eq!(mask_action_label(&MaskAction::Hash), "hash");
}
#[test]
fn validation_names_each_defect() {
let bad = |f: fn(&mut PolicySpec)| {
let mut p = valid();
f(&mut p);
p.validate().unwrap_err()
};
assert!(bad(|p| p.version = 2).contains("version"));
assert!(bad(|p| p.classifications[0].label = " ".into()).contains("label is empty"));
assert!(
bad(|p| p.classifications[1] = Classification {
label: "x".into(),
..Default::default()
})
.contains("set fields")
);
assert!(
bad(|p| p.classifications[1].field_pattern = Some("(".into()))
.contains("invalid field_pattern")
);
assert!(bad(|p| p.classifications[0].fields = vec!["".into()]).contains("empty name"));
assert!(bad(|p| p.rules[0].name = "".into()).contains("name is empty"));
assert!(bad(|p| p.rules[1].name = "pii-eu".into()).contains("declared twice"));
assert!(bad(|p| p.rules[0].when.label = "nope".into()).contains("no classification"));
assert!(
bad(|p| {
p.rules[0].require.clear();
p.rules[0].mask.clear();
})
.contains("deny: true")
);
assert!(
bad(|p| {
p.rules[0].require.insert("".into(), vec!["x".into()]);
})
.contains("empty attribute")
);
assert!(
bad(|p| {
p.rules[0].require.insert("region".into(), vec![]);
})
.contains("no allowed values")
);
assert!(
bad(|p| {
p.rules[0].when.sink.insert("env".into(), vec![]);
})
.contains("when.sink")
);
assert!(bad(|p| p.rules[0].mask = vec!["encrypt".into()]).contains("not one of"));
}
#[test]
fn merge_appends_and_refuses_duplicate_rule_names() {
let a = valid();
let b = spec(json!({
"description": "team policy",
"classifications": [{"label": "health", "fields": ["diagnosis"]}],
"rules": [{"name": "health-masked", "when": {"label": "health"}, "deny": true, "mask": ["redact"]}]
}));
let merged = a.clone().merge(b.clone()).unwrap();
assert_eq!(merged.classifications.len(), 3);
assert_eq!(merged.rules.len(), 3);
assert_eq!(merged.description.as_deref(), Some("team policy"));
assert!(merged.validate().is_ok());
let err = a.merge(valid()).unwrap_err();
assert!(err.contains("two policy sources"), "{err}");
}
#[test]
fn unknown_keys_are_rejected() {
assert!(serde_json::from_value::<PolicySpec>(json!({"rulez": []})).is_err());
assert!(
serde_json::from_value::<PolicyRule>(
json!({"name": "r", "when": {"label": "x"}, "bogus": 1})
)
.is_err()
);
}
}