Skip to main content

cratestack_sqlx/audit/
redact.rs

1//! PII/sensitive-column redaction + JSON snapshot helpers used when
2//! building audit events. Redaction substitutes a canned marker
3//! string; banks need the audit log to record THAT a field changed
4//! without retaining the actual value (PAN, SSN, address).
5
6/// Replace values of PII/sensitive columns in a JSON snapshot with a
7/// fixed marker. Banks need the audit log to record THAT a field
8/// changed without retaining the actual value; the marker lets a
9/// human reviewer see the column shifted while keeping the data out
10/// of long-term logs.
11pub fn redact_snapshot(
12    snapshot: &mut serde_json::Value,
13    pii_columns: &[&str],
14    sensitive_columns: &[&str],
15) {
16    let Some(map) = snapshot.as_object_mut() else {
17        return;
18    };
19    for col in pii_columns {
20        if let Some(slot) = map.get_mut(*col) {
21            *slot = serde_json::Value::String("[redacted-pii]".to_owned());
22        }
23        let camel = snake_to_camel(col);
24        if camel != *col {
25            if let Some(slot) = map.get_mut(&camel) {
26                *slot = serde_json::Value::String("[redacted-pii]".to_owned());
27            }
28        }
29    }
30    for col in sensitive_columns {
31        if let Some(slot) = map.get_mut(*col) {
32            *slot = serde_json::Value::String("[redacted-sensitive]".to_owned());
33        }
34        let camel = snake_to_camel(col);
35        if camel != *col {
36            if let Some(slot) = map.get_mut(&camel) {
37                *slot = serde_json::Value::String("[redacted-sensitive]".to_owned());
38            }
39        }
40    }
41}
42
43/// Convert a model into the JSON snapshot used by the audit log.
44/// Returns `None` if the model isn't serializable; that should never
45/// happen for generated models which derive `Serialize`.
46pub fn snapshot_model<T>(model: &T) -> Option<serde_json::Value>
47where
48    T: serde::Serialize,
49{
50    serde_json::to_value(model).ok()
51}
52
53/// Extract the primary-key field from a serialized model snapshot.
54/// Used to stamp audit events with a stable identifier even when the
55/// schema doesn't surface the PK column verbatim in the response.
56pub fn primary_key_from_snapshot(
57    snapshot: &serde_json::Value,
58    primary_key_column: &str,
59) -> serde_json::Value {
60    if let Some(map) = snapshot.as_object() {
61        if let Some(value) = map.get(primary_key_column) {
62            return value.clone();
63        }
64        // Try snake/camel transposition — the SQL column name might
65        // differ from the JSON key emitted by the serializer.
66        let camel = snake_to_camel(primary_key_column);
67        if let Some(value) = map.get(&camel) {
68            return value.clone();
69        }
70    }
71    serde_json::Value::Null
72}
73
74fn snake_to_camel(input: &str) -> String {
75    let mut out = String::with_capacity(input.len());
76    let mut upper = false;
77    for ch in input.chars() {
78        if ch == '_' {
79            upper = true;
80        } else if upper {
81            out.extend(ch.to_uppercase());
82            upper = false;
83        } else {
84            out.push(ch);
85        }
86    }
87    out
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serde_json::json;
94
95    #[test]
96    fn extracts_primary_key_by_snake_case_column() {
97        let snapshot = json!({ "user_id": 42, "balance": "10.00" });
98        let pk = primary_key_from_snapshot(&snapshot, "user_id");
99        assert_eq!(pk, json!(42));
100    }
101
102    #[test]
103    fn extracts_primary_key_via_camel_case_fallback() {
104        let snapshot = json!({ "userId": 42, "balance": "10.00" });
105        let pk = primary_key_from_snapshot(&snapshot, "user_id");
106        assert_eq!(pk, json!(42));
107    }
108
109    #[test]
110    fn returns_null_when_primary_key_absent() {
111        let snapshot = json!({ "balance": "10.00" });
112        let pk = primary_key_from_snapshot(&snapshot, "user_id");
113        assert_eq!(pk, serde_json::Value::Null);
114    }
115
116    #[test]
117    fn snapshot_round_trip_preserves_strings_and_numbers() {
118        let snap =
119            snapshot_model(&json!({ "amount": "12.34", "currency": "USD" })).expect("serializable");
120        assert_eq!(snap["amount"], json!("12.34"));
121        assert_eq!(snap["currency"], json!("USD"));
122    }
123
124    #[test]
125    fn redacts_pii_columns_with_canned_marker() {
126        let mut snap = json!({
127            "id": 1,
128            "email": "alice@example.com",
129            "balance": "10.00",
130        });
131        redact_snapshot(&mut snap, &["email"], &[]);
132        assert_eq!(snap["email"], json!("[redacted-pii]"));
133        assert_eq!(snap["balance"], json!("10.00"));
134    }
135
136    #[test]
137    fn redacts_sensitive_columns_with_distinct_marker() {
138        let mut snap = json!({
139            "id": 1,
140            "risk_score": 87,
141        });
142        redact_snapshot(&mut snap, &[], &["risk_score"]);
143        assert_eq!(snap["risk_score"], json!("[redacted-sensitive]"));
144    }
145
146    #[test]
147    fn redaction_handles_camel_case_keys() {
148        let mut snap = json!({
149            "id": 1,
150            "primaryEmail": "x@y.com",
151        });
152        redact_snapshot(&mut snap, &["primary_email"], &[]);
153        assert_eq!(snap["primaryEmail"], json!("[redacted-pii]"));
154    }
155
156    #[test]
157    fn redaction_is_noop_for_absent_columns() {
158        let mut snap = json!({ "id": 1 });
159        redact_snapshot(&mut snap, &["email"], &["risk_score"]);
160        assert_eq!(snap, json!({ "id": 1 }));
161    }
162}