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 (and
84 /// integer values, for `credit_card`). Conservative (fully anchored) by
85 /// default to avoid over-masking.
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub value_detector: Option<Detector>,
88
89 /// Explicit dot-paths to mask unconditionally — the pre-classification /
90 /// tagging escape hatch.
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub fields: Vec<String>,
93}
94
95/// Built-in value detectors. All are conservative (anchored full-string
96/// matches; the card detector additionally requires a valid Luhn checksum) so
97/// false positives stay rare — masking silently rewrites data, so a
98/// false positive is a data-quality bug, not just noise.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100#[serde(rename_all = "snake_case")]
101pub enum Detector {
102 /// RFC-5322-ish email address.
103 Email,
104 /// 13–19 digit card number (spaces/dashes allowed, or a JSON integer)
105 /// passing the Luhn check.
106 CreditCard,
107 /// US Social Security Number `NNN-NN-NNNN`.
108 Ssn,
109 /// E.164 (`+` then 8–15 digits) or a North-American number written with
110 /// separators; a bare run of digits never matches.
111 Phone,
112 /// IPv4 dotted-quad address.
113 Ipv4,
114}
115
116impl std::fmt::Display for Detector {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.write_str(match self {
119 Detector::Email => "email",
120 Detector::CreditCard => "credit_card",
121 Detector::Ssn => "ssn",
122 Detector::Phone => "phone",
123 Detector::Ipv4 => "ipv4",
124 })
125 }
126}
127
128/// What to do with a value once a rule matches it.
129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
130#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
131pub enum MaskAction {
132 /// Replace the value wholesale with a fixed mask (default `"***"`).
133 /// Irreversible and not joinable — use for values you never need again.
134 Redact {
135 /// Replacement value. Defaults to the JSON string `"***"`. Set it to an
136 /// explicit `null` to null the field (e.g. for a nullable DB column),
137 /// or to any other JSON scalar you prefer.
138 #[serde(default = "default_mask")]
139 mask: Value,
140 },
141 /// Replace the value with its hex HMAC-SHA256 (keyed) / SHA-256 (unkeyed)
142 /// digest. Deterministic → joinable; irreversible.
143 Hash,
144 /// Replace the value with a short opaque token derived from a keyed digest,
145 /// optionally with a stable prefix (e.g. `tok_`). Deterministic → joinable.
146 Tokenize {
147 /// Literal prefix prepended to every token (e.g. `"tok_"`).
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 prefix: Option<String>,
150 },
151 /// Reveal only the last `keep_last` characters, replacing the rest with
152 /// `mask_char` (default `*`). Preserves format/length for readability
153 /// (e.g. `****1234`). `keep_last: 0` masks everything.
154 Partial {
155 /// Number of trailing characters to keep in the clear. Default `4`.
156 #[serde(default = "default_keep_last")]
157 keep_last: usize,
158 /// Character used for the masked prefix. Default `*`.
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 mask_char: Option<char>,
161 },
162}
163
164fn default_keep_last() -> usize {
165 4
166}
167
168fn default_mask() -> Value {
169 Value::String("***".to_string())
170}
171
172impl MaskAction {
173 /// Stable action label (closed set — safe as a metric label).
174 pub fn label(&self) -> &'static str {
175 match self {
176 MaskAction::Redact { .. } => "redact",
177 MaskAction::Hash => "hash",
178 MaskAction::Tokenize { .. } => "tokenize",
179 MaskAction::Partial { .. } => "partial",
180 }
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use serde_json::json;
188
189 #[test]
190 fn parses_full_masking_block() {
191 let spec: MaskingSpec = serde_json::from_value(json!({
192 "description": "customer PII",
193 "key": "s3cr3t",
194 "rules": [
195 { "name": "emails",
196 "match": { "value_detector": "email" },
197 "action": { "type": "redact" } },
198 { "match": { "field_pattern": "(?i)ssn|social" },
199 "action": { "type": "hash" },
200 "applies_to": ["default"] },
201 { "match": { "fields": ["user.card"] },
202 "action": { "type": "partial", "keep_last": 4 } },
203 { "match": { "field_pattern": "^token$" },
204 "action": { "type": "tokenize", "prefix": "tok_" } }
205 ]
206 }))
207 .unwrap();
208 assert_eq!(spec.key.as_deref(), Some("s3cr3t"));
209 assert_eq!(spec.rules.len(), 4);
210 assert_eq!(spec.rules[0].name.as_deref(), Some("emails"));
211 assert_eq!(spec.rules[0].matcher.value_detector, Some(Detector::Email));
212 assert_eq!(spec.rules[1].applies_to, vec!["default".to_string()]);
213 assert!(matches!(
214 spec.rules[2].action,
215 MaskAction::Partial { keep_last: 4, .. }
216 ));
217 }
218
219 #[test]
220 fn partial_keep_last_defaults_to_four() {
221 let a: MaskAction = serde_json::from_value(json!({ "type": "partial" })).unwrap();
222 assert!(matches!(
223 a,
224 MaskAction::Partial {
225 keep_last: 4,
226 mask_char: None
227 }
228 ));
229 }
230
231 #[test]
232 fn action_labels_are_stable() {
233 assert_eq!(MaskAction::Hash.label(), "hash");
234 assert_eq!(
235 MaskAction::Redact {
236 mask: default_mask()
237 }
238 .label(),
239 "redact"
240 );
241 assert_eq!(MaskAction::Tokenize { prefix: None }.label(), "tokenize");
242 assert_eq!(
243 MaskAction::Partial {
244 keep_last: 2,
245 mask_char: None
246 }
247 .label(),
248 "partial"
249 );
250 }
251
252 #[test]
253 fn detector_display_is_snake_case() {
254 assert_eq!(Detector::CreditCard.to_string(), "credit_card");
255 let d: Detector = serde_json::from_str("\"ipv4\"").unwrap();
256 assert_eq!(d, Detector::Ipv4);
257 }
258
259 #[test]
260 fn rejects_unknown_keys() {
261 assert!(
262 serde_json::from_value::<MaskingSpec>(json!({
263 "rules": [], "nope": 1
264 }))
265 .is_err()
266 );
267 assert!(
268 serde_json::from_value::<MaskRule>(json!({
269 "match": {}, "action": {"type":"hash"}, "bogus": true
270 }))
271 .is_err()
272 );
273 }
274}