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