Skip to main content

envvault/audit/
mod.rs

1//! Audit log — SQLite-based operation history.
2//!
3//! Stores a record of every vault operation (set, delete, rotate, etc.)
4//! in a local SQLite database at `<vault_dir>/audit.db`.
5//!
6//! Designed for graceful degradation: if the database can't be opened or
7//! written to, operations silently continue without logging.
8
9use 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/// A single audit log entry.
19#[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/// Serializable audit entry for JSON/CSV export.
32#[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
59/// SQLite-backed audit log.
60pub struct AuditLog {
61    conn: Connection,
62}
63
64impl AuditLog {
65    /// Open (or create) the audit database at `<vault_dir>/audit.db`.
66    ///
67    /// Returns `None` if the database can't be opened — callers should
68    /// treat this as "audit logging unavailable" and continue normally.
69    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        // Set restrictive permissions on the audit database (owner-only).
74        #[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        // Create the table if it doesn't exist.
82        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        // Run idempotent schema migration for v0.5.0 (user, pid, index).
95        Self::migrate_v5(&conn);
96
97        Some(Self { conn })
98    }
99
100    /// Idempotent migration: add user/pid columns and timestamp index.
101    ///
102    /// SQLite's `ALTER TABLE ADD COLUMN` errors if the column already exists,
103    /// so we silently ignore those errors to make this safe to run every time.
104    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    /// Record an operation. Fire-and-forget — errors are silently ignored.
113    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    /// Query recent audit entries.
133    ///
134    /// - `limit`: maximum number of entries to return (most recent first).
135    /// - `since`: if provided, only return entries newer than this timestamp.
136    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    /// Delete audit entries older than the given timestamp.
194    /// Returns the number of entries deleted.
195    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    /// Return the path to the audit database (for testing/display).
207    pub fn db_path(vault_dir: &Path) -> PathBuf {
208        vault_dir.join("audit.db")
209    }
210}
211
212/// Convenience helper: log an audit event using the CLI context.
213///
214/// Opens the audit database, logs the event, and silently ignores any errors.
215/// This is safe to call from any command — it never fails the parent operation.
216pub 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
227/// Log a read operation only if `[audit] log_reads = true` in config.
228///
229/// Used by get/list/run to optionally record read access.
230pub 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
244/// Always log failed authentication attempts.
245pub 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        // Most recent first.
275        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        // Query with a timestamp in the past should return the entry.
301        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        // Query with a timestamp in the future should return nothing.
306        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        // Open twice — migration runs both times, second should not error.
352        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        // PID should always be populated.
371        assert!(entry.pid.is_some());
372        assert!(entry.pid.unwrap() > 0);
373
374        // User may or may not be set depending on the environment,
375        // but the field should exist (Some or None).
376    }
377
378    #[test]
379    fn timestamp_index_exists() {
380        let dir = TempDir::new().unwrap();
381        let audit = AuditLog::open(dir.path()).unwrap();
382
383        // Query sqlite_master for our index.
384        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        // Purge everything before 1 hour from now — should delete our entry.
408        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        // Purge everything before 1 hour ago — should NOT delete our recent entry.
424        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}