Skip to main content

enact_security/
audit.rs

1//! Audit logging for security events
2//!
3//! Provides structured logging of security-relevant events with rotation support.
4
5use anyhow::Result;
6use chrono::{DateTime, Utc};
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9use std::fs::OpenOptions;
10use std::io::Write;
11use std::path::PathBuf;
12use uuid::Uuid;
13
14/// Audit event types
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AuditEventType {
18    CommandExecution,
19    FileAccess,
20    ConfigChange,
21    AuthSuccess,
22    AuthFailure,
23    PolicyViolation,
24    SecurityEvent,
25    ToolInvocation,
26    AgentAction,
27}
28
29/// Actor information (who performed the action)
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Actor {
32    pub channel: String,
33    pub user_id: Option<String>,
34    pub username: Option<String>,
35    pub session_id: Option<String>,
36}
37
38/// Action information (what was done)
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Action {
41    pub command: Option<String>,
42    pub tool_name: Option<String>,
43    pub risk_level: Option<String>,
44    pub approved: bool,
45    pub allowed: bool,
46}
47
48/// Execution result
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ExecutionResult {
51    pub success: bool,
52    pub exit_code: Option<i32>,
53    pub duration_ms: Option<u64>,
54    pub error: Option<String>,
55}
56
57/// Security context
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct SecurityContext {
60    pub policy_violation: bool,
61    pub rate_limit_remaining: Option<u32>,
62    pub autonomy_level: Option<String>,
63    pub sandbox_backend: Option<String>,
64}
65
66/// Complete audit event
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct AuditEvent {
69    pub timestamp: DateTime<Utc>,
70    pub event_id: String,
71    pub event_type: AuditEventType,
72    pub actor: Option<Actor>,
73    pub action: Option<Action>,
74    pub result: Option<ExecutionResult>,
75    pub security: SecurityContext,
76}
77
78impl AuditEvent {
79    /// Create a new audit event
80    pub fn new(event_type: AuditEventType) -> Self {
81        Self {
82            timestamp: Utc::now(),
83            event_id: Uuid::new_v4().to_string(),
84            event_type,
85            actor: None,
86            action: None,
87            result: None,
88            security: SecurityContext::default(),
89        }
90    }
91
92    /// Set the actor
93    pub fn with_actor(mut self, actor: Actor) -> Self {
94        self.actor = Some(actor);
95        self
96    }
97
98    /// Set the action
99    pub fn with_action(mut self, action: Action) -> Self {
100        self.action = Some(action);
101        self
102    }
103
104    /// Set the result
105    pub fn with_result(mut self, result: ExecutionResult) -> Self {
106        self.result = Some(result);
107        self
108    }
109
110    /// Set security context
111    pub fn with_security(mut self, security: SecurityContext) -> Self {
112        self.security = security;
113        self
114    }
115
116    /// Mark as policy violation
117    pub fn as_violation(mut self) -> Self {
118        self.security.policy_violation = true;
119        self
120    }
121}
122
123/// Audit logger configuration
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AuditConfig {
126    pub enabled: bool,
127    pub log_path: String,
128    pub max_size_mb: u32,
129    pub retain_days: u32,
130}
131
132impl Default for AuditConfig {
133    fn default() -> Self {
134        Self {
135            enabled: true,
136            log_path: "audit.log".to_string(),
137            max_size_mb: 100,
138            retain_days: 90,
139        }
140    }
141}
142
143/// Audit logger with rotation support
144pub struct AuditLogger {
145    log_path: PathBuf,
146    config: AuditConfig,
147    #[allow(dead_code)] // reserved for batching/flush
148    buffer: Mutex<Vec<AuditEvent>>,
149}
150
151impl AuditLogger {
152    /// Create a new audit logger
153    pub fn new(config: AuditConfig, workspace_dir: PathBuf) -> Result<Self> {
154        let log_path = workspace_dir.join(&config.log_path);
155        Ok(Self {
156            log_path,
157            config,
158            buffer: Mutex::new(Vec::new()),
159        })
160    }
161
162    /// Log an event
163    pub fn log(&self, event: &AuditEvent) -> Result<()> {
164        if !self.config.enabled {
165            return Ok(());
166        }
167
168        self.rotate_if_needed()?;
169
170        let line = serde_json::to_string(event)?;
171        let mut file = OpenOptions::new()
172            .create(true)
173            .append(true)
174            .open(&self.log_path)?;
175
176        writeln!(file, "{}", line)?;
177        file.sync_all()?;
178
179        Ok(())
180    }
181
182    /// Log a command execution
183    #[allow(clippy::too_many_arguments)]
184    pub fn log_command(
185        &self,
186        channel: &str,
187        command: &str,
188        risk_level: &str,
189        approved: bool,
190        allowed: bool,
191        success: bool,
192        duration_ms: u64,
193    ) -> Result<()> {
194        let event = AuditEvent::new(AuditEventType::CommandExecution)
195            .with_actor(Actor {
196                channel: channel.to_string(),
197                user_id: None,
198                username: None,
199                session_id: None,
200            })
201            .with_action(Action {
202                command: Some(command.to_string()),
203                tool_name: None,
204                risk_level: Some(risk_level.to_string()),
205                approved,
206                allowed,
207            })
208            .with_result(ExecutionResult {
209                success,
210                exit_code: None,
211                duration_ms: Some(duration_ms),
212                error: None,
213            });
214
215        self.log(&event)
216    }
217
218    /// Log a tool invocation
219    pub fn log_tool(
220        &self,
221        channel: &str,
222        tool_name: &str,
223        allowed: bool,
224        success: bool,
225        duration_ms: u64,
226        error: Option<&str>,
227    ) -> Result<()> {
228        let event = AuditEvent::new(AuditEventType::ToolInvocation)
229            .with_actor(Actor {
230                channel: channel.to_string(),
231                user_id: None,
232                username: None,
233                session_id: None,
234            })
235            .with_action(Action {
236                command: None,
237                tool_name: Some(tool_name.to_string()),
238                risk_level: None,
239                approved: true,
240                allowed,
241            })
242            .with_result(ExecutionResult {
243                success,
244                exit_code: None,
245                duration_ms: Some(duration_ms),
246                error: error.map(String::from),
247            });
248
249        self.log(&event)
250    }
251
252    /// Log a policy violation
253    pub fn log_violation(&self, channel: &str, description: &str) -> Result<()> {
254        let event = AuditEvent::new(AuditEventType::PolicyViolation)
255            .with_actor(Actor {
256                channel: channel.to_string(),
257                user_id: None,
258                username: None,
259                session_id: None,
260            })
261            .with_action(Action {
262                command: Some(description.to_string()),
263                tool_name: None,
264                risk_level: Some("high".to_string()),
265                approved: false,
266                allowed: false,
267            })
268            .as_violation();
269
270        self.log(&event)
271    }
272
273    /// Check if rotation is needed
274    fn rotate_if_needed(&self) -> Result<()> {
275        if let Ok(metadata) = std::fs::metadata(&self.log_path) {
276            let current_size_mb = metadata.len() / (1024 * 1024);
277            if current_size_mb >= u64::from(self.config.max_size_mb) {
278                self.rotate()?;
279            }
280        }
281        Ok(())
282    }
283
284    /// Rotate the log file
285    fn rotate(&self) -> Result<()> {
286        for i in (1..10).rev() {
287            let old_name = format!("{}.{}.log", self.log_path.display(), i);
288            let new_name = format!("{}.{}.log", self.log_path.display(), i + 1);
289            let _ = std::fs::rename(&old_name, &new_name);
290        }
291
292        let rotated = format!("{}.1.log", self.log_path.display());
293        std::fs::rename(&self.log_path, &rotated)?;
294        Ok(())
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use tempfile::TempDir;
302
303    #[test]
304    fn audit_event_new_creates_unique_id() {
305        let event1 = AuditEvent::new(AuditEventType::CommandExecution);
306        let event2 = AuditEvent::new(AuditEventType::CommandExecution);
307        assert_ne!(event1.event_id, event2.event_id);
308    }
309
310    #[test]
311    fn audit_event_serializes_to_json() {
312        let event = AuditEvent::new(AuditEventType::CommandExecution)
313            .with_actor(Actor {
314                channel: "telegram".to_string(),
315                user_id: None,
316                username: None,
317                session_id: None,
318            })
319            .with_action(Action {
320                command: Some("ls".to_string()),
321                tool_name: None,
322                risk_level: Some("low".to_string()),
323                approved: false,
324                allowed: true,
325            });
326
327        let json = serde_json::to_string(&event);
328        assert!(json.is_ok());
329    }
330
331    #[test]
332    fn audit_logger_disabled_does_not_create_file() -> Result<()> {
333        let tmp = TempDir::new()?;
334        let config = AuditConfig {
335            enabled: false,
336            ..Default::default()
337        };
338        let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
339        let event = AuditEvent::new(AuditEventType::CommandExecution);
340
341        logger.log(&event)?;
342
343        assert!(!tmp.path().join("audit.log").exists());
344        Ok(())
345    }
346
347    #[test]
348    fn audit_logger_writes_event_when_enabled() -> Result<()> {
349        let tmp = TempDir::new()?;
350        let config = AuditConfig {
351            enabled: true,
352            max_size_mb: 10,
353            ..Default::default()
354        };
355        let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
356
357        logger.log_command("cli", "ls", "low", false, true, true, 15)?;
358
359        let log_path = tmp.path().join("audit.log");
360        assert!(log_path.exists());
361
362        let content = std::fs::read_to_string(&log_path)?;
363        assert!(!content.is_empty());
364        Ok(())
365    }
366}