| 1 | //! Lightweight audit logging for sensitive operations. |
| 2 | |
| 3 | use std::fs; |
| 4 | use std::path::PathBuf; |
| 5 | |
| 6 | use chrono::Utc; |
| 7 | use serde_json::{Value, json}; |
| 8 | |
| 9 | use crate::utils::{flush_and_sync, open_append}; |
| 10 | |
| 11 | /// Append an audit event to `$CODEWHALE_HOME/audit.log` (or the default |
| 12 | /// `~/.codewhale/audit.log` when no explicit CodeWhale home is configured). |
| 13 | /// |
| 14 | /// This helper is best-effort by design: callers should not fail critical flows |
| 15 | /// if audit persistence fails. |
| 16 | pub fn log_sensitive_event(event: &str, details: Value) { |
| 17 | if let Err(err) = append_event(event, details) { |
| 18 | crate::logging::warn(format!("audit log write failed: {err}")); |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | /// Size at which `audit.log` is rolled to `audit.log.1`. |
| 23 | /// |
| 24 | /// The log was append-only with no bound at all: a real `~/.codewhale/audit.log` |
| 25 | /// had reached 2.6 MB and was still growing, with nothing in the product that |
| 26 | /// would ever shrink it. Unbounded growth in the user's config directory is not |
| 27 | /// a viable end state, and neither is silently discarding the record — so one |
| 28 | /// previous generation is kept, which bounds the pair at ~2× this value while |
| 29 | /// preserving well over a year of ordinary use. |
| 30 | const AUDIT_LOG_ROTATE_BYTES: u64 = 16 * 1024 * 1024; |
| 31 | |
| 32 | /// Roll `audit.log` to `audit.log.1` once it passes [`AUDIT_LOG_ROTATE_BYTES`]. |
| 33 | /// |
| 34 | /// Exactly one previous generation is kept; the older `.1` is replaced. Rolling |
| 35 | /// is a rename, so no record is ever rewritten in place and an event is never |
| 36 | /// lost to a partially-copied file. |
| 37 | /// |
| 38 | /// Best-effort by the same rule as the rest of this module: if the roll fails |
| 39 | /// the event is still appended to the existing file. A too-large audit log is a |
| 40 | /// far better outcome than a dropped audit record. |
| 41 | fn rotate_if_oversized(path: &std::path::Path) { |
| 42 | let oversized = fs::metadata(path).is_ok_and(|meta| meta.len() >= AUDIT_LOG_ROTATE_BYTES); |
| 43 | if !oversized { |
| 44 | return; |
| 45 | } |
| 46 | let mut rolled = path.as_os_str().to_owned(); |
| 47 | rolled.push(".1"); |
| 48 | let _ = fs::rename(path, std::path::Path::new(&rolled)); |
| 49 | } |
| 50 | |
| 51 | fn append_event(event: &str, details: Value) -> anyhow::Result<()> { |
| 52 | let path = default_audit_path()?; |
| 53 | let parent = path.parent().map(|p| p.to_path_buf()); |
| 54 | if let Some(ref parent) = parent { |
| 55 | fs::create_dir_all(parent)?; |
| 56 | } |
| 57 | rotate_if_oversized(&path); |
| 58 | // Open for append with a BufWriter for buffered I/O, then flush + fsync |
| 59 | // after each event so the record is durably on disk. |
| 60 | let mut writer = open_append(&path)?; |
| 61 | let record = json!({ |
| 62 | "ts": Utc::now().to_rfc3339(), |
| 63 | "event": event, |
| 64 | "details": details, |
| 65 | }); |
| 66 | let line = serde_json::to_string(&record)?; |
| 67 | use std::io::Write; |
| 68 | writeln!(writer, "{line}")?; |
| 69 | flush_and_sync(&mut writer)?; |
| 70 | Ok(()) |
| 71 | } |
| 72 | |
| 73 | fn default_audit_path() -> anyhow::Result<PathBuf> { |
| 74 | Ok(codewhale_config::codewhale_home()?.join("audit.log")) |
| 75 | } |
| 76 | |
| 77 | /// Where audit events are written, for surfaces that point a person at the |
| 78 | /// full record (for example `/permissions`). `None` when no Codewhale home |
| 79 | /// resolves; callers show a placeholder rather than guessing a path. |
| 80 | #[must_use] |
| 81 | pub fn audit_log_path() -> Option<PathBuf> { |
| 82 | default_audit_path().ok() |
| 83 | } |
| 84 | |
| 85 | #[cfg(test)] |
| 86 | mod tests { |
| 87 | use super::{AUDIT_LOG_ROTATE_BYTES, rotate_if_oversized}; |
| 88 | |
| 89 | #[test] |
| 90 | fn a_small_log_is_left_alone() { |
| 91 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 92 | let path = dir.path().join("audit.log"); |
| 93 | std::fs::write(&path, b"{}\n").expect("write"); |
| 94 | rotate_if_oversized(&path); |
| 95 | assert!(path.exists(), "an ordinary log is never rolled"); |
| 96 | assert!(!dir.path().join("audit.log.1").exists()); |
| 97 | } |
| 98 | |
| 99 | #[test] |
| 100 | fn an_oversized_log_rolls_to_one_previous_generation() { |
| 101 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 102 | let path = dir.path().join("audit.log"); |
| 103 | let rolled = dir.path().join("audit.log.1"); |
| 104 | std::fs::write(&path, vec![b'x'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("write"); |
| 105 | |
| 106 | rotate_if_oversized(&path); |
| 107 | |
| 108 | assert!( |
| 109 | !path.exists(), |
| 110 | "the live log is rolled aside, not truncated" |
| 111 | ); |
| 112 | assert_eq!( |
| 113 | std::fs::metadata(&rolled).expect("rolled log").len(), |
| 114 | AUDIT_LOG_ROTATE_BYTES, |
| 115 | "the previous generation keeps every byte — rolling is a rename" |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | #[test] |
| 120 | fn rolling_twice_keeps_exactly_one_previous_generation() { |
| 121 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 122 | let path = dir.path().join("audit.log"); |
| 123 | let rolled = dir.path().join("audit.log.1"); |
| 124 | |
| 125 | std::fs::write(&path, vec![b'a'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("first"); |
| 126 | rotate_if_oversized(&path); |
| 127 | std::fs::write(&path, vec![b'b'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("second"); |
| 128 | rotate_if_oversized(&path); |
| 129 | |
| 130 | let kept = std::fs::read(&rolled).expect("rolled log"); |
| 131 | assert_eq!(kept.len(), AUDIT_LOG_ROTATE_BYTES as usize); |
| 132 | assert_eq!(kept[0], b'b', "the newer generation replaces the older one"); |
| 133 | assert!( |
| 134 | !dir.path().join("audit.log.2").exists(), |
| 135 | "generations must not accumulate — that is the bug being fixed" |
| 136 | ); |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn a_missing_log_is_not_an_error() { |
| 141 | let dir = tempfile::TempDir::new().expect("tempdir"); |
| 142 | rotate_if_oversized(&dir.path().join("audit.log")); |
| 143 | } |
| 144 | } |
| 145 |