Skip to main content

reinhardt_admin/server/
audit.rs

1//! Audit logging for admin CRUD operations
2//!
3//! This module provides structured audit logging for all administrative
4//! operations (create, update, delete) to support security monitoring
5//! and compliance requirements.
6//!
7//! Audit log entries include:
8//! - Timestamp of the operation
9//! - User identifier (from authentication state)
10//! - Operation type (create, update, delete, bulk_delete)
11//! - Target model and record ID
12//! - Summary of changed fields (for updates)
13
14use std::collections::HashMap;
15use std::fmt;
16
17/// Types of admin operations that are audit-logged.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum AuditAction {
20	/// A new record was created
21	Create,
22	/// An existing record was updated
23	Update,
24	/// A single record was deleted
25	Delete,
26	/// Multiple records were deleted
27	BulkDelete,
28	/// Data was exported
29	Export,
30	/// Data was imported
31	Import,
32}
33
34impl fmt::Display for AuditAction {
35	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36		match self {
37			AuditAction::Create => write!(f, "CREATE"),
38			AuditAction::Update => write!(f, "UPDATE"),
39			AuditAction::Delete => write!(f, "DELETE"),
40			AuditAction::BulkDelete => write!(f, "BULK_DELETE"),
41			AuditAction::Export => write!(f, "EXPORT"),
42			AuditAction::Import => write!(f, "IMPORT"),
43		}
44	}
45}
46
47/// A single audit log entry representing an admin operation.
48#[derive(Debug, Clone)]
49pub struct AuditEntry {
50	/// When the operation occurred (ISO 8601)
51	pub timestamp: String,
52	/// User identifier (user ID or "anonymous")
53	pub user_id: String,
54	/// Type of operation performed
55	pub action: AuditAction,
56	/// Name of the model affected
57	pub model_name: String,
58	/// Primary key of the affected record(s)
59	pub record_id: Option<String>,
60	/// Field names that were modified (for updates)
61	pub changed_fields: Option<Vec<String>>,
62	/// Whether the operation succeeded
63	pub success: bool,
64	/// Number of records affected (for bulk operations)
65	pub affected_count: Option<u64>,
66}
67
68impl fmt::Display for AuditEntry {
69	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70		write!(
71			f,
72			"[ADMIN_AUDIT] {} user={} action={} model={}",
73			self.timestamp, self.user_id, self.action, self.model_name,
74		)?;
75
76		if let Some(ref id) = self.record_id {
77			write!(f, " record_id={}", id)?;
78		}
79
80		if let Some(ref fields) = self.changed_fields {
81			write!(f, " changed_fields=[{}]", fields.join(", "))?;
82		}
83
84		if let Some(count) = self.affected_count {
85			write!(f, " affected={}", count)?;
86		}
87
88		write!(f, " success={}", self.success)
89	}
90}
91
92/// Logs a create operation to the audit trail.
93///
94/// Records that a new record was created, including which fields were set.
95///
96/// # Arguments
97///
98/// * `user_id` - The authenticated user's identifier
99/// * `model_name` - The model being created
100/// * `data` - The fields being set on the new record
101/// * `success` - Whether the operation succeeded
102///
103/// # Examples
104///
105/// ```
106/// use reinhardt_admin::server::audit::log_create;
107/// use std::collections::HashMap;
108///
109/// let mut data = HashMap::new();
110/// data.insert("name".to_string(), serde_json::json!("Alice"));
111/// log_create("user-42", "User", &data, true);
112/// ```
113pub fn log_create(
114	user_id: &str,
115	model_name: &str,
116	data: &HashMap<String, serde_json::Value>,
117	success: bool,
118) {
119	let entry = AuditEntry {
120		timestamp: chrono::Utc::now().to_rfc3339(),
121		user_id: user_id.to_string(),
122		action: AuditAction::Create,
123		model_name: model_name.to_string(),
124		record_id: None,
125		changed_fields: Some(data.keys().cloned().collect()),
126		success,
127		affected_count: if success { Some(1) } else { None },
128	};
129
130	emit_audit_log(&entry);
131}
132
133/// Logs an update operation to the audit trail.
134///
135/// Records that an existing record was updated, including the record ID
136/// and which fields were modified.
137///
138/// # Arguments
139///
140/// * `user_id` - The authenticated user's identifier
141/// * `model_name` - The model being updated
142/// * `record_id` - The primary key of the record being updated
143/// * `data` - The fields being modified
144/// * `success` - Whether the operation succeeded
145///
146/// # Examples
147///
148/// ```
149/// use reinhardt_admin::server::audit::log_update;
150/// use std::collections::HashMap;
151///
152/// let mut data = HashMap::new();
153/// data.insert("email".to_string(), serde_json::json!("new@example.com"));
154/// log_update("user-42", "User", "123", &data, true);
155/// ```
156pub fn log_update(
157	user_id: &str,
158	model_name: &str,
159	record_id: &str,
160	data: &HashMap<String, serde_json::Value>,
161	success: bool,
162) {
163	let entry = AuditEntry {
164		timestamp: chrono::Utc::now().to_rfc3339(),
165		user_id: user_id.to_string(),
166		action: AuditAction::Update,
167		model_name: model_name.to_string(),
168		record_id: Some(record_id.to_string()),
169		changed_fields: Some(data.keys().cloned().collect()),
170		success,
171		affected_count: if success { Some(1) } else { None },
172	};
173
174	emit_audit_log(&entry);
175}
176
177/// Logs a delete operation to the audit trail.
178///
179/// # Arguments
180///
181/// * `user_id` - The authenticated user's identifier
182/// * `model_name` - The model being deleted from
183/// * `record_id` - The primary key of the deleted record
184/// * `success` - Whether the operation succeeded
185///
186/// # Examples
187///
188/// ```
189/// use reinhardt_admin::server::audit::log_delete;
190///
191/// log_delete("user-42", "User", "123", true);
192/// ```
193pub fn log_delete(user_id: &str, model_name: &str, record_id: &str, success: bool) {
194	let entry = AuditEntry {
195		timestamp: chrono::Utc::now().to_rfc3339(),
196		user_id: user_id.to_string(),
197		action: AuditAction::Delete,
198		model_name: model_name.to_string(),
199		record_id: Some(record_id.to_string()),
200		changed_fields: None,
201		success,
202		affected_count: if success { Some(1) } else { None },
203	};
204
205	emit_audit_log(&entry);
206}
207
208/// Logs a bulk delete operation to the audit trail.
209///
210/// # Arguments
211///
212/// * `user_id` - The authenticated user's identifier
213/// * `model_name` - The model being deleted from
214/// * `record_ids` - The primary keys of the deleted records
215/// * `affected` - Number of records actually deleted
216/// * `success` - Whether the operation succeeded
217///
218/// # Examples
219///
220/// ```
221/// use reinhardt_admin::server::audit::log_bulk_delete;
222///
223/// log_bulk_delete("user-42", "User", &["1".to_string(), "2".to_string()], 2, true);
224/// ```
225pub fn log_bulk_delete(
226	user_id: &str,
227	model_name: &str,
228	record_ids: &[String],
229	affected: u64,
230	success: bool,
231) {
232	let entry = AuditEntry {
233		timestamp: chrono::Utc::now().to_rfc3339(),
234		user_id: user_id.to_string(),
235		action: AuditAction::BulkDelete,
236		model_name: model_name.to_string(),
237		record_id: Some(
238			serde_json::to_string(&record_ids).unwrap_or_else(|_| record_ids.join(",")),
239		),
240		changed_fields: None,
241		success,
242		affected_count: Some(affected),
243	};
244
245	emit_audit_log(&entry);
246}
247
248/// Emits an audit log entry via the tracing infrastructure.
249///
250/// Uses `info!` level for successful operations and `warn!` level for failures.
251#[cfg(server)]
252fn emit_audit_log(entry: &AuditEntry) {
253	if entry.success {
254		tracing::info!("{}", entry);
255	} else {
256		tracing::warn!("{}", entry);
257	}
258}
259
260/// No-op audit log on WASM targets (tracing is server-only).
261#[cfg(client)]
262fn emit_audit_log(_entry: &AuditEntry) {}
263
264#[cfg(all(test, server))]
265mod tests {
266	use super::*;
267	use rstest::rstest;
268
269	// ============================================================
270	// AuditAction Display tests
271	// ============================================================
272
273	#[rstest]
274	fn test_audit_action_create_display() {
275		// Assert
276		assert_eq!(AuditAction::Create.to_string(), "CREATE");
277	}
278
279	#[rstest]
280	fn test_audit_action_update_display() {
281		// Assert
282		assert_eq!(AuditAction::Update.to_string(), "UPDATE");
283	}
284
285	#[rstest]
286	fn test_audit_action_delete_display() {
287		// Assert
288		assert_eq!(AuditAction::Delete.to_string(), "DELETE");
289	}
290
291	#[rstest]
292	fn test_audit_action_bulk_delete_display() {
293		// Assert
294		assert_eq!(AuditAction::BulkDelete.to_string(), "BULK_DELETE");
295	}
296
297	#[rstest]
298	fn test_audit_action_export_display() {
299		// Assert
300		assert_eq!(AuditAction::Export.to_string(), "EXPORT");
301	}
302
303	#[rstest]
304	fn test_audit_action_import_display() {
305		// Assert
306		assert_eq!(AuditAction::Import.to_string(), "IMPORT");
307	}
308
309	// ============================================================
310	// AuditEntry Display tests
311	// ============================================================
312
313	#[rstest]
314	fn test_audit_entry_display_create() {
315		// Arrange
316		let entry = AuditEntry {
317			timestamp: "2024-01-01T00:00:00Z".to_string(),
318			user_id: "user-42".to_string(),
319			action: AuditAction::Create,
320			model_name: "User".to_string(),
321			record_id: None,
322			changed_fields: Some(vec!["name".to_string(), "email".to_string()]),
323			success: true,
324			affected_count: Some(1),
325		};
326
327		// Act
328		let output = entry.to_string();
329
330		// Assert
331		assert!(output.contains("[ADMIN_AUDIT]"));
332		assert!(output.contains("user=user-42"));
333		assert!(output.contains("action=CREATE"));
334		assert!(output.contains("model=User"));
335		assert!(output.contains("changed_fields=[name, email]"));
336		assert!(output.contains("success=true"));
337	}
338
339	#[rstest]
340	fn test_audit_entry_display_delete() {
341		// Arrange
342		let entry = AuditEntry {
343			timestamp: "2024-01-01T00:00:00Z".to_string(),
344			user_id: "admin-1".to_string(),
345			action: AuditAction::Delete,
346			model_name: "Post".to_string(),
347			record_id: Some("123".to_string()),
348			changed_fields: None,
349			success: true,
350			affected_count: Some(1),
351		};
352
353		// Act
354		let output = entry.to_string();
355
356		// Assert
357		assert!(output.contains("action=DELETE"));
358		assert!(output.contains("model=Post"));
359		assert!(output.contains("record_id=123"));
360		assert!(output.contains("affected=1"));
361	}
362
363	#[rstest]
364	fn test_audit_entry_display_bulk_delete() {
365		// Arrange
366		let entry = AuditEntry {
367			timestamp: "2024-01-01T00:00:00Z".to_string(),
368			user_id: "admin-1".to_string(),
369			action: AuditAction::BulkDelete,
370			model_name: "Comment".to_string(),
371			record_id: Some("[\"1\",\"2\",\"3\"]".to_string()),
372			changed_fields: None,
373			success: true,
374			affected_count: Some(3),
375		};
376
377		// Act
378		let output = entry.to_string();
379
380		// Assert
381		assert!(output.contains("action=BULK_DELETE"));
382		assert!(output.contains("record_id=[\"1\",\"2\",\"3\"]"));
383		assert!(output.contains("affected=3"));
384	}
385
386	#[rstest]
387	fn test_audit_entry_display_failed_operation() {
388		// Arrange
389		let entry = AuditEntry {
390			timestamp: "2024-01-01T00:00:00Z".to_string(),
391			user_id: "user-99".to_string(),
392			action: AuditAction::Update,
393			model_name: "User".to_string(),
394			record_id: Some("456".to_string()),
395			changed_fields: Some(vec!["password".to_string()]),
396			success: false,
397			affected_count: None,
398		};
399
400		// Act
401		let output = entry.to_string();
402
403		// Assert
404		assert!(output.contains("success=false"));
405		assert!(output.contains("action=UPDATE"));
406	}
407
408	// ============================================================
409	// Log function tests (verify entry construction)
410	// ============================================================
411
412	#[rstest]
413	fn test_log_create_constructs_correct_entry() {
414		// Arrange
415		let mut data = HashMap::new();
416		data.insert("name".to_string(), serde_json::json!("Alice"));
417		data.insert("email".to_string(), serde_json::json!("alice@example.com"));
418
419		// Act - just verify no panic; logging goes to the log infrastructure
420		log_create("user-42", "User", &data, true);
421	}
422
423	#[rstest]
424	fn test_log_update_constructs_correct_entry() {
425		// Arrange
426		let mut data = HashMap::new();
427		data.insert("email".to_string(), serde_json::json!("new@example.com"));
428
429		// Act
430		log_update("user-42", "User", "123", &data, true);
431	}
432
433	#[rstest]
434	fn test_log_delete_constructs_correct_entry() {
435		// Act
436		log_delete("user-42", "User", "123", true);
437	}
438
439	#[rstest]
440	fn test_log_bulk_delete_constructs_correct_entry() {
441		// Arrange
442		let ids = vec!["1".to_string(), "2".to_string(), "3".to_string()];
443
444		// Act - construct the AuditEntry the same way log_bulk_delete does
445		// to verify the JSON array format used for record_id
446		let entry = AuditEntry {
447			timestamp: chrono::Utc::now().to_rfc3339(),
448			user_id: "user-42".to_string(),
449			action: AuditAction::BulkDelete,
450			model_name: "User".to_string(),
451			record_id: Some(serde_json::to_string(&ids).unwrap_or_else(|_| ids.join(","))),
452			changed_fields: None,
453			success: true,
454			affected_count: Some(3),
455		};
456
457		// Assert
458		assert_eq!(entry.record_id, Some("[\"1\",\"2\",\"3\"]".to_string()));
459		assert_eq!(entry.action, AuditAction::BulkDelete);
460		assert!(entry.success);
461	}
462
463	#[rstest]
464	fn test_log_create_with_failure() {
465		// Arrange
466		let data = HashMap::new();
467
468		// Act
469		log_create("user-42", "User", &data, false);
470	}
471
472	// ============================================================
473	// AuditAction equality tests
474	// ============================================================
475
476	#[rstest]
477	fn test_audit_action_equality() {
478		// Assert
479		assert_eq!(AuditAction::Create, AuditAction::Create);
480		assert_ne!(AuditAction::Create, AuditAction::Delete);
481	}
482
483	#[rstest]
484	fn test_audit_action_clone() {
485		// Arrange
486		let action = AuditAction::Update;
487
488		// Act
489		let cloned = action;
490
491		// Assert
492		assert_eq!(action, cloned);
493	}
494}