Skip to main content

rullst_orm/
audit.rs

1use crate::Orm;
2use serde::{Deserialize, Serialize};
3
4#[derive(Clone, Debug, Serialize, Deserialize)]
5pub struct AuditLog {
6    pub id: i32,
7    pub model_type: String,
8    pub model_id: i32,
9    pub event: String,
10    pub old_values: Option<String>,
11    pub new_values: Option<String>,
12    pub created_at: Option<String>,
13}
14
15pub async fn log_audit(
16    model_type: &str,
17    model_id: i32,
18    event: &str,
19    mut old_values: Option<String>,
20    mut new_values: Option<String>,
21) -> Result<(), crate::Error> {
22    const MAX_PAYLOAD_LEN: usize = 5 * 1024 * 1024; // 5 MB
23
24    if model_type.len() > 255 || event.len() > 50 {
25        return Err(crate::Error::Validation(
26            "Audit model_type or event string too long".to_string(),
27        ));
28    }
29
30    if let Some(val) = &old_values
31        && val.len() > MAX_PAYLOAD_LEN
32    {
33        old_values = Some(r#"{"error":"payload_too_large"}"#.to_string());
34    }
35
36    if let Some(val) = &new_values
37        && val.len() > MAX_PAYLOAD_LEN
38    {
39        new_values = Some(r#"{"error":"payload_too_large"}"#.to_string());
40    }
41
42    let pool = Orm::pool();
43    let driver = Orm::driver();
44
45    if driver == "postgres" {
46        sqlx::query(
47            "INSERT INTO rullst_audits (model_type, model_id, event, old_values, new_values) VALUES ($1, $2, $3, $4, $5)"
48        )
49        .bind(model_type)
50        .bind(model_id)
51        .bind(event)
52        .bind(old_values)
53        .bind(new_values)
54        .execute(pool)
55        .await?;
56    } else {
57        sqlx::query(
58            "INSERT INTO rullst_audits (model_type, model_id, event, old_values, new_values) VALUES (?, ?, ?, ?, ?)"
59        )
60        .bind(model_type)
61        .bind(model_id)
62        .bind(event)
63        .bind(old_values)
64        .bind(new_values)
65        .execute(pool)
66        .await?;
67    }
68
69    Ok(())
70}
71
72pub fn compute_diff(old_json: &str, new_json: &str) -> (Option<String>, Option<String>) {
73    if old_json == new_json {
74        return (None, None);
75    }
76
77    let old_val: serde_json::Value =
78        serde_json::from_str(old_json).unwrap_or(serde_json::Value::Null);
79    let new_val: serde_json::Value =
80        serde_json::from_str(new_json).unwrap_or(serde_json::Value::Null);
81
82    let mut diff_old = serde_json::Map::new();
83    let mut diff_new = serde_json::Map::new();
84
85    fn is_sensitive(key: &str) -> bool {
86        let k = key.to_lowercase();
87        k.contains("password")
88            || k.contains("token")
89            || k.contains("secret")
90            || k.contains("senha")
91            || k.contains("api_key")
92            || k.contains("cvv")
93            || k.contains("ssn")
94            || k.contains("credit_card")
95            || k.contains("auth_code")
96    }
97
98    fn mask_if_sensitive(key: &str, value: serde_json::Value) -> serde_json::Value {
99        if is_sensitive(key) {
100            serde_json::Value::String("***".to_string())
101        } else {
102            value
103        }
104    }
105
106    if let (serde_json::Value::Object(old_obj), serde_json::Value::Object(mut new_obj)) =
107        (old_val, new_val)
108    {
109        for (k, v) in old_obj {
110            if let Some(new_v) = new_obj.remove(&k) {
111                #[allow(clippy::collapsible_if)]
112                if v != new_v {
113                    diff_new.insert(k.clone(), mask_if_sensitive(&k, new_v));
114                    diff_old.insert(k.clone(), mask_if_sensitive(&k, v));
115                }
116            } else {
117                diff_new.insert(k.clone(), serde_json::Value::Null);
118                diff_old.insert(k.clone(), mask_if_sensitive(&k, v));
119            }
120        }
121        for (k, new_v) in new_obj {
122            diff_new.insert(k.clone(), mask_if_sensitive(&k, new_v));
123            diff_old.insert(k, serde_json::Value::Null);
124        }
125    }
126
127    if diff_old.is_empty() && diff_new.is_empty() {
128        return (None, None); // Nothing changed
129    }
130
131    let final_old = serde_json::to_string(&diff_old).ok();
132    let final_new = serde_json::to_string(&diff_new).ok();
133
134    (final_old, final_new)
135}
136
137pub async fn log_audit_diff(
138    model_type: &str,
139    model_id: i32,
140    event: &str,
141    old_json: &str,
142    new_json: &str,
143) -> Result<(), crate::Error> {
144    const MAX_PAYLOAD_LEN: usize = 5 * 1024 * 1024; // 5 MB
145
146    if old_json.len() > MAX_PAYLOAD_LEN || new_json.len() > MAX_PAYLOAD_LEN {
147        return log_audit(
148            model_type,
149            model_id,
150            event,
151            Some(r#"{"error":"payload_too_large_for_diff"}"#.to_string()),
152            Some(r#"{"error":"payload_too_large_for_diff"}"#.to_string()),
153        )
154        .await;
155    }
156
157    let (final_old, final_new) = compute_diff(old_json, new_json);
158    if final_old.is_none() && final_new.is_none() {
159        return Ok(()); // Nothing changed
160    }
161    log_audit(model_type, model_id, event, final_old, final_new).await
162}
163
164pub async fn create_audit_table() -> Result<(), crate::Error> {
165    let pool = Orm::pool();
166    let driver = Orm::driver();
167
168    let query = if driver == "postgres" {
169        r#"
170        CREATE TABLE IF NOT EXISTS rullst_audits (
171            id SERIAL PRIMARY KEY,
172            model_type VARCHAR(255) NOT NULL,
173            model_id INT NOT NULL,
174            event VARCHAR(50) NOT NULL,
175            old_values TEXT,
176            new_values TEXT,
177            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
178        )
179        "#
180    } else if driver == "mysql" {
181        r#"
182        CREATE TABLE IF NOT EXISTS rullst_audits (
183            id INT AUTO_INCREMENT PRIMARY KEY,
184            model_type VARCHAR(255) NOT NULL,
185            model_id INT NOT NULL,
186            event VARCHAR(50) NOT NULL,
187            old_values TEXT,
188            new_values TEXT,
189            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
190        )
191        "#
192    } else {
193        r#"
194        CREATE TABLE IF NOT EXISTS rullst_audits (
195            id INTEGER PRIMARY KEY AUTOINCREMENT,
196            model_type TEXT NOT NULL,
197            model_id INTEGER NOT NULL,
198            event TEXT NOT NULL,
199            old_values TEXT,
200            new_values TEXT,
201            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
202        )
203        "#
204    };
205
206    sqlx::query(query).execute(pool).await?;
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::AuditLog;
213
214    #[test]
215    fn test_audit_log_serialization_round_trip() {
216        let log = AuditLog {
217            id: 1,
218            model_type: "User".to_string(),
219            model_id: 42,
220            event: "created".to_string(),
221            old_values: None,
222            new_values: Some(r#"{"name":"Alice"}"#.to_string()),
223            created_at: Some("2024-01-01T00:00:00Z".to_string()),
224        };
225
226        let json_str = serde_json::to_string(&log).expect("serialize");
227        assert!(json_str.contains("\"model_type\":\"User\""));
228        assert!(json_str.contains("\"event\":\"created\""));
229
230        let deserialized: AuditLog = serde_json::from_str(&json_str).expect("deserialize");
231        assert_eq!(deserialized.id, 1);
232        assert_eq!(deserialized.model_id, 42);
233        assert_eq!(deserialized.event, "created");
234        assert!(deserialized.old_values.is_none());
235    }
236
237    #[test]
238    fn test_audit_log_clone_debug() {
239        let log = AuditLog {
240            id: 5,
241            model_type: "Post".to_string(),
242            model_id: 99,
243            event: "updated".to_string(),
244            old_values: Some(r#"{"title":"Old"}"#.to_string()),
245            new_values: Some(r#"{"title":"New"}"#.to_string()),
246            created_at: None,
247        };
248        let cloned = log.clone();
249        assert_eq!(cloned.model_type, "Post");
250        // Debug must not panic
251        let _ = format!("{:?}", cloned);
252    }
253
254    #[test]
255    fn test_compute_diff_changes() {
256        let old_json = r#"{"name":"Alice","age":30}"#;
257        let new_json = r#"{"name":"Alice","age":31}"#;
258        let (old_diff, new_diff) = super::compute_diff(old_json, new_json);
259        assert_eq!(old_diff.unwrap(), r#"{"age":30}"#);
260        assert_eq!(new_diff.unwrap(), r#"{"age":31}"#);
261    }
262
263    #[test]
264    fn test_compute_diff_no_changes() {
265        let json = r#"{"name":"Alice","age":30}"#;
266        let (old_diff, new_diff) = super::compute_diff(json, json);
267        assert!(old_diff.is_none());
268        assert!(new_diff.is_none());
269    }
270
271    #[test]
272    fn test_compute_diff_invalid_json() {
273        let (old_diff, new_diff) = super::compute_diff("not json", "{invalid}");
274        assert!(old_diff.is_none());
275        assert!(new_diff.is_none());
276    }
277
278    #[tokio::test]
279    async fn test_log_audit_diff_bypass() {
280        // Should not panic or hit the database if the old and new JSONs are identical
281        let result = super::log_audit_diff(
282            "User",
283            1,
284            "update",
285            r#"{"name":"Alice"}"#,
286            r#"{"name":"Alice"}"#,
287        )
288        .await;
289        assert!(result.is_ok());
290    }
291
292    #[test]
293    fn test_compute_diff_explicit_null_vs_omitted() {
294        let old_json = r#"{"name":"Alice","age":30}"#;
295        let new_json = r#"{"name":"Alice"}"#;
296        let (old_diff, new_diff) = super::compute_diff(old_json, new_json);
297        assert_eq!(old_diff.unwrap(), r#"{"age":30}"#);
298        assert_eq!(new_diff.unwrap(), r#"{"age":null}"#);
299
300        let old_json2 = r#"{"name":"Alice"}"#;
301        let new_json2 = r#"{"name":"Alice","age":null}"#;
302        let (old_diff2, new_diff2) = super::compute_diff(old_json2, new_json2);
303        assert_eq!(old_diff2.unwrap(), r#"{"age":null}"#);
304        assert_eq!(new_diff2.unwrap(), r#"{"age":null}"#);
305
306        let old_json3 = r#"{"name":"Alice"}"#;
307        let new_json3 = r#"{"name":"Alice","age":30}"#;
308        let (old_diff3, new_diff3) = super::compute_diff(old_json3, new_json3);
309        assert_eq!(old_diff3.unwrap(), r#"{"age":null}"#);
310        assert_eq!(new_diff3.unwrap(), r#"{"age":30}"#);
311    }
312}