Skip to main content

fraiseql_core/security/
field_masking.rs

1//! Sensitive field masking for compliance profiles
2//!
3//! This module handles masking sensitive data in GraphQL responses for REGULATED profiles.
4//! Field sensitivity is determined by field name patterns and explicit marking.
5//!
6//! ## Field Sensitivity Levels
7//!
8//! - **Public**: No masking (e.g., id, name, title)
9//! - **Sensitive**: Partial masking - show first char + *** (e.g., email, phone)
10//! - **PII**: Heavy masking - type + **** (e.g., `ssn`, `credit_card`)
11//! - **Secret**: Always masked - **** (e.g., `password`, `api_key`)
12//!
13//! ## Pattern Matching
14//!
15//! Fields are classified based on name patterns:
16//! - `password*`, `secret*`, `token*`, `key*` → Secret
17//! - `ssn`, `credit_card`, `cvv`, `pin` → PII
18//! - `email`, `phone`, `mobile`, `telephone` → Sensitive
19//! - Everything else → Public
20//!
21//! ## Usage
22//!
23//! ```
24//! use fraiseql_core::security::{FieldMasker, FieldSensitivity};
25//!
26//! let sensitivity = FieldMasker::detect_sensitivity("email");
27//! assert_eq!(sensitivity, FieldSensitivity::Sensitive);
28//!
29//! let masked = FieldMasker::mask_value("user@example.com", sensitivity);
30//! assert_eq!(masked, "u***");
31//! ```
32
33use std::fmt;
34
35use crate::security::SecurityProfile;
36
37/// Field sensitivity classification
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum FieldSensitivity {
40    /// Public field - no masking
41    Public,
42    /// Sensitive field - partial masking
43    Sensitive,
44    /// Personally Identifiable Information - heavy masking
45    PII,
46    /// Secret field - always masked
47    Secret,
48}
49
50impl fmt::Display for FieldSensitivity {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Public => write!(f, "public"),
54            Self::Sensitive => write!(f, "sensitive"),
55            Self::PII => write!(f, "pii"),
56            Self::Secret => write!(f, "secret"),
57        }
58    }
59}
60
61/// Field masking rules and patterns
62#[derive(Debug)]
63pub struct FieldMasker;
64
65impl FieldMasker {
66    /// Detect field sensitivity based on name patterns
67    #[must_use]
68    pub fn detect_sensitivity(field_name: &str) -> FieldSensitivity {
69        let lower = field_name.to_lowercase();
70
71        // Secret fields - cryptographic material and credentials
72        if lower.starts_with("password")
73            || lower.starts_with("secret")
74            || lower.starts_with("token")
75            || lower.contains("token")  // Also catch refresh_token, bearer_token, etc.
76            || lower.starts_with("key")
77            || lower.starts_with("api_key")
78            || lower.starts_with("api_secret")
79            || lower.starts_with("auth")
80            || lower.starts_with("oauth")
81            || lower == "hash"
82            || lower == "signature"
83            || lower.contains("webhook_secret")
84            || lower.contains("private_key")
85            || lower.contains("certificate")
86            || lower.contains("tls_secret")
87            || lower.contains("encryption_key")
88            || lower.contains("database_url")
89            || lower.contains("connection_string")
90            || lower.contains("access_token")
91        {
92            return FieldSensitivity::Secret;
93        }
94
95        // PII fields - personally identifiable and financial information
96        if lower == "ssn"
97            || lower == "social_security_number"
98            || lower.contains("credit_card")
99            || lower.contains("card_number")
100            || lower == "cvv"
101            || lower == "cvc"
102            || lower.contains("bank_account")
103            || lower == "pin"
104            || lower.contains("driver_license")
105            || lower.contains("driver's_license")
106            || lower.contains("passport")
107            || lower == "date_of_birth"
108            || lower == "dob"
109            || lower.contains("maiden_name")
110            || lower.contains("mother's_name")
111            || lower.contains("routing_number")
112            || lower.contains("swift_code")
113            || lower == "iban"
114            || lower.contains("health_record")
115            || lower.contains("medical_record")
116            || lower.contains("state_id")
117            || lower.contains("drivers_license_number")
118        {
119            return FieldSensitivity::PII;
120        }
121
122        // Sensitive fields - personal contact and identification info
123        if lower == "email"
124            || lower.starts_with("email_")
125            || lower.ends_with("_email")
126            || lower == "phone"
127            || lower == "phone_number"
128            || lower.starts_with("phone_")
129            || lower == "mobile"
130            || lower.starts_with("mobile_")
131            || lower == "telephone"
132            || lower == "fax"
133            || lower.contains("ip_address")
134            || lower.contains("ipaddress")
135            || lower == "mac_address"
136            || lower == "macaddress"
137            || lower == "username"
138            || lower.starts_with("username_")
139            || lower.contains("login_name")
140            || lower.contains("im_handle")
141            || lower.contains("slack_id")
142            || lower.contains("twitter_handle")
143            || lower.contains("billing_address")
144            || lower.contains("shipping_address")
145            || lower.contains("home_address")
146            || lower.contains("work_address")
147            || lower.contains("zip_code")
148            || lower.contains("postal_code")
149            || lower.contains("ssn_last_four")
150        {
151            return FieldSensitivity::Sensitive;
152        }
153
154        // Default: public
155        FieldSensitivity::Public
156    }
157
158    /// Mask a string value based on sensitivity level
159    #[must_use]
160    pub fn mask_value(value: &str, sensitivity: FieldSensitivity) -> String {
161        match sensitivity {
162            FieldSensitivity::Public => value.to_string(),
163            FieldSensitivity::Sensitive => Self::mask_sensitive(value),
164            FieldSensitivity::PII => Self::mask_pii(value),
165            FieldSensitivity::Secret => Self::mask_secret(value),
166        }
167    }
168
169    /// Mask sensitive value - show first char + ***
170    fn mask_sensitive(value: &str) -> String {
171        if value.is_empty() {
172            "***".to_string()
173        } else {
174            let first_char = value.chars().next().unwrap_or('*');
175            format!("{first_char}***")
176        }
177    }
178
179    /// Mask PII - show only type + ****
180    fn mask_pii(_value: &str) -> String {
181        "[PII]".to_string()
182    }
183
184    /// Mask secret - always ****
185    fn mask_secret(_value: &str) -> String {
186        "****".to_string()
187    }
188
189    /// Determine if value should be masked for this profile
190    #[must_use]
191    pub fn should_mask(sensitivity: FieldSensitivity, profile: &SecurityProfile) -> bool {
192        match profile {
193            SecurityProfile::Standard => false,
194            SecurityProfile::Regulated => sensitivity != FieldSensitivity::Public,
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    // ========================================================================
204    // Test Suite 1: Field Sensitivity Detection - Public Fields
205    // ========================================================================
206
207    #[test]
208    fn test_id_is_public() {
209        assert_eq!(FieldMasker::detect_sensitivity("id"), FieldSensitivity::Public);
210    }
211
212    #[test]
213    fn test_name_is_public() {
214        assert_eq!(FieldMasker::detect_sensitivity("name"), FieldSensitivity::Public);
215    }
216
217    #[test]
218    fn test_title_is_public() {
219        assert_eq!(FieldMasker::detect_sensitivity("title"), FieldSensitivity::Public);
220    }
221
222    #[test]
223    fn test_description_is_public() {
224        assert_eq!(FieldMasker::detect_sensitivity("description"), FieldSensitivity::Public);
225    }
226
227    #[test]
228    fn test_created_at_is_public() {
229        assert_eq!(FieldMasker::detect_sensitivity("created_at"), FieldSensitivity::Public);
230    }
231
232    // ========================================================================
233    // Test Suite 2: Field Sensitivity Detection - Sensitive Fields
234    // ========================================================================
235
236    #[test]
237    fn test_email_is_sensitive() {
238        assert_eq!(FieldMasker::detect_sensitivity("email"), FieldSensitivity::Sensitive);
239    }
240
241    #[test]
242    fn test_email_address_is_sensitive() {
243        assert_eq!(FieldMasker::detect_sensitivity("email_address"), FieldSensitivity::Sensitive);
244    }
245
246    #[test]
247    fn test_user_email_is_sensitive() {
248        assert_eq!(FieldMasker::detect_sensitivity("user_email"), FieldSensitivity::Sensitive);
249    }
250
251    #[test]
252    fn test_phone_is_sensitive() {
253        assert_eq!(FieldMasker::detect_sensitivity("phone"), FieldSensitivity::Sensitive);
254    }
255
256    #[test]
257    fn test_phone_number_is_sensitive() {
258        assert_eq!(FieldMasker::detect_sensitivity("phone_number"), FieldSensitivity::Sensitive);
259    }
260
261    #[test]
262    fn test_mobile_is_sensitive() {
263        assert_eq!(FieldMasker::detect_sensitivity("mobile"), FieldSensitivity::Sensitive);
264    }
265
266    #[test]
267    fn test_mobile_phone_is_sensitive() {
268        assert_eq!(FieldMasker::detect_sensitivity("mobile_phone"), FieldSensitivity::Sensitive);
269    }
270
271    #[test]
272    fn test_ip_address_is_sensitive() {
273        assert_eq!(FieldMasker::detect_sensitivity("ip_address"), FieldSensitivity::Sensitive);
274    }
275
276    #[test]
277    fn test_mac_address_is_sensitive() {
278        assert_eq!(FieldMasker::detect_sensitivity("mac_address"), FieldSensitivity::Sensitive);
279    }
280
281    // ========================================================================
282    // Test Suite 3: Field Sensitivity Detection - PII Fields
283    // ========================================================================
284
285    #[test]
286    fn test_ssn_is_pii() {
287        assert_eq!(FieldMasker::detect_sensitivity("ssn"), FieldSensitivity::PII);
288    }
289
290    #[test]
291    fn test_social_security_is_pii() {
292        assert_eq!(
293            FieldMasker::detect_sensitivity("social_security_number"),
294            FieldSensitivity::PII
295        );
296    }
297
298    #[test]
299    fn test_credit_card_is_pii() {
300        assert_eq!(FieldMasker::detect_sensitivity("credit_card"), FieldSensitivity::PII);
301    }
302
303    #[test]
304    fn test_card_number_is_pii() {
305        assert_eq!(FieldMasker::detect_sensitivity("card_number"), FieldSensitivity::PII);
306    }
307
308    #[test]
309    fn test_cvv_is_pii() {
310        assert_eq!(FieldMasker::detect_sensitivity("cvv"), FieldSensitivity::PII);
311    }
312
313    #[test]
314    fn test_cvc_is_pii() {
315        assert_eq!(FieldMasker::detect_sensitivity("cvc"), FieldSensitivity::PII);
316    }
317
318    #[test]
319    fn test_bank_account_is_pii() {
320        assert_eq!(FieldMasker::detect_sensitivity("bank_account"), FieldSensitivity::PII);
321    }
322
323    #[test]
324    fn test_pin_is_pii() {
325        assert_eq!(FieldMasker::detect_sensitivity("pin"), FieldSensitivity::PII);
326    }
327
328    #[test]
329    fn test_driver_license_is_pii() {
330        assert_eq!(FieldMasker::detect_sensitivity("driver_license"), FieldSensitivity::PII);
331    }
332
333    #[test]
334    fn test_passport_is_pii() {
335        assert_eq!(FieldMasker::detect_sensitivity("passport"), FieldSensitivity::PII);
336    }
337
338    // ========================================================================
339    // Test Suite 4: Field Sensitivity Detection - Secret Fields
340    // ========================================================================
341
342    #[test]
343    fn test_password_is_secret() {
344        assert_eq!(FieldMasker::detect_sensitivity("password"), FieldSensitivity::Secret);
345    }
346
347    #[test]
348    fn test_password_hash_is_secret() {
349        assert_eq!(FieldMasker::detect_sensitivity("password_hash"), FieldSensitivity::Secret);
350    }
351
352    #[test]
353    fn test_secret_is_secret() {
354        assert_eq!(FieldMasker::detect_sensitivity("secret"), FieldSensitivity::Secret);
355    }
356
357    #[test]
358    fn test_secret_key_is_secret() {
359        assert_eq!(FieldMasker::detect_sensitivity("secret_key"), FieldSensitivity::Secret);
360    }
361
362    #[test]
363    fn test_token_is_secret() {
364        assert_eq!(FieldMasker::detect_sensitivity("token"), FieldSensitivity::Secret);
365    }
366
367    #[test]
368    fn test_refresh_token_is_secret() {
369        assert_eq!(FieldMasker::detect_sensitivity("refresh_token"), FieldSensitivity::Secret);
370    }
371
372    #[test]
373    fn test_api_key_is_secret() {
374        assert_eq!(FieldMasker::detect_sensitivity("api_key"), FieldSensitivity::Secret);
375    }
376
377    #[test]
378    fn test_auth_token_is_secret() {
379        assert_eq!(FieldMasker::detect_sensitivity("auth_token"), FieldSensitivity::Secret);
380    }
381
382    #[test]
383    fn test_hash_is_secret() {
384        assert_eq!(FieldMasker::detect_sensitivity("hash"), FieldSensitivity::Secret);
385    }
386
387    #[test]
388    fn test_signature_is_secret() {
389        assert_eq!(FieldMasker::detect_sensitivity("signature"), FieldSensitivity::Secret);
390    }
391
392    // ========================================================================
393    // Test Suite 5: Case Insensitivity
394    // ========================================================================
395
396    #[test]
397    fn test_case_insensitive_email() {
398        assert_eq!(FieldMasker::detect_sensitivity("EMAIL"), FieldSensitivity::Sensitive);
399    }
400
401    #[test]
402    fn test_case_insensitive_password() {
403        assert_eq!(FieldMasker::detect_sensitivity("PASSWORD"), FieldSensitivity::Secret);
404    }
405
406    #[test]
407    fn test_mixed_case_ssn() {
408        assert_eq!(FieldMasker::detect_sensitivity("SSN"), FieldSensitivity::PII);
409    }
410
411    // ========================================================================
412    // Test Suite 6: Value Masking - Public
413    // ========================================================================
414
415    #[test]
416    fn test_public_value_unmasked() {
417        let result = FieldMasker::mask_value("value", FieldSensitivity::Public);
418        assert_eq!(result, "value");
419    }
420
421    #[test]
422    fn test_public_empty_string_unmasked() {
423        let result = FieldMasker::mask_value("", FieldSensitivity::Public);
424        assert_eq!(result, "");
425    }
426
427    // ========================================================================
428    // Test Suite 7: Value Masking - Sensitive
429    // ========================================================================
430
431    #[test]
432    fn test_sensitive_email_masked() {
433        let result = FieldMasker::mask_value("user@example.com", FieldSensitivity::Sensitive);
434        assert_eq!(result, "u***");
435    }
436
437    #[test]
438    fn test_sensitive_phone_masked() {
439        let result = FieldMasker::mask_value("555-1234", FieldSensitivity::Sensitive);
440        assert_eq!(result, "5***");
441    }
442
443    #[test]
444    fn test_sensitive_single_char_masked() {
445        let result = FieldMasker::mask_value("a", FieldSensitivity::Sensitive);
446        assert_eq!(result, "a***");
447    }
448
449    #[test]
450    fn test_sensitive_empty_masked() {
451        let result = FieldMasker::mask_value("", FieldSensitivity::Sensitive);
452        assert_eq!(result, "***");
453    }
454
455    // ========================================================================
456    // Test Suite 8: Value Masking - PII
457    // ========================================================================
458
459    #[test]
460    fn test_pii_ssn_masked() {
461        let result = FieldMasker::mask_value("123-45-6789", FieldSensitivity::PII);
462        assert_eq!(result, "[PII]");
463    }
464
465    #[test]
466    fn test_pii_credit_card_masked() {
467        let result = FieldMasker::mask_value("4111-1111-1111-1111", FieldSensitivity::PII);
468        assert_eq!(result, "[PII]");
469    }
470
471    #[test]
472    fn test_pii_empty_masked() {
473        let result = FieldMasker::mask_value("", FieldSensitivity::PII);
474        assert_eq!(result, "[PII]");
475    }
476
477    // ========================================================================
478    // Test Suite 9: Value Masking - Secret
479    // ========================================================================
480
481    #[test]
482    fn test_secret_password_masked() {
483        let result = FieldMasker::mask_value("mypassword123", FieldSensitivity::Secret);
484        assert_eq!(result, "****");
485    }
486
487    #[test]
488    fn test_secret_token_masked() {
489        let result = FieldMasker::mask_value("token_abc123xyz", FieldSensitivity::Secret);
490        assert_eq!(result, "****");
491    }
492
493    #[test]
494    fn test_secret_empty_masked() {
495        let result = FieldMasker::mask_value("", FieldSensitivity::Secret);
496        assert_eq!(result, "****");
497    }
498
499    #[test]
500    fn test_secret_any_value_masked() {
501        let result = FieldMasker::mask_value("anything", FieldSensitivity::Secret);
502        assert_eq!(result, "****");
503    }
504
505    // ========================================================================
506    // Test Suite 10: Profile-Based Masking Decision
507    // ========================================================================
508
509    #[test]
510    fn test_standard_profile_no_masking() {
511        let standard = SecurityProfile::standard();
512        assert!(!FieldMasker::should_mask(FieldSensitivity::Public, &standard));
513        assert!(!FieldMasker::should_mask(FieldSensitivity::Sensitive, &standard));
514        assert!(!FieldMasker::should_mask(FieldSensitivity::PII, &standard));
515        assert!(!FieldMasker::should_mask(FieldSensitivity::Secret, &standard));
516    }
517
518    #[test]
519    fn test_regulated_profile_public_no_masking() {
520        let regulated = SecurityProfile::regulated();
521        assert!(!FieldMasker::should_mask(FieldSensitivity::Public, &regulated));
522    }
523
524    #[test]
525    fn test_regulated_profile_sensitive_masked() {
526        let regulated = SecurityProfile::regulated();
527        assert!(FieldMasker::should_mask(FieldSensitivity::Sensitive, &regulated));
528    }
529
530    #[test]
531    fn test_regulated_profile_pii_masked() {
532        let regulated = SecurityProfile::regulated();
533        assert!(FieldMasker::should_mask(FieldSensitivity::PII, &regulated));
534    }
535
536    #[test]
537    fn test_regulated_profile_secret_masked() {
538        let regulated = SecurityProfile::regulated();
539        assert!(FieldMasker::should_mask(FieldSensitivity::Secret, &regulated));
540    }
541
542    // ========================================================================
543    // Test Suite 11: Edge Cases
544    // ========================================================================
545
546    #[test]
547    fn test_very_long_email_masked() {
548        let long_email = "a".repeat(1000) + "@example.com";
549        let result = FieldMasker::mask_value(&long_email, FieldSensitivity::Sensitive);
550        assert_eq!(result, "a***");
551        assert!(result.len() < long_email.len());
552    }
553
554    #[test]
555    fn test_unicode_email_masked() {
556        let result = FieldMasker::mask_value("émail@example.com", FieldSensitivity::Sensitive);
557        assert_eq!(result, "é***");
558    }
559
560    #[test]
561    fn test_sensitivity_display() {
562        assert_eq!(FieldSensitivity::Public.to_string(), "public");
563        assert_eq!(FieldSensitivity::Sensitive.to_string(), "sensitive");
564        assert_eq!(FieldSensitivity::PII.to_string(), "pii");
565        assert_eq!(FieldSensitivity::Secret.to_string(), "secret");
566    }
567}