faucet_core/masking/config.rs
1//! Config-shaped types for the PII detection + column-masking layer (issue
2//! #206). Pure declarations — validation and compilation live in `compile.rs`,
3//! evaluation in the module root.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// The `masking:` config block: a declarative, destination-scoped policy that
10/// classifies sensitive fields (by field-name pattern, by value detector, or
11/// by explicit field list) and applies a masking action per page. The masking
12/// pass runs *first* — before the quality, contract, and schema-drift passes
13/// and before every sink write — so PII never reaches a sink, the DLQ, or a
14/// lineage sample unmasked.
15///
16/// Masking never fails a run or quarantines records: every matching field is
17/// rewritten in place. Rules are evaluated in declared order; for a given
18/// field, the first matching rule wins.
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct MaskingSpec {
22 /// Human-readable description of the policy (documentation metadata).
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub description: Option<String>,
25
26 /// Secret key for keyed hashing/tokenization (`hash` / `tokenize`
27 /// actions). When present, masked values are HMAC-SHA256 keyed digests, so
28 /// the mapping cannot be reversed by an attacker who lacks the key yet stays
29 /// deterministic (equal inputs → equal tokens) so masked values remain
30 /// joinable downstream. Pull it from a secrets manager
31 /// (`${vault:...}` / `${aws-sm:...}` / …) rather than hard-coding it.
32 ///
33 /// When absent, `hash` / `tokenize` fall back to an *unkeyed* SHA-256
34 /// digest — still deterministic, but not secret (anyone can recompute it).
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub key: Option<String>,
37
38 /// The masking rules. Must be non-empty. Evaluated in declared order; the
39 /// first rule that matches a given field wins for that field.
40 pub rules: Vec<MaskRule>,
41}
42
43/// One masking rule: a matcher + the action to apply to matching fields,
44/// optionally scoped to a subset of destination sinks.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46#[serde(deny_unknown_fields)]
47pub struct MaskRule {
48 /// Optional stable label for this rule — surfaced in logs and as the
49 /// `rule` metric label. Defaults to a generated `rule_<n>` when absent.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub name: Option<String>,
52
53 /// How this rule decides a field is sensitive. At least one of
54 /// `field_pattern` / `value_detector` / `fields` must be set.
55 #[serde(rename = "match")]
56 pub matcher: MatchSpec,
57
58 /// What to do with a matching field's value.
59 pub action: MaskAction,
60
61 /// Destination sinks this rule applies to, by template name (e.g.
62 /// `default`, `secure`) or connector kind (e.g. `bigquery`). Empty (the
63 /// default) means the rule applies to every sink — so the same source can
64 /// be fully masked to one sink and partially masked to another by scoping
65 /// rules per destination.
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub applies_to: Vec<String>,
68}
69
70/// How a rule classifies a field as sensitive. Any combination is allowed;
71/// a field matches the rule if *any* configured criterion matches (name
72/// pattern OR value detector OR explicit field name).
73#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct MatchSpec {
76 /// Regex matched against each field's dot-path (e.g. `user.email`,
77 /// `contacts.0.phone`). Case-sensitive unless the pattern opts in with
78 /// `(?i)`. Name-based classification — cheap and precise when you know
79 /// your field names.
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub field_pattern: Option<String>,
82
83 /// Value-based PII detector run over each *string* field value.
84 /// Conservative (fully anchored) by default to avoid over-masking.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub value_detector: Option<Detector>,
87
88 /// Explicit dot-paths to mask unconditionally — the pre-classification /
89 /// tagging escape hatch.
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub fields: Vec<String>,
92}
93
94/// Built-in value detectors. All are conservative (anchored full-string
95/// matches; the card detector additionally requires a valid Luhn checksum) so
96/// false positives stay rare — masking silently rewrites data, so a
97/// false positive is a data-quality bug, not just noise.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99#[serde(rename_all = "snake_case")]
100pub enum Detector {
101 /// RFC-5322-ish email address.
102 Email,
103 /// 13–19 digit card number (spaces/dashes allowed) passing the Luhn check.
104 CreditCard,
105 /// US Social Security Number `NNN-NN-NNNN`.
106 Ssn,
107 /// E.164 / North-American phone number.
108 Phone,
109 /// IPv4 dotted-quad address.
110 Ipv4,
111}
112
113impl std::fmt::Display for Detector {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.write_str(match self {
116 Detector::Email => "email",
117 Detector::CreditCard => "credit_card",
118 Detector::Ssn => "ssn",
119 Detector::Phone => "phone",
120 Detector::Ipv4 => "ipv4",
121 })
122 }
123}
124
125/// What to do with a value once a rule matches it.
126#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
127#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
128pub enum MaskAction {
129 /// Replace the value wholesale with a fixed mask (default `"***"`).
130 /// Irreversible and not joinable — use for values you never need again.
131 Redact {
132 /// Replacement value. Defaults to the JSON string `"***"`. Set it to an
133 /// explicit `null` to null the field (e.g. for a nullable DB column),
134 /// or to any other JSON scalar you prefer.
135 #[serde(default = "default_mask")]
136 mask: Value,
137 },
138 /// Replace the value with its hex HMAC-SHA256 (keyed) / SHA-256 (unkeyed)
139 /// digest. Deterministic → joinable; irreversible.
140 Hash,
141 /// Replace the value with a short opaque token derived from a keyed digest,
142 /// optionally with a stable prefix (e.g. `tok_`). Deterministic → joinable.
143 Tokenize {
144 /// Literal prefix prepended to every token (e.g. `"tok_"`).
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 prefix: Option<String>,
147 },
148 /// Reveal only the last `keep_last` characters, replacing the rest with
149 /// `mask_char` (default `*`). Preserves format/length for readability
150 /// (e.g. `****1234`). `keep_last: 0` masks everything.
151 Partial {
152 /// Number of trailing characters to keep in the clear. Default `4`.
153 #[serde(default = "default_keep_last")]
154 keep_last: usize,
155 /// Character used for the masked prefix. Default `*`.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 mask_char: Option<char>,
158 },
159}
160
161fn default_keep_last() -> usize {
162 4
163}
164
165fn default_mask() -> Value {
166 Value::String("***".to_string())
167}
168
169impl MaskAction {
170 /// Stable action label (closed set — safe as a metric label).
171 pub fn label(&self) -> &'static str {
172 match self {
173 MaskAction::Redact { .. } => "redact",
174 MaskAction::Hash => "hash",
175 MaskAction::Tokenize { .. } => "tokenize",
176 MaskAction::Partial { .. } => "partial",
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use serde_json::json;
185
186 #[test]
187 fn parses_full_masking_block() {
188 let spec: MaskingSpec = serde_json::from_value(json!({
189 "description": "customer PII",
190 "key": "s3cr3t",
191 "rules": [
192 { "name": "emails",
193 "match": { "value_detector": "email" },
194 "action": { "type": "redact" } },
195 { "match": { "field_pattern": "(?i)ssn|social" },
196 "action": { "type": "hash" },
197 "applies_to": ["default"] },
198 { "match": { "fields": ["user.card"] },
199 "action": { "type": "partial", "keep_last": 4 } },
200 { "match": { "field_pattern": "^token$" },
201 "action": { "type": "tokenize", "prefix": "tok_" } }
202 ]
203 }))
204 .unwrap();
205 assert_eq!(spec.key.as_deref(), Some("s3cr3t"));
206 assert_eq!(spec.rules.len(), 4);
207 assert_eq!(spec.rules[0].name.as_deref(), Some("emails"));
208 assert_eq!(spec.rules[0].matcher.value_detector, Some(Detector::Email));
209 assert_eq!(spec.rules[1].applies_to, vec!["default".to_string()]);
210 assert!(matches!(
211 spec.rules[2].action,
212 MaskAction::Partial { keep_last: 4, .. }
213 ));
214 }
215
216 #[test]
217 fn partial_keep_last_defaults_to_four() {
218 let a: MaskAction = serde_json::from_value(json!({ "type": "partial" })).unwrap();
219 assert!(matches!(
220 a,
221 MaskAction::Partial {
222 keep_last: 4,
223 mask_char: None
224 }
225 ));
226 }
227
228 #[test]
229 fn action_labels_are_stable() {
230 assert_eq!(MaskAction::Hash.label(), "hash");
231 assert_eq!(
232 MaskAction::Redact {
233 mask: default_mask()
234 }
235 .label(),
236 "redact"
237 );
238 assert_eq!(MaskAction::Tokenize { prefix: None }.label(), "tokenize");
239 assert_eq!(
240 MaskAction::Partial {
241 keep_last: 2,
242 mask_char: None
243 }
244 .label(),
245 "partial"
246 );
247 }
248
249 #[test]
250 fn detector_display_is_snake_case() {
251 assert_eq!(Detector::CreditCard.to_string(), "credit_card");
252 let d: Detector = serde_json::from_str("\"ipv4\"").unwrap();
253 assert_eq!(d, Detector::Ipv4);
254 }
255
256 #[test]
257 fn rejects_unknown_keys() {
258 assert!(
259 serde_json::from_value::<MaskingSpec>(json!({
260 "rules": [], "nope": 1
261 }))
262 .is_err()
263 );
264 assert!(
265 serde_json::from_value::<MaskRule>(json!({
266 "match": {}, "action": {"type":"hash"}, "bogus": true
267 }))
268 .is_err()
269 );
270 }
271}