koprogo_api/infrastructure/
audit.rs

1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Audit log event types
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub enum AuditEventType {
8    // Authentication events
9    UserLogin,
10    UserLogout,
11    UserRegistration,
12    TokenRefresh,
13    AuthenticationFailed,
14
15    // Data modification events
16    BuildingCreated,
17    BuildingUpdated,
18    BuildingDeleted,
19    UnitCreated,
20    UnitAssignedToOwner,
21    OwnerCreated,
22    OwnerUpdated,
23    ExpenseCreated,
24    ExpenseMarkedPaid,
25    MeetingCreated,
26    MeetingCompleted,
27    DocumentUploaded,
28    DocumentDeleted,
29
30    // Security events
31    UnauthorizedAccess,
32    RateLimitExceeded,
33    InvalidToken,
34}
35
36/// Audit log entry
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AuditLogEntry {
39    /// Unique ID for this audit entry
40    pub id: Uuid,
41    /// Timestamp of the event
42    pub timestamp: chrono::DateTime<chrono::Utc>,
43    /// Type of event
44    pub event_type: AuditEventType,
45    /// User ID who performed the action (if authenticated)
46    pub user_id: Option<Uuid>,
47    /// Organization ID (for multi-tenant isolation)
48    pub organization_id: Option<Uuid>,
49    /// Resource type affected (e.g., "Building", "Unit")
50    pub resource_type: Option<String>,
51    /// Resource ID affected
52    pub resource_id: Option<Uuid>,
53    /// IP address of the client
54    pub ip_address: Option<String>,
55    /// User agent string
56    pub user_agent: Option<String>,
57    /// Additional metadata as JSON
58    pub metadata: Option<serde_json::Value>,
59    /// Success or failure
60    pub success: bool,
61    /// Error message if failed
62    pub error_message: Option<String>,
63}
64
65impl AuditLogEntry {
66    /// Create a new audit log entry
67    pub fn new(
68        event_type: AuditEventType,
69        user_id: Option<Uuid>,
70        organization_id: Option<Uuid>,
71    ) -> Self {
72        Self {
73            id: Uuid::new_v4(),
74            timestamp: Utc::now(),
75            event_type,
76            user_id,
77            organization_id,
78            resource_type: None,
79            resource_id: None,
80            ip_address: None,
81            user_agent: None,
82            metadata: None,
83            success: true,
84            error_message: None,
85        }
86    }
87
88    /// Set resource information
89    pub fn with_resource(mut self, resource_type: &str, resource_id: Uuid) -> Self {
90        self.resource_type = Some(resource_type.to_string());
91        self.resource_id = Some(resource_id);
92        self
93    }
94
95    /// Set client information
96    pub fn with_client_info(
97        mut self,
98        ip_address: Option<String>,
99        user_agent: Option<String>,
100    ) -> Self {
101        self.ip_address = ip_address;
102        self.user_agent = user_agent;
103        self
104    }
105
106    /// Set metadata
107    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
108        self.metadata = Some(metadata);
109        self
110    }
111
112    /// Mark as failed with error message
113    pub fn with_error(mut self, error_message: String) -> Self {
114        self.success = false;
115        self.error_message = Some(error_message);
116        self
117    }
118
119    /// Log this entry (currently to stdout, can be extended to database/file)
120    pub fn log(&self) {
121        // Redact sensitive information for logging (GDPR compliance)
122        let redacted_ip = self.ip_address.as_ref().map(|_| "[REDACTED]");
123        let redacted_error = self.error_message.as_ref().map(|_| "[REDACTED]");
124
125        let log_message = format!(
126            "[AUDIT] {} | {:?} | User: {:?} | Org: {:?} | Resource: {:?}/{:?} | Success: {} | IP: {:?}",
127            self.timestamp.format("%Y-%m-%d %H:%M:%S"),
128            self.event_type,
129            self.user_id,
130            self.organization_id,
131            self.resource_type,
132            self.resource_id,
133            self.success,
134            redacted_ip
135        );
136
137        if self.success {
138            log::info!("{}", log_message);
139        } else {
140            log::warn!("{} | Error: {:?}", log_message, redacted_error);
141        }
142
143        // TODO: In production, write full (unredacted) audit data to:
144        // - Database table (audit_logs) with encryption at rest
145        // - Rotating log files in secure location with restricted access
146        // - SIEM system (Security Information and Event Management)
147        // Note: Full audit data (including IP, error messages) should only be
148        // stored in secure, access-controlled systems for compliance and forensics
149    }
150}
151
152/// Helper macro to create and log audit entries
153#[macro_export]
154macro_rules! audit_log {
155    ($event_type:expr, $user_id:expr, $org_id:expr) => {
156        $crate::infrastructure::audit::AuditLogEntry::new($event_type, $user_id, $org_id).log()
157    };
158    ($event_type:expr, $user_id:expr, $org_id:expr, $resource_type:expr, $resource_id:expr) => {
159        $crate::infrastructure::audit::AuditLogEntry::new($event_type, $user_id, $org_id)
160            .with_resource($resource_type, $resource_id)
161            .log()
162    };
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_audit_log_creation() {
171        let user_id = Uuid::new_v4();
172        let org_id = Uuid::new_v4();
173        let building_id = Uuid::new_v4();
174
175        let entry =
176            AuditLogEntry::new(AuditEventType::BuildingCreated, Some(user_id), Some(org_id))
177                .with_resource("Building", building_id)
178                .with_client_info(Some("192.168.1.1".to_string()), None);
179
180        assert_eq!(entry.user_id, Some(user_id));
181        assert_eq!(entry.organization_id, Some(org_id));
182        assert_eq!(entry.resource_id, Some(building_id));
183        assert!(entry.success);
184    }
185
186    #[test]
187    fn test_audit_log_with_error() {
188        let entry = AuditLogEntry::new(AuditEventType::AuthenticationFailed, None, None)
189            .with_error("Invalid credentials".to_string());
190
191        assert!(!entry.success);
192        assert_eq!(entry.error_message, Some("Invalid credentials".to_string()));
193    }
194}