use super::compile::CompiledPolicy;
use super::spec::PolicyRule;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SinkFacts {
pub id: String,
pub kind: String,
pub attributes: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ColumnFacts {
pub name: String,
pub labels: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub masked: Option<String>,
#[serde(default)]
pub conservative: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ViolationKind {
Denied,
MissingAttribute { attribute: String },
AttributeNotAllowed {
attribute: String,
value: String,
allowed: Vec<String>,
},
Unmasked { allowed: Vec<String> },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Violation {
pub rule: String,
pub label: String,
pub column: String,
pub sink: String,
pub sink_kind: String,
#[serde(flatten)]
pub kind: ViolationKind,
#[serde(default)]
pub conservative: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub satisfied_by_mask: Vec<String>,
}
impl fmt::Display for Violation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let what = match &self.kind {
ViolationKind::Denied => format!(
"label `{}` may not reach sink `{}` ({})",
self.label, self.sink, self.sink_kind
),
ViolationKind::MissingAttribute { attribute } => format!(
"label `{}` requires sink attribute `{attribute}`, which sink `{}` ({}) does not declare",
self.label, self.sink, self.sink_kind
),
ViolationKind::AttributeNotAllowed {
attribute,
value,
allowed,
} => format!(
"label `{}` requires sink `{attribute}` in [{}]; sink `{}` ({}) has `{value}`",
self.label,
allowed.join(", "),
self.sink,
self.sink_kind
),
ViolationKind::Unmasked { allowed } => format!(
"label `{}` may reach sink `{}` ({}) only masked with {}",
self.label,
self.sink,
self.sink_kind,
allowed.join(" or ")
),
};
write!(
f,
"rule `{}`: column `{}`{} — {what}",
self.rule,
self.column,
if self.conservative {
" (may be present: opaque transform)"
} else {
""
}
)?;
if !self.satisfied_by_mask.is_empty() {
write!(
f,
"; masking it with {} would satisfy the rule",
self.satisfied_by_mask.join(" or ")
)?;
}
Ok(())
}
}
pub fn rule_applies(rule: &PolicyRule, label: &str, sink: &SinkFacts) -> bool {
if rule.when.label != label {
return false;
}
if !rule.when.sink_kind.is_empty() && !rule.when.sink_kind.iter().any(|k| k == &sink.kind) {
return false;
}
rule.when.sink.iter().all(|(attr, allowed)| {
sink.attributes
.get(attr)
.is_some_and(|v| allowed.iter().any(|a| a == v))
})
}
pub fn evaluate(
policy: &CompiledPolicy,
sink: &SinkFacts,
columns: &[ColumnFacts],
) -> Vec<Violation> {
let mut out = Vec::new();
for rule in policy.rules() {
for col in columns {
if !col.labels.iter().any(|l| rule_applies(rule, l, sink)) {
continue;
}
if let Some(action) = &col.masked
&& rule.mask.iter().any(|m| m == action)
{
continue;
}
let base = |kind: ViolationKind| Violation {
rule: rule.name.clone(),
label: rule.when.label.clone(),
column: col.name.clone(),
sink: sink.id.clone(),
sink_kind: sink.kind.clone(),
kind,
conservative: col.conservative,
satisfied_by_mask: rule.mask.clone(),
};
if rule.deny {
out.push(base(ViolationKind::Denied));
continue;
}
if rule.require.is_empty() {
out.push(base(ViolationKind::Unmasked {
allowed: rule.mask.clone(),
}));
continue;
}
for (attr, allowed) in &rule.require {
match sink.attributes.get(attr) {
None => out.push(base(ViolationKind::MissingAttribute {
attribute: attr.clone(),
})),
Some(v) if !allowed.iter().any(|a| a == v) => {
out.push(base(ViolationKind::AttributeNotAllowed {
attribute: attr.clone(),
value: v.clone(),
allowed: allowed.clone(),
}))
}
Some(_) => {}
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::PolicySpec;
use serde_json::json;
fn policy() -> CompiledPolicy {
let spec: PolicySpec = serde_json::from_value(json!({
"classifications": [
{"label": "pii", "fields": ["email"]},
{"label": "finance", "fields": ["iban"]}
],
"rules": [
{"name": "pii-eu", "when": {"label": "pii"}, "require": {"residency": ["eu"]}, "mask": ["hash", "redact"]},
{"name": "no-finance-prod-files", "when": {"label": "finance", "sink_kind": ["jsonl"], "sink": {"environment": ["prod"]}}, "deny": true}
]
}))
.unwrap();
CompiledPolicy::compile(&spec).unwrap()
}
fn sink(kind: &str, attrs: &[(&str, &str)]) -> SinkFacts {
SinkFacts {
id: "default".into(),
kind: kind.into(),
attributes: attrs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
fn col(name: &str, labels: &[&str], masked: Option<&str>) -> ColumnFacts {
ColumnFacts {
name: name.into(),
labels: labels.iter().map(|s| s.to_string()).collect(),
masked: masked.map(String::from),
conservative: false,
via: None,
}
}
#[test]
fn require_checks_presence_and_value_and_masking_satisfies() {
let p = policy();
let cols = [col("email", &["pii"], None), col("id", &[], None)];
let v = evaluate(&p, &sink("postgres", &[("residency", "us")]), &cols);
assert_eq!(v.len(), 1);
assert!(
matches!(&v[0].kind, ViolationKind::AttributeNotAllowed { value, .. } if value == "us")
);
assert_eq!(v[0].satisfied_by_mask, vec!["hash", "redact"]);
let text = v[0].to_string();
assert!(
text.contains("rule `pii-eu`") && text.contains("masking it with hash or redact"),
"{text}"
);
let v = evaluate(&p, &sink("postgres", &[]), &cols);
assert!(
matches!(&v[0].kind, ViolationKind::MissingAttribute { attribute } if attribute == "residency")
);
assert!(v[0].to_string().contains("does not declare"));
assert!(evaluate(&p, &sink("postgres", &[("residency", "eu")]), &cols).is_empty());
let masked = [col("email", &["pii"], Some("hash"))];
assert!(evaluate(&p, &sink("postgres", &[("residency", "us")]), &masked).is_empty());
let partial = [col("email", &["pii"], Some("partial"))];
assert_eq!(
evaluate(&p, &sink("postgres", &[("residency", "us")]), &partial).len(),
1
);
}
#[test]
fn deny_rules_scope_by_sink_kind_and_attributes() {
let p = policy();
let cols = [col("iban", &["finance"], None)];
let hit = evaluate(&p, &sink("jsonl", &[("environment", "prod")]), &cols);
assert_eq!(hit.len(), 1);
assert_eq!(hit[0].kind, ViolationKind::Denied);
assert!(hit[0].to_string().contains("may not reach sink"));
assert!(evaluate(&p, &sink("jsonl", &[("environment", "dev")]), &cols).is_empty());
assert!(evaluate(&p, &sink("postgres", &[("environment", "prod")]), &cols).is_empty());
assert!(
evaluate(&p, &sink("jsonl", &[]), &cols).is_empty(),
"a `when.sink` attribute the sink lacks means the rule does not apply"
);
}
#[test]
fn conservative_columns_are_flagged_as_such() {
let p = policy();
let mut c = col("email", &["pii"], None);
c.conservative = true;
let v = evaluate(&p, &sink("postgres", &[("residency", "us")]), &[c]);
assert!(v[0].conservative);
assert!(v[0].to_string().contains("opaque transform"));
let j = serde_json::to_value(&v[0]).unwrap();
assert_eq!(j["kind"], "attribute_not_allowed");
assert_eq!(j["conservative"], true);
}
#[test]
fn mask_only_rule_requires_the_mask() {
let spec: PolicySpec = serde_json::from_value(json!({
"classifications": [{"label": "finance", "fields": ["salary"]}],
"rules": [{"name": "hashed-only", "when": {"label": "finance"}, "mask": ["hash"]}]
}))
.unwrap();
let p = CompiledPolicy::compile(&spec).unwrap();
let sink = SinkFacts {
id: "s".into(),
kind: "jsonl".into(),
attributes: BTreeMap::new(),
};
let col = |masked: Option<&str>| ColumnFacts {
name: "salary".into(),
labels: ["finance".to_string()].into(),
masked: masked.map(String::from),
conservative: false,
via: None,
};
let v = evaluate(&p, &sink, &[col(None)]);
assert_eq!(v.len(), 1);
assert!(matches!(&v[0].kind, ViolationKind::Unmasked { allowed } if allowed == &["hash"]));
assert!(
v[0].to_string().contains("only masked with hash"),
"{}",
v[0]
);
assert!(evaluate(&p, &sink, &[col(Some("hash"))]).is_empty());
assert_eq!(evaluate(&p, &sink, &[col(Some("redact"))]).len(), 1);
let bad: PolicySpec = serde_json::from_value(json!({
"classifications": [{"label": "finance", "fields": ["salary"]}],
"rules": [{"name": "empty", "when": {"label": "finance"}}]
}))
.unwrap();
assert!(bad.validate().unwrap_err().contains("mask"));
}
}