1use std::path::{Path, PathBuf};
10
11use chrono::{DateTime, Utc};
12use rusqlite::Connection;
13
14use crate::cli::Cli;
15use crate::errors::{EnvVaultError, Result};
16
17#[derive(Debug, Clone)]
19pub struct AuditEntry {
20 pub id: i64,
21 pub timestamp: DateTime<Utc>,
22 pub operation: String,
23 pub environment: String,
24 pub key_name: Option<String>,
25 pub details: Option<String>,
26}
27
28pub struct AuditLog {
30 conn: Connection,
31}
32
33impl AuditLog {
34 pub fn open(vault_dir: &Path) -> Option<Self> {
39 let db_path = vault_dir.join("audit.db");
40 let conn = Connection::open(&db_path).ok()?;
41
42 #[cfg(unix)]
44 {
45 use std::os::unix::fs::PermissionsExt;
46 let perms = std::fs::Permissions::from_mode(0o600);
47 let _ = std::fs::set_permissions(&db_path, perms);
48 }
49
50 conn.execute_batch(
52 "CREATE TABLE IF NOT EXISTS audit_log (
53 id INTEGER PRIMARY KEY AUTOINCREMENT,
54 timestamp TEXT NOT NULL,
55 operation TEXT NOT NULL,
56 environment TEXT NOT NULL,
57 key_name TEXT,
58 details TEXT
59 );",
60 )
61 .ok()?;
62
63 Some(Self { conn })
64 }
65
66 pub fn log(
68 &self,
69 operation: &str,
70 environment: &str,
71 key_name: Option<&str>,
72 details: Option<&str>,
73 ) {
74 let now = Utc::now().to_rfc3339();
75 let _ = self.conn.execute(
76 "INSERT INTO audit_log (timestamp, operation, environment, key_name, details)
77 VALUES (?1, ?2, ?3, ?4, ?5)",
78 rusqlite::params![now, operation, environment, key_name, details],
79 );
80 }
81
82 pub fn query(&self, limit: usize, since: Option<DateTime<Utc>>) -> Result<Vec<AuditEntry>> {
87 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
88 let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = match since {
89 Some(ref ts) => (
90 "SELECT id, timestamp, operation, environment, key_name, details
91 FROM audit_log
92 WHERE timestamp >= ?1
93 ORDER BY id DESC
94 LIMIT ?2",
95 vec![
96 Box::new(ts.to_rfc3339()) as Box<dyn rusqlite::types::ToSql>,
97 Box::new(limit_i64),
98 ],
99 ),
100 None => (
101 "SELECT id, timestamp, operation, environment, key_name, details
102 FROM audit_log
103 ORDER BY id DESC
104 LIMIT ?1",
105 vec![Box::new(limit_i64) as Box<dyn rusqlite::types::ToSql>],
106 ),
107 };
108
109 let mut stmt = self
110 .conn
111 .prepare(sql)
112 .map_err(|e| EnvVaultError::AuditError(format!("query prepare: {e}")))?;
113
114 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| &**p).collect();
115
116 let rows = stmt
117 .query_map(params_refs.as_slice(), |row| {
118 let ts_str: String = row.get(1)?;
119 let timestamp = DateTime::parse_from_rfc3339(&ts_str)
120 .map_or_else(|_| Utc::now(), |dt| dt.with_timezone(&Utc));
121
122 Ok(AuditEntry {
123 id: row.get(0)?,
124 timestamp,
125 operation: row.get(2)?,
126 environment: row.get(3)?,
127 key_name: row.get(4)?,
128 details: row.get(5)?,
129 })
130 })
131 .map_err(|e| EnvVaultError::AuditError(format!("query exec: {e}")))?;
132
133 let mut entries = Vec::new();
134 for row in rows {
135 entries.push(row.map_err(|e| EnvVaultError::AuditError(format!("row parse: {e}")))?);
136 }
137
138 Ok(entries)
139 }
140
141 pub fn db_path(vault_dir: &Path) -> PathBuf {
143 vault_dir.join("audit.db")
144 }
145}
146
147pub fn log_audit(cli: &Cli, op: &str, key: Option<&str>, details: Option<&str>) {
152 let vault_dir = match std::env::current_dir() {
153 Ok(cwd) => cwd.join(&cli.vault_dir),
154 Err(_) => return,
155 };
156
157 if let Some(audit) = AuditLog::open(&vault_dir) {
158 audit.log(op, &cli.env, key, details);
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use tempfile::TempDir;
166
167 #[test]
168 fn open_creates_database() {
169 let dir = TempDir::new().unwrap();
170 let audit = AuditLog::open(dir.path());
171 assert!(audit.is_some(), "should open successfully");
172 assert!(dir.path().join("audit.db").exists());
173 }
174
175 #[test]
176 fn log_and_query_roundtrip() {
177 let dir = TempDir::new().unwrap();
178 let audit = AuditLog::open(dir.path()).unwrap();
179
180 audit.log("set", "dev", Some("DB_URL"), Some("added"));
181 audit.log("set", "dev", Some("API_KEY"), Some("added"));
182 audit.log("delete", "dev", Some("OLD_KEY"), None);
183
184 let entries = audit.query(10, None).unwrap();
185 assert_eq!(entries.len(), 3);
186
187 assert_eq!(entries[0].operation, "delete");
189 assert_eq!(entries[1].operation, "set");
190 assert_eq!(entries[2].operation, "set");
191 }
192
193 #[test]
194 fn query_with_limit() {
195 let dir = TempDir::new().unwrap();
196 let audit = AuditLog::open(dir.path()).unwrap();
197
198 for i in 0..10 {
199 audit.log("set", "dev", Some(&format!("KEY_{i}")), None);
200 }
201
202 let entries = audit.query(3, None).unwrap();
203 assert_eq!(entries.len(), 3);
204 }
205
206 #[test]
207 fn query_with_since_filter() {
208 let dir = TempDir::new().unwrap();
209 let audit = AuditLog::open(dir.path()).unwrap();
210
211 audit.log("set", "dev", Some("KEY_1"), None);
212
213 let past = Utc::now() - chrono::Duration::hours(1);
215 let entries = audit.query(10, Some(past)).unwrap();
216 assert_eq!(entries.len(), 1);
217
218 let future = Utc::now() + chrono::Duration::hours(1);
220 let entries = audit.query(10, Some(future)).unwrap();
221 assert_eq!(entries.len(), 0);
222 }
223
224 #[test]
225 fn log_records_environment() {
226 let dir = TempDir::new().unwrap();
227 let audit = AuditLog::open(dir.path()).unwrap();
228
229 audit.log("init", "staging", None, Some("vault created"));
230
231 let entries = audit.query(1, None).unwrap();
232 assert_eq!(entries[0].environment, "staging");
233 assert_eq!(entries[0].operation, "init");
234 assert!(entries[0].key_name.is_none());
235 assert_eq!(entries[0].details.as_deref(), Some("vault created"));
236 }
237
238 #[test]
239 fn open_returns_none_on_bad_path() {
240 let result = AuditLog::open(Path::new("/nonexistent/path/that/does/not/exist"));
242 assert!(result.is_none());
243 }
244
245 #[cfg(unix)]
246 #[test]
247 fn audit_db_has_restrictive_permissions() {
248 use std::os::unix::fs::PermissionsExt;
249
250 let dir = TempDir::new().unwrap();
251 let _audit = AuditLog::open(dir.path()).unwrap();
252
253 let db_path = dir.path().join("audit.db");
254 let perms = std::fs::metadata(&db_path).unwrap().permissions();
255 assert_eq!(
256 perms.mode() & 0o777,
257 0o600,
258 "audit.db should have 0o600 permissions"
259 );
260 }
261}