Skip to main content

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}