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#[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#[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 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
61pub struct AuditLogger {
63 log_file: Mutex<File>,
64 log_path: PathBuf,
65}
66
67impl AuditLogger {
68 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 if let Some(parent) = log_path.parent() {
75 std::fs::create_dir_all(parent)?;
76 }
77
78 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 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 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 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 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 pub fn log_path(&self) -> &PathBuf {
166 &self.log_path
167 }
168
169 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 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}