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;
13
14use crate::cli::Cli;
15use crate::errors::{EnvVaultError, Result};
16
17/// A single audit log entry.
18#[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
28/// SQLite-backed audit log.
29pub struct AuditLog {
30    conn: Connection,
31}
32
33impl AuditLog {
34    /// Open (or create) the audit database at `<vault_dir>/audit.db`.
35    ///
36    /// Returns `None` if the database can't be opened — callers should
37    /// treat this as "audit logging unavailable" and continue normally.
38    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        // Set restrictive permissions on the audit database (owner-only).
43        #[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        // Create the table if it doesn't exist.
51        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    /// Record an operation. Fire-and-forget — errors are silently ignored.
67    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    /// Query recent audit entries.
83    ///
84    /// - `limit`: maximum number of entries to return (most recent first).
85    /// - `since`: if provided, only return entries newer than this timestamp.
86    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    /// Return the path to the audit database (for testing/display).
142    pub fn db_path(vault_dir: &Path) -> PathBuf {
143        vault_dir.join("audit.db")
144    }
145}
146
147/// Convenience helper: log an audit event using the CLI context.
148///
149/// Opens the audit database, logs the event, and silently ignores any errors.
150/// This is safe to call from any command — it never fails the parent operation.
151pub 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        // Most recent first.
188        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        // Query with a timestamp in the past should return the entry.
214        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        // Query with a timestamp in the future should return nothing.
219        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        // A path that doesn't exist as a directory should fail gracefully.
241        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}