Skip to main content

stateset_authz/
redaction.rs

1//! Sensitive field redaction for audit and logging.
2//!
3//! Provides recursive JSON value redaction and partial string masking,
4//! mirroring the `_sanitizeParams` logic from `cli/src/permissions.js`.
5
6use std::collections::HashSet;
7
8use serde::{Deserialize, Serialize};
9
10/// Configuration for which fields to redact.
11///
12/// ```rust
13/// use stateset_authz::RedactionConfig;
14///
15/// let config = RedactionConfig::default();
16/// assert!(config.fields().contains("password"));
17/// assert!(config.fields().contains("api_key"));
18/// ```
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RedactionConfig {
21    fields: HashSet<String>,
22    patterns: Vec<String>,
23}
24
25impl RedactionConfig {
26    /// Creates a config with no fields and no patterns.
27    #[must_use]
28    pub fn empty() -> Self {
29        Self { fields: HashSet::new(), patterns: Vec::new() }
30    }
31
32    /// Creates a config with the given field names.
33    #[must_use]
34    pub fn with_fields(fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
35        Self { fields: fields.into_iter().map(Into::into).collect(), patterns: Vec::new() }
36    }
37
38    /// Adds a field name to the redaction set.
39    pub fn add_field(&mut self, field: impl Into<String>) {
40        self.fields.insert(field.into());
41    }
42
43    /// Adds a regex pattern for matching field names.
44    pub fn add_pattern(&mut self, pattern: impl Into<String>) {
45        self.patterns.push(pattern.into());
46    }
47
48    /// Returns the set of exact field names to redact.
49    #[must_use]
50    pub const fn fields(&self) -> &HashSet<String> {
51        &self.fields
52    }
53
54    /// Returns the list of regex patterns.
55    #[must_use]
56    pub fn patterns(&self) -> &[String] {
57        &self.patterns
58    }
59
60    /// Returns `true` if the given field name should be redacted.
61    #[must_use]
62    pub fn should_redact(&self, field_name: &str) -> bool {
63        let lower = field_name.to_ascii_lowercase();
64
65        // Check exact match (case-insensitive)
66        if self.fields.iter().any(|f| f.to_ascii_lowercase() == lower) {
67            return true;
68        }
69
70        // Check patterns (simple substring matching — no regex dependency)
71        for pattern in &self.patterns {
72            if lower.contains(&pattern.to_ascii_lowercase()) {
73                return true;
74            }
75        }
76
77        false
78    }
79}
80
81impl Default for RedactionConfig {
82    /// Default sensitive fields matching the JS `_sanitizeParams` behavior.
83    fn default() -> Self {
84        Self {
85            fields: [
86                "password",
87                "secret",
88                "token",
89                "api_key",
90                "authorization",
91                "credit_card",
92                "ssn",
93            ]
94            .into_iter()
95            .map(String::from)
96            .collect(),
97            patterns: Vec::new(),
98        }
99    }
100}
101
102/// Recursively walks a JSON value and replaces matching field values with `"[REDACTED]"`.
103///
104/// ```rust
105/// use stateset_authz::{RedactionConfig, redact_value};
106/// use serde_json::json;
107///
108/// let config = RedactionConfig::default();
109/// let mut value = json!({
110///     "name": "Alice",
111///     "password": "secret123",
112///     "nested": { "token": "abc" }
113/// });
114///
115/// redact_value(&mut value, &config);
116///
117/// assert_eq!(value["name"], "Alice");
118/// assert_eq!(value["password"], "[REDACTED]");
119/// assert_eq!(value["nested"]["token"], "[REDACTED]");
120/// ```
121pub fn redact_value(value: &mut serde_json::Value, config: &RedactionConfig) {
122    match value {
123        serde_json::Value::Object(map) => {
124            let keys_to_redact: Vec<String> =
125                map.keys().filter(|k| config.should_redact(k)).cloned().collect();
126
127            for key in keys_to_redact {
128                if let Some(v) = map.get_mut(&key) {
129                    *v = serde_json::Value::String("[REDACTED]".to_owned());
130                }
131            }
132
133            // Recurse into non-redacted values
134            for (key, v) in map.iter_mut() {
135                if !config.should_redact(key) {
136                    redact_value(v, config);
137                }
138            }
139        }
140        serde_json::Value::Array(arr) => {
141            for item in arr {
142                redact_value(item, config);
143            }
144        }
145        _ => {}
146    }
147}
148
149/// Partially masks a string by keeping the first 3 and last 3 characters,
150/// replacing the middle with `***`.
151///
152/// For strings with 6 or fewer characters, the entire string is replaced
153/// with `***`.
154///
155/// ```rust
156/// use stateset_authz::redact_string;
157///
158/// assert_eq!(redact_string("secret123"), "sec***123");
159/// assert_eq!(redact_string("ab"), "***");
160/// assert_eq!(redact_string(""), "***");
161/// ```
162#[must_use]
163pub fn redact_string(s: &str) -> String {
164    let len = s.chars().count();
165    if len <= 6 {
166        return "***".to_owned();
167    }
168
169    let prefix: String = s.chars().take(3).collect();
170    let suffix: String = s.chars().skip(len - 3).collect();
171    format!("{prefix}***{suffix}")
172}
173
174#[cfg(test)]
175mod tests {
176    use serde_json::json;
177
178    use super::*;
179
180    // -- RedactionConfig --
181
182    #[test]
183    fn default_includes_standard_fields() {
184        let config = RedactionConfig::default();
185        assert!(config.should_redact("password"));
186        assert!(config.should_redact("secret"));
187        assert!(config.should_redact("token"));
188        assert!(config.should_redact("api_key"));
189        assert!(config.should_redact("authorization"));
190        assert!(config.should_redact("credit_card"));
191        assert!(config.should_redact("ssn"));
192    }
193
194    #[test]
195    fn default_does_not_redact_normal_fields() {
196        let config = RedactionConfig::default();
197        assert!(!config.should_redact("name"));
198        assert!(!config.should_redact("email"));
199        assert!(!config.should_redact("order_id"));
200    }
201
202    #[test]
203    fn case_insensitive_matching() {
204        let config = RedactionConfig::default();
205        assert!(config.should_redact("PASSWORD"));
206        assert!(config.should_redact("Password"));
207        assert!(config.should_redact("API_KEY"));
208    }
209
210    #[test]
211    fn custom_fields() {
212        let config = RedactionConfig::with_fields(["custom_secret", "my_token"]);
213        assert!(config.should_redact("custom_secret"));
214        assert!(config.should_redact("my_token"));
215        assert!(!config.should_redact("password")); // not in custom set
216    }
217
218    #[test]
219    fn pattern_matching() {
220        let mut config = RedactionConfig::empty();
221        config.add_pattern("key");
222        assert!(config.should_redact("api_key"));
223        assert!(config.should_redact("secret_key_value"));
224        assert!(config.should_redact("KEY_ID"));
225        assert!(!config.should_redact("name"));
226    }
227
228    #[test]
229    fn empty_config_redacts_nothing() {
230        let config = RedactionConfig::empty();
231        assert!(!config.should_redact("password"));
232        assert!(!config.should_redact("anything"));
233    }
234
235    #[test]
236    fn add_field() {
237        let mut config = RedactionConfig::empty();
238        config.add_field("custom");
239        assert!(config.should_redact("custom"));
240    }
241
242    // -- redact_value --
243
244    #[test]
245    fn redact_flat_object() {
246        let config = RedactionConfig::default();
247        let mut value = json!({
248            "name": "Alice",
249            "password": "secret123"
250        });
251
252        redact_value(&mut value, &config);
253        assert_eq!(value["name"], "Alice");
254        assert_eq!(value["password"], "[REDACTED]");
255    }
256
257    #[test]
258    fn redact_nested_object() {
259        let config = RedactionConfig::default();
260        let mut value = json!({
261            "user": {
262                "name": "Bob",
263                "auth": {
264                    "token": "abc123",
265                    "level": "admin"
266                }
267            }
268        });
269
270        redact_value(&mut value, &config);
271        assert_eq!(value["user"]["name"], "Bob");
272        assert_eq!(value["user"]["auth"]["token"], "[REDACTED]");
273        assert_eq!(value["user"]["auth"]["level"], "admin");
274    }
275
276    #[test]
277    fn redact_array_of_objects() {
278        let config = RedactionConfig::default();
279        let mut value = json!([
280            { "name": "A", "token": "x" },
281            { "name": "B", "token": "y" }
282        ]);
283
284        redact_value(&mut value, &config);
285        assert_eq!(value[0]["name"], "A");
286        assert_eq!(value[0]["token"], "[REDACTED]");
287        assert_eq!(value[1]["token"], "[REDACTED]");
288    }
289
290    #[test]
291    fn redact_deeply_nested() {
292        let config = RedactionConfig::default();
293        let mut value = json!({
294            "a": { "b": { "c": { "secret": "deep" } } }
295        });
296
297        redact_value(&mut value, &config);
298        assert_eq!(value["a"]["b"]["c"]["secret"], "[REDACTED]");
299    }
300
301    #[test]
302    fn redact_preserves_non_string_redacted_values() {
303        let config = RedactionConfig::default();
304        let mut value = json!({
305            "token": 12345,
306            "name": "test"
307        });
308
309        redact_value(&mut value, &config);
310        assert_eq!(value["token"], "[REDACTED]");
311    }
312
313    #[test]
314    fn redact_non_object_is_noop() {
315        let config = RedactionConfig::default();
316        let mut value = json!("just a string");
317        redact_value(&mut value, &config);
318        assert_eq!(value, json!("just a string"));
319    }
320
321    #[test]
322    fn redact_null_is_noop() {
323        let config = RedactionConfig::default();
324        let mut value = json!(null);
325        redact_value(&mut value, &config);
326        assert_eq!(value, json!(null));
327    }
328
329    // -- redact_string --
330
331    #[test]
332    fn redact_string_normal() {
333        assert_eq!(redact_string("secret123"), "sec***123");
334    }
335
336    #[test]
337    fn redact_string_long() {
338        assert_eq!(redact_string("abcdefghij"), "abc***hij");
339    }
340
341    #[test]
342    fn redact_string_exactly_seven() {
343        assert_eq!(redact_string("1234567"), "123***567");
344    }
345
346    #[test]
347    fn redact_string_six_chars() {
348        assert_eq!(redact_string("123456"), "***");
349    }
350
351    #[test]
352    fn redact_string_short() {
353        assert_eq!(redact_string("ab"), "***");
354    }
355
356    #[test]
357    fn redact_string_empty() {
358        assert_eq!(redact_string(""), "***");
359    }
360
361    #[test]
362    fn redact_string_single_char() {
363        assert_eq!(redact_string("x"), "***");
364    }
365
366    #[test]
367    fn redact_string_unicode_safe() {
368        assert_eq!(redact_string("абвгдеёж"), "абв***еёж");
369    }
370
371    // -- Serde --
372
373    #[test]
374    fn config_serde_roundtrip() {
375        let config = RedactionConfig::default();
376        let json = serde_json::to_string(&config).unwrap();
377        let parsed: RedactionConfig = serde_json::from_str(&json).unwrap();
378        assert_eq!(parsed.fields().len(), config.fields().len());
379    }
380}