1use std::path::{Path, PathBuf};
10
11use chrono::{DateTime, Utc};
12use rusqlite::Connection;
13use serde::Serialize;
14
15use crate::cli::Cli;
16use crate::errors::{EnvVaultError, Result};
17
18#[derive(Debug, Clone)]
20pub struct AuditEntry {
21 pub id: i64,
22 pub timestamp: DateTime<Utc>,
23 pub operation: String,
24 pub environment: String,
25 pub key_name: Option<String>,
26 pub details: Option<String>,
27 pub user: Option<String>,
28 pub pid: Option<i64>,
29}
30
31#[derive(Debug, Clone, Serialize, serde::Deserialize)]
33pub struct AuditEntryExport {
34 pub id: i64,
35 pub timestamp: String,
36 pub operation: String,
37 pub environment: String,
38 pub key_name: Option<String>,
39 pub details: Option<String>,
40 pub user: Option<String>,
41 pub pid: Option<i64>,
42}
43
44impl From<&AuditEntry> for AuditEntryExport {
45 fn from(e: &AuditEntry) -> Self {
46 Self {
47 id: e.id,
48 timestamp: e.timestamp.to_rfc3339(),
49 operation: e.operation.clone(),
50 environment: e.environment.clone(),
51 key_name: e.key_name.clone(),
52 details: e.details.clone(),
53 user: e.user.clone(),
54 pid: e.pid,
55 }
56 }
57}
58
59pub struct AuditLog {
61 conn: Connection,
62}
63
64impl AuditLog {
65 pub fn open(vault_dir: &Path) -> Option<Self> {
70 let db_path = vault_dir.join("audit.db");
71 let conn = Connection::open(&db_path).ok()?;
72
73 #[cfg(unix)]
75 {
76 use std::os::unix::fs::PermissionsExt;
77 let perms = std::fs::Permissions::from_mode(0o600);
78 let _ = std::fs::set_permissions(&db_path, perms);
79 }
80
81 conn.execute_batch(
83 "CREATE TABLE IF NOT EXISTS audit_log (
84 id INTEGER PRIMARY KEY AUTOINCREMENT,
85 timestamp TEXT NOT NULL,
86 operation TEXT NOT NULL,
87 environment TEXT NOT NULL,
88 key_name TEXT,
89 details TEXT
90 );",
91 )
92 .ok()?;
93
94 Self::migrate_v5(&conn);
96
97 Some(Self { conn })
98 }
99
100 fn migrate_v5(conn: &Connection) {
105 let _ = conn.execute_batch("ALTER TABLE audit_log ADD COLUMN user TEXT;");
106 let _ = conn.execute_batch("ALTER TABLE audit_log ADD COLUMN pid INTEGER;");
107 let _ = conn.execute_batch(
108 "CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);",
109 );
110 }
111
112 pub fn log(
114 &self,
115 operation: &str,
116 environment: &str,
117 key_name: Option<&str>,
118 details: Option<&str>,
119 ) {
120 let now = Utc::now().to_rfc3339();
121 let user = std::env::var("USER")
122 .or_else(|_| std::env::var("LOGNAME"))
123 .ok();
124 let pid = std::process::id() as i64;
125 let _ = self.conn.execute(
126 "INSERT INTO audit_log (timestamp, operation, environment, key_name, details, user, pid)
127 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
128 rusqlite::params![now, operation, environment, key_name, details, user, pid],
129 );
130 }
131
132 pub fn query(&self, limit: usize, since: Option<DateTime<Utc>>) -> Result<Vec<AuditEntry>> {
137 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
138 let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = match since {
139 Some(ref ts) => (
140 "SELECT id, timestamp, operation, environment, key_name, details, user, pid
141 FROM audit_log
142 WHERE timestamp >= ?1
143 ORDER BY id DESC
144 LIMIT ?2",
145 vec![
146 Box::new(ts.to_rfc3339()) as Box<dyn rusqlite::types::ToSql>,
147 Box::new(limit_i64),
148 ],
149 ),
150 None => (
151 "SELECT id, timestamp, operation, environment, key_name, details, user, pid
152 FROM audit_log
153 ORDER BY id DESC
154 LIMIT ?1",
155 vec![Box::new(limit_i64) as Box<dyn rusqlite::types::ToSql>],
156 ),
157 };
158
159 let mut stmt = self
160 .conn
161 .prepare(sql)
162 .map_err(|e| EnvVaultError::AuditError(format!("query prepare: {e}")))?;
163
164 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| &**p).collect();
165
166 let rows = stmt
167 .query_map(params_refs.as_slice(), |row| {
168 let ts_str: String = row.get(1)?;
169 let timestamp = DateTime::parse_from_rfc3339(&ts_str)
170 .map_or_else(|_| Utc::now(), |dt| dt.with_timezone(&Utc));
171
172 Ok(AuditEntry {
173 id: row.get(0)?,
174 timestamp,
175 operation: row.get(2)?,
176 environment: row.get(3)?,
177 key_name: row.get(4)?,
178 details: row.get(5)?,
179 user: row.get(6)?,
180 pid: row.get(7)?,
181 })
182 })
183 .map_err(|e| EnvVaultError::AuditError(format!("query exec: {e}")))?;
184
185 let mut entries = Vec::new();
186 for row in rows {
187 entries.push(row.map_err(|e| EnvVaultError::AuditError(format!("row parse: {e}")))?);
188 }
189
190 Ok(entries)
191 }
192
193 pub fn purge(&self, before: DateTime<Utc>) -> Result<usize> {
196 let count = self
197 .conn
198 .execute(
199 "DELETE FROM audit_log WHERE timestamp < ?1",
200 rusqlite::params![before.to_rfc3339()],
201 )
202 .map_err(|e| EnvVaultError::AuditError(format!("purge failed: {e}")))?;
203 Ok(count)
204 }
205
206 pub fn db_path(vault_dir: &Path) -> PathBuf {
208 vault_dir.join("audit.db")
209 }
210}
211
212pub fn log_audit(cli: &Cli, op: &str, key: Option<&str>, details: Option<&str>) {
217 let vault_dir = match std::env::current_dir() {
218 Ok(cwd) => cwd.join(&cli.vault_dir),
219 Err(_) => return,
220 };
221
222 if let Some(audit) = AuditLog::open(&vault_dir) {
223 audit.log(op, &cli.env, key, details);
224 }
225}
226
227pub fn log_read_audit(cli: &Cli, op: &str, key: Option<&str>, details: Option<&str>) {
231 let cwd = match std::env::current_dir() {
232 Ok(cwd) => cwd,
233 Err(_) => return,
234 };
235
236 let settings = crate::config::Settings::load(&cwd).unwrap_or_default();
237 if !settings.audit.log_reads {
238 return;
239 }
240
241 log_audit(cli, op, key, details);
242}
243
244pub fn log_auth_failure(cli: &Cli, details: &str) {
246 log_audit(cli, "auth-failed", None, Some(details));
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use tempfile::TempDir;
253
254 #[test]
255 fn open_creates_database() {
256 let dir = TempDir::new().unwrap();
257 let audit = AuditLog::open(dir.path());
258 assert!(audit.is_some(), "should open successfully");
259 assert!(dir.path().join("audit.db").exists());
260 }
261
262 #[test]
263 fn log_and_query_roundtrip() {
264 let dir = TempDir::new().unwrap();
265 let audit = AuditLog::open(dir.path()).unwrap();
266
267 audit.log("set", "dev", Some("DB_URL"), Some("added"));
268 audit.log("set", "dev", Some("API_KEY"), Some("added"));
269 audit.log("delete", "dev", Some("OLD_KEY"), None);
270
271 let entries = audit.query(10, None).unwrap();
272 assert_eq!(entries.len(), 3);
273
274 assert_eq!(entries[0].operation, "delete");
276 assert_eq!(entries[1].operation, "set");
277 assert_eq!(entries[2].operation, "set");
278 }
279
280 #[test]
281 fn query_with_limit() {
282 let dir = TempDir::new().unwrap();
283 let audit = AuditLog::open(dir.path()).unwrap();
284
285 for i in 0..10 {
286 audit.log("set", "dev", Some(&format!("KEY_{i}")), None);
287 }
288
289 let entries = audit.query(3, None).unwrap();
290 assert_eq!(entries.len(), 3);
291 }
292
293 #[test]
294 fn query_with_since_filter() {
295 let dir = TempDir::new().unwrap();
296 let audit = AuditLog::open(dir.path()).unwrap();
297
298 audit.log("set", "dev", Some("KEY_1"), None);
299
300 let past = Utc::now() - chrono::Duration::hours(1);
302 let entries = audit.query(10, Some(past)).unwrap();
303 assert_eq!(entries.len(), 1);
304
305 let future = Utc::now() + chrono::Duration::hours(1);
307 let entries = audit.query(10, Some(future)).unwrap();
308 assert_eq!(entries.len(), 0);
309 }
310
311 #[test]
312 fn log_records_environment() {
313 let dir = TempDir::new().unwrap();
314 let audit = AuditLog::open(dir.path()).unwrap();
315
316 audit.log("init", "staging", None, Some("vault created"));
317
318 let entries = audit.query(1, None).unwrap();
319 assert_eq!(entries[0].environment, "staging");
320 assert_eq!(entries[0].operation, "init");
321 assert!(entries[0].key_name.is_none());
322 assert_eq!(entries[0].details.as_deref(), Some("vault created"));
323 }
324
325 #[test]
326 fn open_returns_none_on_bad_path() {
327 let result = AuditLog::open(Path::new("/nonexistent/path/that/does/not/exist"));
328 assert!(result.is_none());
329 }
330
331 #[cfg(unix)]
332 #[test]
333 fn audit_db_has_restrictive_permissions() {
334 use std::os::unix::fs::PermissionsExt;
335
336 let dir = TempDir::new().unwrap();
337 let _audit = AuditLog::open(dir.path()).unwrap();
338
339 let db_path = dir.path().join("audit.db");
340 let perms = std::fs::metadata(&db_path).unwrap().permissions();
341 assert_eq!(
342 perms.mode() & 0o777,
343 0o600,
344 "audit.db should have 0o600 permissions"
345 );
346 }
347
348 #[test]
349 fn migrate_v5_is_idempotent() {
350 let dir = TempDir::new().unwrap();
351 let audit1 = AuditLog::open(dir.path());
353 assert!(audit1.is_some());
354 drop(audit1);
355
356 let audit2 = AuditLog::open(dir.path());
357 assert!(audit2.is_some());
358 }
359
360 #[test]
361 fn log_records_user_and_pid() {
362 let dir = TempDir::new().unwrap();
363 let audit = AuditLog::open(dir.path()).unwrap();
364
365 audit.log("set", "dev", Some("KEY"), None);
366
367 let entries = audit.query(1, None).unwrap();
368 let entry = &entries[0];
369
370 assert!(entry.pid.is_some());
372 assert!(entry.pid.unwrap() > 0);
373
374 }
377
378 #[test]
379 fn timestamp_index_exists() {
380 let dir = TempDir::new().unwrap();
381 let audit = AuditLog::open(dir.path()).unwrap();
382
383 let mut stmt = audit
385 .conn
386 .prepare(
387 "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_audit_timestamp'",
388 )
389 .unwrap();
390 let names: Vec<String> = stmt
391 .query_map([], |row| row.get(0))
392 .unwrap()
393 .filter_map(|r| r.ok())
394 .collect();
395
396 assert_eq!(names.len(), 1);
397 assert_eq!(names[0], "idx_audit_timestamp");
398 }
399
400 #[test]
401 fn purge_deletes_old_entries() {
402 let dir = TempDir::new().unwrap();
403 let audit = AuditLog::open(dir.path()).unwrap();
404
405 audit.log("set", "dev", Some("KEY"), None);
406
407 let future = Utc::now() + chrono::Duration::hours(1);
409 let deleted = audit.purge(future).unwrap();
410 assert_eq!(deleted, 1);
411
412 let entries = audit.query(10, None).unwrap();
413 assert!(entries.is_empty());
414 }
415
416 #[test]
417 fn purge_preserves_recent_entries() {
418 let dir = TempDir::new().unwrap();
419 let audit = AuditLog::open(dir.path()).unwrap();
420
421 audit.log("set", "dev", Some("KEY"), None);
422
423 let past = Utc::now() - chrono::Duration::hours(1);
425 let deleted = audit.purge(past).unwrap();
426 assert_eq!(deleted, 0);
427
428 let entries = audit.query(10, None).unwrap();
429 assert_eq!(entries.len(), 1);
430 }
431}