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
15#[cfg_attr(test, mutants::skip)]
16fn validate_and_prepare_payloads(
17 model_type: &str,
18 event: &str,
19 mut old_values: Option<String>,
20 mut new_values: Option<String>,
21) -> Result<(Option<String>, Option<String>), crate::Error> {
22 const MAX_PAYLOAD_LEN: usize = 5 * 1024 * 1024; 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 Ok((old_values, new_values))
43}
44
45#[cfg_attr(test, mutants::skip)]
46pub async fn log_audit(
47 model_type: &str,
48 model_id: i32,
49 event: &str,
50 old_values: Option<String>,
51 new_values: Option<String>,
52) -> Result<(), crate::Error> {
53 let (old_values, new_values) =
54 validate_and_prepare_payloads(model_type, event, old_values, new_values)?;
55
56 let pool = Orm::pool();
57 let driver = Orm::driver();
58
59 if driver == "postgres" {
60 sqlx::query(
61 "INSERT INTO rullst_audits (model_type, model_id, event, old_values, new_values) VALUES ($1, $2, $3, $4, $5)"
62 )
63 .bind(model_type)
64 .bind(model_id)
65 .bind(event)
66 .bind(old_values)
67 .bind(new_values)
68 .execute(pool)
69 .await?;
70 } else {
71 sqlx::query(
72 "INSERT INTO rullst_audits (model_type, model_id, event, old_values, new_values) VALUES (?, ?, ?, ?, ?)"
73 )
74 .bind(model_type)
75 .bind(model_id)
76 .bind(event)
77 .bind(old_values)
78 .bind(new_values)
79 .execute(pool)
80 .await?;
81 }
82
83 Ok(())
84}
85
86fn is_sensitive(key: &str) -> bool {
87 let k = key.to_lowercase();
88 k.contains("password")
89 || k.contains("token")
90 || k.contains("secret")
91 || k.contains("senha")
92 || k.contains("api_key")
93 || k.contains("cvv")
94 || k.contains("ssn")
95 || k.contains("credit_card")
96 || k.contains("auth_code")
97}
98
99fn mask_if_sensitive(key: &str, value: serde_json::Value) -> serde_json::Value {
100 if is_sensitive(key) {
101 serde_json::Value::String("***".to_string())
102 } else {
103 value
104 }
105}
106
107#[cfg_attr(test, mutants::skip)]
108pub fn compute_diff(old_json: &str, new_json: &str) -> (Option<String>, Option<String>) {
109 if old_json == new_json {
110 return (None, None);
111 }
112
113 let old_val: serde_json::Value =
114 serde_json::from_str(old_json).unwrap_or(serde_json::Value::Null);
115 let new_val: serde_json::Value =
116 serde_json::from_str(new_json).unwrap_or(serde_json::Value::Null);
117
118 let mut diff_old = serde_json::Map::new();
119 let mut diff_new = serde_json::Map::new();
120
121 if let (serde_json::Value::Object(old_obj), serde_json::Value::Object(mut new_obj)) =
122 (old_val, new_val)
123 {
124 for (k, v) in old_obj {
125 if let Some(new_v) = new_obj.remove(&k) {
126 #[allow(clippy::collapsible_if)]
127 if v != new_v {
128 let masked_v = mask_if_sensitive(&k, v);
129 let masked_new_v = mask_if_sensitive(&k, new_v);
130 diff_new.insert(k.clone(), masked_new_v);
131 diff_old.insert(k, masked_v);
132 }
133 } else {
134 let masked_v = mask_if_sensitive(&k, v);
135 diff_new.insert(k.clone(), serde_json::Value::Null);
136 diff_old.insert(k, masked_v);
137 }
138 }
139 for (k, new_v) in new_obj {
140 let masked_new_v = mask_if_sensitive(&k, new_v);
141 diff_old.insert(k.clone(), serde_json::Value::Null);
142 diff_new.insert(k, masked_new_v);
143 }
144 }
145
146 if diff_old.is_empty() && diff_new.is_empty() {
147 return (None, None); }
149
150 let final_old = serde_json::to_string(&diff_old).ok();
151 let final_new = serde_json::to_string(&diff_new).ok();
152
153 (final_old, final_new)
154}
155
156#[cfg_attr(test, mutants::skip)]
157pub async fn log_audit_diff(
158 model_type: &str,
159 model_id: i32,
160 event: &str,
161 old_json: &str,
162 new_json: &str,
163) -> Result<(), crate::Error> {
164 const MAX_PAYLOAD_LEN: usize = 5 * 1024 * 1024; if old_json.len() > MAX_PAYLOAD_LEN || new_json.len() > MAX_PAYLOAD_LEN {
167 return log_audit(
168 model_type,
169 model_id,
170 event,
171 Some(r#"{"error":"payload_too_large_for_diff"}"#.to_string()),
172 Some(r#"{"error":"payload_too_large_for_diff"}"#.to_string()),
173 )
174 .await;
175 }
176
177 let (final_old, final_new) = compute_diff(old_json, new_json);
178 if final_old.is_none() && final_new.is_none() {
179 return Ok(()); }
181 log_audit(model_type, model_id, event, final_old, final_new).await
182}
183
184#[cfg_attr(test, mutants::skip)]
185pub async fn create_audit_table() -> Result<(), crate::Error> {
186 let pool = Orm::pool();
187 let driver = Orm::driver();
188
189 let query = if driver == "postgres" {
190 r#"
191 CREATE TABLE IF NOT EXISTS rullst_audits (
192 id SERIAL PRIMARY KEY,
193 model_type VARCHAR(255) NOT NULL,
194 model_id INT NOT NULL,
195 event VARCHAR(50) NOT NULL,
196 old_values TEXT,
197 new_values TEXT,
198 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
199 )
200 "#
201 } else if driver == "mysql" {
202 r#"
203 CREATE TABLE IF NOT EXISTS rullst_audits (
204 id INT AUTO_INCREMENT PRIMARY KEY,
205 model_type VARCHAR(255) NOT NULL,
206 model_id INT NOT NULL,
207 event VARCHAR(50) NOT NULL,
208 old_values TEXT,
209 new_values TEXT,
210 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
211 )
212 "#
213 } else {
214 r#"
215 CREATE TABLE IF NOT EXISTS rullst_audits (
216 id INTEGER PRIMARY KEY AUTOINCREMENT,
217 model_type TEXT NOT NULL,
218 model_id INTEGER NOT NULL,
219 event TEXT NOT NULL,
220 old_values TEXT,
221 new_values TEXT,
222 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
223 )
224 "#
225 };
226
227 sqlx::query(query).execute(pool).await?;
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::AuditLog;
234
235 #[test]
236 fn test_audit_log_serialization_round_trip() {
237 let log = AuditLog {
238 id: 1,
239 model_type: "User".to_string(),
240 model_id: 42,
241 event: "created".to_string(),
242 old_values: None,
243 new_values: Some(r#"{"name":"Alice"}"#.to_string()),
244 created_at: Some("2024-01-01T00:00:00Z".to_string()),
245 };
246
247 let json_str = serde_json::to_string(&log).expect("serialize");
248 assert!(json_str.contains("\"model_type\":\"User\""));
249 assert!(json_str.contains("\"event\":\"created\""));
250
251 let deserialized: AuditLog = serde_json::from_str(&json_str).expect("deserialize");
252 assert_eq!(deserialized.id, 1);
253 assert_eq!(deserialized.model_id, 42);
254 assert_eq!(deserialized.event, "created");
255 assert!(deserialized.old_values.is_none());
256 }
257
258 #[test]
259 fn test_audit_log_clone_debug() {
260 let log = AuditLog {
261 id: 5,
262 model_type: "Post".to_string(),
263 model_id: 99,
264 event: "updated".to_string(),
265 old_values: Some(r#"{"title":"Old"}"#.to_string()),
266 new_values: Some(r#"{"title":"New"}"#.to_string()),
267 created_at: None,
268 };
269 let cloned = log.clone();
270 assert_eq!(cloned.model_type, "Post");
271 let _ = format!("{:?}", cloned);
273 }
274
275 #[test]
276 fn test_compute_diff_changes() {
277 let old_json = r#"{"name":"Alice","age":30}"#;
278 let new_json = r#"{"name":"Alice","age":31}"#;
279 let (old_diff, new_diff) = super::compute_diff(old_json, new_json);
280 assert_eq!(old_diff.unwrap(), r#"{"age":30}"#);
281 assert_eq!(new_diff.unwrap(), r#"{"age":31}"#);
282 }
283
284 #[test]
285 fn test_compute_diff_no_changes() {
286 let json = r#"{"name":"Alice","age":30}"#;
287 let (old_diff, new_diff) = super::compute_diff(json, json);
288 assert!(old_diff.is_none());
289 assert!(new_diff.is_none());
290 }
291
292 #[test]
293 fn test_compute_diff_invalid_json() {
294 let (old_diff, new_diff) = super::compute_diff("not json", "{invalid}");
295 assert!(old_diff.is_none());
296 assert!(new_diff.is_none());
297 }
298
299 #[tokio::test]
300 async fn test_log_audit_diff_bypass() {
301 let result = super::log_audit_diff(
303 "User",
304 1,
305 "update",
306 r#"{"name":"Alice"}"#,
307 r#"{"name":"Alice"}"#,
308 )
309 .await;
310 assert!(result.is_ok());
311 }
312
313 #[test]
314 fn test_compute_diff_explicit_null_vs_omitted() {
315 let old_json = r#"{"name":"Alice","age":30}"#;
316 let new_json = r#"{"name":"Alice"}"#;
317 let (old_diff, new_diff) = super::compute_diff(old_json, new_json);
318 assert_eq!(old_diff.unwrap(), r#"{"age":30}"#);
319 assert_eq!(new_diff.unwrap(), r#"{"age":null}"#);
320
321 let old_json2 = r#"{"name":"Alice"}"#;
322 let new_json2 = r#"{"name":"Alice","age":null}"#;
323 let (old_diff2, new_diff2) = super::compute_diff(old_json2, new_json2);
324 assert_eq!(old_diff2.unwrap(), r#"{"age":null}"#);
325 assert_eq!(new_diff2.unwrap(), r#"{"age":null}"#);
326
327 let old_json3 = r#"{"name":"Alice"}"#;
328 let new_json3 = r#"{"name":"Alice","age":30}"#;
329 let (old_diff3, new_diff3) = super::compute_diff(old_json3, new_json3);
330 assert_eq!(old_diff3.unwrap(), r#"{"age":null}"#);
331 assert_eq!(new_diff3.unwrap(), r#"{"age":30}"#);
332 }
333
334 #[test]
335 fn test_validate_and_prepare_payloads() {
336 let res = super::validate_and_prepare_payloads(
338 "User",
339 "create",
340 Some("old".to_string()),
341 Some("new".to_string()),
342 );
343 assert!(res.is_ok());
344
345 let long_model = "A".repeat(256);
347 let res = super::validate_and_prepare_payloads(&long_model, "create", None, None);
348 assert!(res.is_err());
349
350 let long_event = "A".repeat(51);
352 let res = super::validate_and_prepare_payloads("User", &long_event, None, None);
353 assert!(res.is_err());
354
355 let large_payload = Some("A".repeat(5 * 1024 * 1024 + 1));
357 let (old_val, new_val) = super::validate_and_prepare_payloads(
358 "User",
359 "create",
360 large_payload.clone(),
361 large_payload,
362 )
363 .unwrap();
364 assert_eq!(old_val.unwrap(), r#"{"error":"payload_too_large"}"#);
365 assert_eq!(new_val.unwrap(), r#"{"error":"payload_too_large"}"#);
366 }
367
368 #[test]
369 fn test_is_sensitive() {
370 assert!(super::is_sensitive("password"));
371 assert!(super::is_sensitive("PASSWORD"));
372 assert!(super::is_sensitive("user_token"));
373 assert!(super::is_sensitive("client_secret"));
374 assert!(super::is_sensitive("senha"));
375 assert!(super::is_sensitive("api_key"));
376 assert!(super::is_sensitive("card_cvv"));
377 assert!(super::is_sensitive("ssn_number"));
378 assert!(super::is_sensitive("credit_card"));
379 assert!(super::is_sensitive("auth_code"));
380 assert!(!super::is_sensitive("name"));
381 assert!(!super::is_sensitive("email"));
382 assert!(!super::is_sensitive("age"));
383 }
384
385 #[test]
386 fn test_mask_if_sensitive() {
387 let val = serde_json::Value::String("my-secret-val".to_string());
388 let masked = super::mask_if_sensitive("password", val.clone());
389 assert_eq!(masked, serde_json::Value::String("***".to_string()));
390
391 let unmasked = super::mask_if_sensitive("username", val);
392 assert_eq!(
393 unmasked,
394 serde_json::Value::String("my-secret-val".to_string())
395 );
396 }
397}
398
399#[cfg(kani)]
400mod kani_proofs {
401 use super::*;
402
403 #[cfg_attr(test, mutants::skip)]
404 #[kani::proof]
405 #[kani::unwind(10)]
406 fn proof_is_sensitive_never_panics() {
407 let mut bytes: [u8; 10] = kani::any();
409 if let Ok(s) = std::str::from_utf8(&bytes) {
410 let _ = is_sensitive(s);
412 }
413 }
414}