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}