skill_runtime/
audit.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::fs::{File, OpenOptions};
5use std::io::Write;
6use std::path::PathBuf;
7use std::sync::Mutex;
8
9/// Audit event types
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum AuditEventType {
13    CredentialAccess,
14    CredentialStore,
15    CredentialDelete,
16    InstanceCreate,
17    InstanceDelete,
18    ConfigLoad,
19    ConfigUpdate,
20}
21
22/// Audit log entry
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AuditEntry {
25    pub timestamp: DateTime<Utc>,
26    pub event_type: AuditEventType,
27    pub skill_name: String,
28    pub instance_name: String,
29    pub details: Option<String>,
30    /// Redacted information (never contains actual secrets)
31    pub metadata: Option<serde_json::Value>,
32}
33
34impl AuditEntry {
35    pub fn new(
36        event_type: AuditEventType,
37        skill_name: String,
38        instance_name: String,
39    ) -> Self {
40        Self {
41            timestamp: Utc::now(),
42            event_type,
43            skill_name,
44            instance_name,
45            details: None,
46            metadata: None,
47        }
48    }
49
50    pub fn with_details(mut self, details: String) -> Self {
51        self.details = Some(details);
52        self
53    }
54
55    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
56        self.metadata = Some(metadata);
57        self
58    }
59}
60
61/// Audit logger for security-sensitive operations
62pub struct AuditLogger {
63    log_file: Mutex<File>,
64    log_path: PathBuf,
65}
66
67impl AuditLogger {
68    /// Create a new audit logger
69    pub fn new() -> Result<Self> {
70        let home = dirs::home_dir().context("Failed to get home directory")?;
71        let log_path = home.join(".skill-engine").join("audit.log");
72
73        // Create parent directory if it doesn't exist
74        if let Some(parent) = log_path.parent() {
75            std::fs::create_dir_all(parent)?;
76        }
77
78        // Open log file in append mode
79        let log_file = OpenOptions::new()
80            .create(true)
81            .append(true)
82            .open(&log_path)
83            .with_context(|| format!("Failed to open audit log: {}", log_path.display()))?;
84
85        Ok(Self {
86            log_file: Mutex::new(log_file),
87            log_path,
88        })
89    }
90
91    /// Log an audit event
92    pub fn log(&self, entry: AuditEntry) -> Result<()> {
93        let json = serde_json::to_string(&entry)?;
94
95        let mut file = self
96            .log_file
97            .lock()
98            .map_err(|e| anyhow::anyhow!("Failed to lock audit log: {}", e))?;
99
100        writeln!(file, "{}", json)?;
101        file.flush()?;
102
103        tracing::debug!(
104            event = ?entry.event_type,
105            skill = %entry.skill_name,
106            instance = %entry.instance_name,
107            "Audit event logged"
108        );
109
110        Ok(())
111    }
112
113    /// Log credential access
114    pub fn log_credential_access(
115        &self,
116        skill_name: &str,
117        instance_name: &str,
118        key_name: &str,
119    ) -> Result<()> {
120        let entry = AuditEntry::new(
121            AuditEventType::CredentialAccess,
122            skill_name.to_string(),
123            instance_name.to_string(),
124        )
125        .with_details(format!("Accessed credential key: {}", key_name));
126
127        self.log(entry)
128    }
129
130    /// Log credential storage
131    pub fn log_credential_store(
132        &self,
133        skill_name: &str,
134        instance_name: &str,
135        key_name: &str,
136    ) -> Result<()> {
137        let entry = AuditEntry::new(
138            AuditEventType::CredentialStore,
139            skill_name.to_string(),
140            instance_name.to_string(),
141        )
142        .with_details(format!("Stored credential key: {}", key_name));
143
144        self.log(entry)
145    }
146
147    /// Log credential deletion
148    pub fn log_credential_delete(
149        &self,
150        skill_name: &str,
151        instance_name: &str,
152        key_name: &str,
153    ) -> Result<()> {
154        let entry = AuditEntry::new(
155            AuditEventType::CredentialDelete,
156            skill_name.to_string(),
157            instance_name.to_string(),
158        )
159        .with_details(format!("Deleted credential key: {}", key_name));
160
161        self.log(entry)
162    }
163
164    /// Get the audit log path
165    pub fn log_path(&self) -> &PathBuf {
166        &self.log_path
167    }
168
169    /// Read recent audit entries
170    pub fn read_recent(&self, limit: usize) -> Result<Vec<AuditEntry>> {
171        use std::io::{BufRead, BufReader};
172
173        let file = File::open(&self.log_path)?;
174        let reader = BufReader::new(file);
175
176        let entries: Vec<AuditEntry> = reader
177            .lines()
178            .filter_map(|line| line.ok())
179            .filter_map(|line| serde_json::from_str(&line).ok())
180            .collect();
181
182        // Return last N entries
183        Ok(entries.into_iter().rev().take(limit).rev().collect())
184    }
185}
186
187impl Default for AuditLogger {
188    fn default() -> Self {
189        Self::new().expect("Failed to create AuditLogger")
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use tempfile::TempDir;
197
198    #[test]
199    fn test_audit_entry_creation() {
200        let entry = AuditEntry::new(
201            AuditEventType::CredentialAccess,
202            "test-skill".to_string(),
203            "prod".to_string(),
204        )
205        .with_details("Test access".to_string());
206
207        assert_eq!(entry.skill_name, "test-skill");
208        assert_eq!(entry.instance_name, "prod");
209        assert_eq!(entry.details, Some("Test access".to_string()));
210    }
211
212    #[test]
213    fn test_audit_entry_serialization() {
214        let entry = AuditEntry::new(
215            AuditEventType::CredentialStore,
216            "test-skill".to_string(),
217            "prod".to_string(),
218        );
219
220        let json = serde_json::to_string(&entry).unwrap();
221        let deserialized: AuditEntry = serde_json::from_str(&json).unwrap();
222
223        assert_eq!(deserialized.skill_name, entry.skill_name);
224        assert_eq!(deserialized.instance_name, entry.instance_name);
225    }
226}