Skip to main content

ranvier_compliance/
lib.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt;
4
5// ---------------------------------------------------------------------------
6// ClassificationLevel
7// ---------------------------------------------------------------------------
8
9/// Data classification level for compliance policies.
10///
11/// Higher levels require more stringent access controls and handling procedures.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13pub enum ClassificationLevel {
14    /// Freely shareable data.
15    Public,
16    /// Organization-internal data, not for public distribution.
17    Internal,
18    /// Sensitive data requiring access controls (e.g., financial records).
19    Confidential,
20    /// Highly sensitive data (e.g., PII, PHI, credentials). Requires explicit grant.
21    Restricted,
22}
23
24impl fmt::Display for ClassificationLevel {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            ClassificationLevel::Public => write!(f, "Public"),
28            ClassificationLevel::Internal => write!(f, "Internal"),
29            ClassificationLevel::Confidential => write!(f, "Confidential"),
30            ClassificationLevel::Restricted => write!(f, "Restricted"),
31        }
32    }
33}
34
35// ---------------------------------------------------------------------------
36// Redact trait
37// ---------------------------------------------------------------------------
38
39/// Defines types that contain sensitive PII or PHI data and should be redacted in logs or regular outputs.
40pub trait Redact {
41    fn redact(&self) -> String;
42}
43
44// ---------------------------------------------------------------------------
45// Sensitive<T>
46// ---------------------------------------------------------------------------
47
48/// A wrapper for sensitive data indicating it falls under GDPR or HIPAA compliance scope.
49/// The inner data is strictly prevented from being debug-printed or logged by default.
50#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
51pub struct Sensitive<T> {
52    value: T,
53    /// Data classification level. Defaults to `Restricted`.
54    pub classification: ClassificationLevel,
55}
56
57impl<T> Sensitive<T> {
58    pub fn new(value: T) -> Self {
59        Self {
60            value,
61            classification: ClassificationLevel::Restricted,
62        }
63    }
64
65    /// Create a Sensitive wrapper with a specific classification level.
66    pub fn with_classification(value: T, classification: ClassificationLevel) -> Self {
67        Self {
68            value,
69            classification,
70        }
71    }
72
73    /// Explicitly unwrap and access the sensitive data. Use with caution.
74    pub fn expose(&self) -> &T {
75        &self.value
76    }
77
78    pub fn into_inner(self) -> T {
79        self.value
80    }
81}
82
83// Redact by default in Debug
84impl<T> fmt::Debug for Sensitive<T> {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "[REDACTED:{}]", self.classification)
87    }
88}
89
90// Redact by default in Display
91impl<T> fmt::Display for Sensitive<T> {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "[REDACTED]")
94    }
95}
96
97/// Serialization for `Sensitive<T>`.
98///
99/// In **debug builds** (`cfg(debug_assertions)`), the inner value is serialized
100/// transparently so that local development and testing work as expected.
101///
102/// In **release builds**, the value is replaced with the string `"[REDACTED]"`
103/// to prevent accidental PII/PHI leakage into logs, API responses, or external
104/// systems. Use [`Sensitive::expose()`] to access the underlying value when
105/// explicit transmission is required.
106impl<T: Serialize> Serialize for Sensitive<T> {
107    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
108    where
109        S: Serializer,
110    {
111        if cfg!(debug_assertions) {
112            self.value.serialize(serializer)
113        } else {
114            serializer.serialize_str("[REDACTED]")
115        }
116    }
117}
118
119impl<'de, T: Deserialize<'de>> Deserialize<'de> for Sensitive<T> {
120    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121    where
122        D: Deserializer<'de>,
123    {
124        T::deserialize(deserializer).map(Sensitive::new)
125    }
126}
127
128// ---------------------------------------------------------------------------
129// EncryptionHook
130// ---------------------------------------------------------------------------
131
132/// Hook for field-level encryption/decryption of sensitive data.
133pub trait EncryptionHook: Send + Sync {
134    /// Encrypt the given plaintext bytes.
135    fn encrypt(&self, data: &[u8]) -> Vec<u8>;
136
137    /// Decrypt the given ciphertext bytes.
138    fn decrypt(&self, data: &[u8]) -> Vec<u8>;
139}
140
141/// No-op encryption hook that passes data through unchanged.
142pub struct NoOpEncryption;
143
144impl EncryptionHook for NoOpEncryption {
145    fn encrypt(&self, data: &[u8]) -> Vec<u8> {
146        data.to_vec()
147    }
148
149    fn decrypt(&self, data: &[u8]) -> Vec<u8> {
150        data.to_vec()
151    }
152}
153
154/// XOR-based encryption hook for testing/demo purposes.
155///
156/// # Security Warning
157///
158/// **NOT SUITABLE FOR PRODUCTION.** This uses a single-byte key (256 possible
159/// values) and is trivially breakable via brute force. Use AES-GCM,
160/// ChaCha20Poly1305, or similar authenticated encryption for real workloads.
161///
162/// This struct is gated behind the `xor-demo` feature to prevent accidental
163/// use in production builds.
164#[cfg(feature = "xor-demo")]
165#[deprecated(
166    since = "0.32.0",
167    note = "XOR encryption is cryptographically broken. Use AES-GCM or ChaCha20Poly1305 for production."
168)]
169pub struct XorEncryption {
170    key: u8,
171}
172
173#[cfg(feature = "xor-demo")]
174#[allow(deprecated)]
175impl XorEncryption {
176    pub fn new(key: u8) -> Self {
177        Self { key }
178    }
179}
180
181#[cfg(feature = "xor-demo")]
182#[allow(deprecated)]
183impl EncryptionHook for XorEncryption {
184    fn encrypt(&self, data: &[u8]) -> Vec<u8> {
185        data.iter().map(|b| b ^ self.key).collect()
186    }
187
188    fn decrypt(&self, data: &[u8]) -> Vec<u8> {
189        // XOR is its own inverse
190        self.encrypt(data)
191    }
192}
193
194// ---------------------------------------------------------------------------
195// PII Detection
196// ---------------------------------------------------------------------------
197
198/// A detected PII field with its classification.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct PiiField {
201    /// The JSON path or field name that was flagged.
202    pub field_name: String,
203    /// The suggested classification level.
204    pub classification: ClassificationLevel,
205    /// The PII category that triggered the match.
206    pub category: String,
207}
208
209/// Detects PII in field names using pattern matching.
210pub struct FieldNamePiiDetector {
211    patterns: Vec<(Vec<&'static str>, &'static str, ClassificationLevel)>,
212}
213
214impl Default for FieldNamePiiDetector {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220impl FieldNamePiiDetector {
221    pub fn new() -> Self {
222        Self {
223            patterns: vec![
224                (
225                    vec!["email", "e_mail", "email_address"],
226                    "email",
227                    ClassificationLevel::Confidential,
228                ),
229                (
230                    vec!["phone", "phone_number", "mobile", "tel", "telephone"],
231                    "phone",
232                    ClassificationLevel::Confidential,
233                ),
234                (
235                    vec!["ssn", "social_security", "social_security_number"],
236                    "ssn",
237                    ClassificationLevel::Restricted,
238                ),
239                (
240                    vec![
241                        "address", "street", "street_address", "home_address",
242                        "postal_code", "zip_code", "zip",
243                    ],
244                    "address",
245                    ClassificationLevel::Confidential,
246                ),
247                (
248                    vec![
249                        "first_name", "last_name", "full_name", "given_name",
250                        "family_name", "surname",
251                    ],
252                    "name",
253                    ClassificationLevel::Confidential,
254                ),
255                (
256                    vec!["ip", "ip_address", "ipv4", "ipv6", "client_ip", "remote_addr"],
257                    "ip_address",
258                    ClassificationLevel::Internal,
259                ),
260                (
261                    vec![
262                        "credit_card", "card_number", "cc_number", "pan",
263                        "payment_card",
264                    ],
265                    "credit_card",
266                    ClassificationLevel::Restricted,
267                ),
268                (
269                    vec!["password", "passwd", "secret", "api_key", "access_token"],
270                    "credential",
271                    ClassificationLevel::Restricted,
272                ),
273                (
274                    vec!["date_of_birth", "dob", "birth_date", "birthday"],
275                    "date_of_birth",
276                    ClassificationLevel::Confidential,
277                ),
278                // Korean PII patterns
279                (
280                    vec!["jumin", "jumin_number", "resident_number", "resident_registration"],
281                    "kr_resident_number",
282                    ClassificationLevel::Restricted,
283                ),
284                (
285                    vec!["business_number", "saeopja", "business_registration"],
286                    "kr_business_number",
287                    ClassificationLevel::Confidential,
288                ),
289                (
290                    vec!["passport", "passport_number", "yeokkwon"],
291                    "passport",
292                    ClassificationLevel::Restricted,
293                ),
294                (
295                    vec!["drivers_license", "driver_license", "license_number", "myeonheo"],
296                    "drivers_license",
297                    ClassificationLevel::Restricted,
298                ),
299            ],
300        }
301    }
302
303    /// Classify a field name, returning the classification level if it matches a PII pattern.
304    pub fn classify(&self, field_name: &str) -> Option<ClassificationLevel> {
305        let lower = field_name.to_lowercase();
306        for (patterns, _, level) in &self.patterns {
307            if patterns.iter().any(|p| lower == *p || lower.contains(p)) {
308                return Some(*level);
309            }
310        }
311        None
312    }
313
314    /// Scan a JSON value for PII field names, returning all detected PII fields.
315    pub fn scan_value(&self, value: &serde_json::Value) -> Vec<PiiField> {
316        let mut results = Vec::new();
317        self.scan_recursive(value, "", &mut results);
318        results
319    }
320
321    fn scan_recursive(
322        &self,
323        value: &serde_json::Value,
324        path: &str,
325        results: &mut Vec<PiiField>,
326    ) {
327        match value {
328            serde_json::Value::Object(map) => {
329                for (key, val) in map {
330                    let field_path = if path.is_empty() {
331                        key.clone()
332                    } else {
333                        format!("{path}.{key}")
334                    };
335
336                    let lower = key.to_lowercase();
337                    for (patterns, category, level) in &self.patterns {
338                        if patterns.iter().any(|p| lower == *p || lower.contains(p)) {
339                            results.push(PiiField {
340                                field_name: field_path.clone(),
341                                classification: *level,
342                                category: category.to_string(),
343                            });
344                            break;
345                        }
346                    }
347
348                    self.scan_recursive(val, &field_path, results);
349                }
350            }
351            serde_json::Value::Array(arr) => {
352                for (i, val) in arr.iter().enumerate() {
353                    let item_path = format!("{path}[{i}]");
354                    self.scan_recursive(val, &item_path, results);
355                }
356            }
357            _ => {}
358        }
359    }
360}
361
362/// Trait for PII detection on text content.
363pub trait PiiDetector {
364    /// Detects if the given text likely contains Personally Identifiable Information
365    fn contains_pii(&self, text: &str) -> bool;
366}
367
368impl PiiDetector for FieldNamePiiDetector {
369    fn contains_pii(&self, text: &str) -> bool {
370        self.classify(text).is_some()
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Right-to-Erasure (GDPR Article 17)
376// ---------------------------------------------------------------------------
377
378/// A request to erase personal data for a specific subject.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct ErasureRequest {
381    /// Unique identifier for this erasure request.
382    pub id: String,
383    /// The data subject identifier (e.g., user ID, email).
384    pub subject: String,
385    /// Scope of erasure (e.g., "all", "transactions", specific table names).
386    pub scope: Vec<String>,
387    /// When the request was made.
388    pub timestamp: DateTime<Utc>,
389    /// Optional reason for the request.
390    pub reason: Option<String>,
391}
392
393impl ErasureRequest {
394    pub fn new(id: String, subject: String, scope: Vec<String>) -> Self {
395        Self {
396            id,
397            subject,
398            scope,
399            timestamp: Utc::now(),
400            reason: None,
401        }
402    }
403
404    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
405        self.reason = Some(reason.into());
406        self
407    }
408}
409
410/// Result of processing an erasure request.
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct ErasureResult {
413    /// The original request ID.
414    pub request_id: String,
415    /// Whether the erasure was successfully completed.
416    pub success: bool,
417    /// Number of records erased.
418    pub records_erased: usize,
419    /// Any scopes that could not be processed.
420    pub failed_scopes: Vec<String>,
421    /// When the erasure was completed.
422    pub completed_at: DateTime<Utc>,
423}
424
425/// Sink for processing data erasure requests.
426pub trait ErasureSink: Send + Sync {
427    /// Process a data erasure request.
428    fn erase(&self, request: &ErasureRequest) -> ErasureResult;
429}
430
431/// In-memory erasure sink for testing.
432pub struct InMemoryErasureSink {
433    records: std::sync::Mutex<std::collections::HashMap<String, Vec<String>>>,
434}
435
436impl Default for InMemoryErasureSink {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442impl InMemoryErasureSink {
443    pub fn new() -> Self {
444        Self {
445            records: std::sync::Mutex::new(std::collections::HashMap::new()),
446        }
447    }
448
449    /// Add records for a subject (for testing purposes).
450    pub fn add_records(&self, subject: &str, data: Vec<String>) {
451        let mut records = self.records.lock().unwrap();
452        records.insert(subject.to_string(), data);
453    }
454}
455
456impl ErasureSink for InMemoryErasureSink {
457    fn erase(&self, request: &ErasureRequest) -> ErasureResult {
458        let mut records = self.records.lock().unwrap();
459        let count = if let Some(data) = records.remove(&request.subject) {
460            data.len()
461        } else {
462            0
463        };
464
465        ErasureResult {
466            request_id: request.id.clone(),
467            success: true,
468            records_erased: count,
469            failed_scopes: Vec::new(),
470            completed_at: Utc::now(),
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_sensitive_redaction() {
481        let email = Sensitive::new("user@example.com".to_string());
482
483        assert_eq!(format!("{:?}", email), "[REDACTED:Restricted]");
484        assert_eq!(format!("{}", email), "[REDACTED]");
485        assert_eq!(email.expose(), "user@example.com");
486    }
487
488    #[test]
489    fn test_sensitive_with_classification() {
490        let data = Sensitive::with_classification("internal doc", ClassificationLevel::Internal);
491        assert_eq!(data.classification, ClassificationLevel::Internal);
492        assert_eq!(format!("{:?}", data), "[REDACTED:Internal]");
493    }
494
495    #[test]
496    fn test_sensitive_serialization() {
497        let password = Sensitive::new("my_secret_pass".to_string());
498
499        let json = serde_json::to_string(&password).unwrap();
500        assert_eq!(json, "\"my_secret_pass\"");
501
502        let deserialized: Sensitive<String> = serde_json::from_str(&json).unwrap();
503        assert_eq!(deserialized.expose(), "my_secret_pass");
504    }
505
506    #[test]
507    fn classification_level_ordering() {
508        assert!(ClassificationLevel::Public < ClassificationLevel::Internal);
509        assert!(ClassificationLevel::Internal < ClassificationLevel::Confidential);
510        assert!(ClassificationLevel::Confidential < ClassificationLevel::Restricted);
511    }
512
513    #[test]
514    fn noop_encryption_passthrough() {
515        let hook = NoOpEncryption;
516        let data = b"hello world";
517        let encrypted = hook.encrypt(data);
518        let decrypted = hook.decrypt(&encrypted);
519        assert_eq!(decrypted, data);
520    }
521
522    #[cfg(feature = "xor-demo")]
523    #[test]
524    #[allow(deprecated)]
525    fn xor_encryption_roundtrip() {
526        let hook = XorEncryption::new(0x42);
527        let data = b"sensitive data";
528        let encrypted = hook.encrypt(data);
529        assert_ne!(encrypted, data, "Encrypted should differ from plaintext");
530        let decrypted = hook.decrypt(&encrypted);
531        assert_eq!(decrypted, data, "Decrypted should match original");
532    }
533
534    #[test]
535    fn pii_detector_classifies_email() {
536        let detector = FieldNamePiiDetector::new();
537        assert_eq!(
538            detector.classify("email"),
539            Some(ClassificationLevel::Confidential)
540        );
541        assert_eq!(
542            detector.classify("email_address"),
543            Some(ClassificationLevel::Confidential)
544        );
545    }
546
547    #[test]
548    fn pii_detector_classifies_ssn_as_restricted() {
549        let detector = FieldNamePiiDetector::new();
550        assert_eq!(
551            detector.classify("ssn"),
552            Some(ClassificationLevel::Restricted)
553        );
554        assert_eq!(
555            detector.classify("social_security_number"),
556            Some(ClassificationLevel::Restricted)
557        );
558    }
559
560    #[test]
561    fn pii_detector_no_match_for_regular_fields() {
562        let detector = FieldNamePiiDetector::new();
563        assert_eq!(detector.classify("created_at"), None);
564        assert_eq!(detector.classify("status"), None);
565        assert_eq!(detector.classify("quantity"), None);
566    }
567
568    #[test]
569    fn pii_detector_scan_json_value() {
570        let detector = FieldNamePiiDetector::new();
571        let json = serde_json::json!({
572            "id": 1,
573            "email": "test@example.com",
574            "profile": {
575                "first_name": "Alice",
576                "phone": "555-1234"
577            },
578            "status": "active"
579        });
580
581        let results = detector.scan_value(&json);
582        assert_eq!(results.len(), 3);
583
584        let field_names: Vec<&str> = results.iter().map(|f| f.field_name.as_str()).collect();
585        assert!(field_names.contains(&"email"));
586        assert!(field_names.contains(&"profile.first_name"));
587        assert!(field_names.contains(&"profile.phone"));
588    }
589
590    #[test]
591    fn pii_detector_trait_impl() {
592        let detector = FieldNamePiiDetector::new();
593        assert!(detector.contains_pii("email"));
594        assert!(!detector.contains_pii("status"));
595    }
596
597    #[test]
598    fn erasure_request_processing() {
599        let sink = InMemoryErasureSink::new();
600        sink.add_records(
601            "user_42",
602            vec![
603                "record1".into(),
604                "record2".into(),
605                "record3".into(),
606            ],
607        );
608
609        let request = ErasureRequest::new(
610            "req_001".into(),
611            "user_42".into(),
612            vec!["all".into()],
613        )
614        .with_reason("GDPR Article 17 request");
615
616        let result = sink.erase(&request);
617        assert!(result.success);
618        assert_eq!(result.records_erased, 3);
619        assert!(result.failed_scopes.is_empty());
620    }
621
622    #[test]
623    fn erasure_request_for_missing_subject() {
624        let sink = InMemoryErasureSink::new();
625        let request = ErasureRequest::new(
626            "req_002".into(),
627            "unknown_user".into(),
628            vec!["all".into()],
629        );
630
631        let result = sink.erase(&request);
632        assert!(result.success);
633        assert_eq!(result.records_erased, 0);
634    }
635
636    // --- New tests for M241 ---
637
638    #[test]
639    fn sensitive_display_masking_all_levels() {
640        let public = Sensitive::with_classification("data", ClassificationLevel::Public);
641        let internal = Sensitive::with_classification("data", ClassificationLevel::Internal);
642        let confidential =
643            Sensitive::with_classification("data", ClassificationLevel::Confidential);
644        let restricted = Sensitive::with_classification("data", ClassificationLevel::Restricted);
645
646        assert_eq!(format!("{}", public), "[REDACTED]");
647        assert_eq!(format!("{}", internal), "[REDACTED]");
648        assert_eq!(format!("{}", confidential), "[REDACTED]");
649        assert_eq!(format!("{}", restricted), "[REDACTED]");
650    }
651
652    #[test]
653    fn sensitive_debug_includes_level() {
654        let public = Sensitive::with_classification("data", ClassificationLevel::Public);
655        let restricted = Sensitive::with_classification("data", ClassificationLevel::Restricted);
656
657        assert_eq!(format!("{:?}", public), "[REDACTED:Public]");
658        assert_eq!(format!("{:?}", restricted), "[REDACTED:Restricted]");
659    }
660
661    #[test]
662    fn pii_detector_korean_resident_number() {
663        let detector = FieldNamePiiDetector::new();
664        assert_eq!(
665            detector.classify("jumin_number"),
666            Some(ClassificationLevel::Restricted)
667        );
668        assert_eq!(
669            detector.classify("resident_registration"),
670            Some(ClassificationLevel::Restricted)
671        );
672    }
673
674    #[test]
675    fn pii_detector_korean_business_number() {
676        let detector = FieldNamePiiDetector::new();
677        assert_eq!(
678            detector.classify("business_number"),
679            Some(ClassificationLevel::Confidential)
680        );
681        assert_eq!(
682            detector.classify("business_registration"),
683            Some(ClassificationLevel::Confidential)
684        );
685    }
686
687    #[test]
688    fn pii_detector_passport() {
689        let detector = FieldNamePiiDetector::new();
690        assert_eq!(
691            detector.classify("passport_number"),
692            Some(ClassificationLevel::Restricted)
693        );
694    }
695
696    #[test]
697    fn pii_detector_drivers_license() {
698        let detector = FieldNamePiiDetector::new();
699        assert_eq!(
700            detector.classify("drivers_license"),
701            Some(ClassificationLevel::Restricted)
702        );
703        assert_eq!(
704            detector.classify("license_number"),
705            Some(ClassificationLevel::Restricted)
706        );
707    }
708
709    #[test]
710    fn classification_level_serde_roundtrip() {
711        let level = ClassificationLevel::Restricted;
712        let json = serde_json::to_string(&level).unwrap();
713        let deser: ClassificationLevel = serde_json::from_str(&json).unwrap();
714        assert_eq!(deser, level);
715    }
716
717    #[test]
718    fn erasure_request_with_reason() {
719        let req = ErasureRequest::new("r1".into(), "user1".into(), vec!["all".into()])
720            .with_reason("GDPR Article 17");
721        assert_eq!(req.reason.as_deref(), Some("GDPR Article 17"));
722    }
723
724    #[test]
725    fn in_memory_erasure_sink_verify_post_erasure() {
726        let sink = InMemoryErasureSink::new();
727        sink.add_records("user_1", vec!["rec1".into(), "rec2".into()]);
728
729        let req = ErasureRequest::new("r1".into(), "user_1".into(), vec!["all".into()]);
730        let result = sink.erase(&req);
731        assert_eq!(result.records_erased, 2);
732
733        // Second erasure should find nothing
734        let result2 = sink.erase(&req);
735        assert_eq!(result2.records_erased, 0);
736    }
737
738    #[test]
739    fn pii_detector_total_categories() {
740        let detector = FieldNamePiiDetector::new();
741        // 9 original + 4 Korean = 13 categories
742        assert_eq!(detector.patterns.len(), 13);
743    }
744
745    #[test]
746    fn sensitive_into_inner_returns_value() {
747        let s = Sensitive::new(42);
748        assert_eq!(s.into_inner(), 42);
749    }
750
751    #[cfg(feature = "xor-demo")]
752    #[test]
753    #[allow(deprecated)]
754    fn xor_encryption_different_keys_differ() {
755        let hook1 = XorEncryption::new(0x42);
756        let hook2 = XorEncryption::new(0xFF);
757        let data = b"test";
758        assert_ne!(hook1.encrypt(data), hook2.encrypt(data));
759    }
760}