Skip to main content

hasp_core/
audit.rs

1//! Structured audit events emitted by every `Store` verb.
2//!
3//! `AuditEvent` is a closed-shape record: a timestamp, a verb-and-phase
4//! label, the URL scheme(s) the verb operated on, a stable outcome
5//! label, and (on failure) a stable `error_kind` classifier. No field
6//! carries a value, a value-derived length, or any byte of secret
7//! material — the redaction posture is enforced by the type system:
8//! the struct is `#[non_exhaustive]`, every field is either a
9//! timestamp, a `&'static str` chosen from a closed set, or a small
10//! owned `String` populated only from a URL scheme.
11//!
12//! Implementations of `AuditSink` only ever receive an
13//! `&AuditEvent` — they cannot construct one with arbitrary content,
14//! and they cannot widen the field set.
15
16use std::fs::{File, OpenOptions};
17use std::io::{self, Write};
18use std::path::{Path, PathBuf};
19use std::sync::Mutex;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22/// The verb a `Store` operation belongs to. Closed set so audit event
23/// labels are statically known and cannot be widened by a caller.
24///
25/// Library-side verbs only. CLI-only concerns like `run` (subprocess
26/// env injection) live in `hasp-cli` and build their own events via
27/// [`AuditEvent::with_event`] using a `&'static str` label literal.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Verb {
30    Get,
31    Put,
32    List,
33    Delete,
34    Exists,
35    Cp,
36    Diff,
37}
38
39/// Single-phase cache event classifier. Unlike [`Verb`] these events
40/// have no start/done split — a cache hit is observable in one phase.
41/// The label set is closed at the type level so audit consumers can
42/// switch on it without parsing.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum CacheEvent {
45    Hit,
46    Miss,
47    Expire,
48    Clear,
49}
50
51impl CacheEvent {
52    /// Stable `&'static str` label for the event.
53    pub fn label(self) -> &'static str {
54        match self {
55            CacheEvent::Hit => "cache.hit",
56            CacheEvent::Miss => "cache.miss",
57            CacheEvent::Expire => "cache.expire",
58            CacheEvent::Clear => "cache.clear",
59        }
60    }
61}
62
63impl Verb {
64    /// Static label for the `*.start` event.
65    pub fn start_label(self) -> &'static str {
66        match self {
67            Verb::Get => "get.start",
68            Verb::Put => "put.start",
69            Verb::List => "list.start",
70            Verb::Delete => "delete.start",
71            Verb::Exists => "exists.start",
72            Verb::Cp => "cp.start",
73            Verb::Diff => "diff.start",
74        }
75    }
76
77    /// Static label for the `*.done` event.
78    pub fn done_label(self) -> &'static str {
79        match self {
80            Verb::Get => "get.done",
81            Verb::Put => "put.done",
82            Verb::List => "list.done",
83            Verb::Delete => "delete.done",
84            Verb::Exists => "exists.done",
85            Verb::Cp => "cp.done",
86            Verb::Diff => "diff.done",
87        }
88    }
89}
90
91/// A redacted audit event.
92///
93/// Constructed via [`AuditEvent::start`] or [`AuditEvent::done`]; the
94/// struct is `#[non_exhaustive]` so external crates cannot widen the
95/// field set by struct-literal initialization. Sink implementations
96/// receive `&AuditEvent` and can serialize whichever fields they
97/// expose — but every field is, by construction, value-free.
98#[non_exhaustive]
99#[derive(Debug, Clone)]
100pub struct AuditEvent {
101    pub ts: SystemTime,
102    pub event: &'static str,
103    pub url_scheme: String,
104    pub dst_scheme: Option<String>,
105    pub outcome: &'static str,
106    pub error_kind: Option<&'static str>,
107}
108
109impl AuditEvent {
110    /// Build a `*.start` event for `verb` operating on a URL with
111    /// `scheme`. Default `outcome` is `"started"`.
112    pub fn start(verb: Verb, scheme: impl Into<String>) -> Self {
113        Self {
114            ts: SystemTime::now(),
115            event: verb.start_label(),
116            url_scheme: scheme.into(),
117            dst_scheme: None,
118            outcome: "started",
119            error_kind: None,
120        }
121    }
122
123    /// Build a `*.done` event for `verb` with the given outcome label.
124    pub fn done(verb: Verb, scheme: impl Into<String>, outcome: &'static str) -> Self {
125        Self {
126            ts: SystemTime::now(),
127            event: verb.done_label(),
128            url_scheme: scheme.into(),
129            dst_scheme: None,
130            outcome,
131            error_kind: None,
132        }
133    }
134
135    /// Attach a destination scheme (used by `cp`, `diff`, and any future
136    /// two-URL verb).
137    pub fn with_dst_scheme(mut self, dst_scheme: impl Into<String>) -> Self {
138        self.dst_scheme = Some(dst_scheme.into());
139        self
140    }
141
142    /// Attach a stable error-kind classifier (from `Error::kind()`).
143    pub fn with_error_kind(mut self, kind: &'static str) -> Self {
144        self.error_kind = Some(kind);
145        self
146    }
147
148    /// Build an event with an arbitrary `'static` event label.
149    ///
150    /// CLI-only verbs that do not belong on the library-side [`Verb`]
151    /// enum (e.g., `run.start` / `run.done` from subprocess env
152    /// injection) build events through this constructor. The
153    /// `&'static str` bound prevents runtime-built label strings from
154    /// smuggling value bytes into the audit envelope: callers must
155    /// pass string literals known at compile time.
156    pub fn with_event(
157        event: &'static str,
158        scheme: impl Into<String>,
159        outcome: &'static str,
160    ) -> Self {
161        Self {
162            ts: SystemTime::now(),
163            event,
164            url_scheme: scheme.into(),
165            dst_scheme: None,
166            outcome,
167            error_kind: None,
168        }
169    }
170
171    /// Build a cache event for the given URL scheme. Single-phase —
172    /// the `outcome` field carries the same classifier as `event`
173    /// (e.g., `event = "cache.hit"`, `outcome = "hit"`) so consumers
174    /// that filter on `outcome` see a stable label without parsing
175    /// the `event` prefix.
176    pub fn cache(kind: CacheEvent, scheme: impl Into<String>) -> Self {
177        let outcome: &'static str = match kind {
178            CacheEvent::Hit => "hit",
179            CacheEvent::Miss => "miss",
180            CacheEvent::Expire => "expire",
181            CacheEvent::Clear => "clear",
182        };
183        Self {
184            ts: SystemTime::now(),
185            event: kind.label(),
186            url_scheme: scheme.into(),
187            dst_scheme: None,
188            outcome,
189            error_kind: None,
190        }
191    }
192
193    /// Render to a single-line JSON string with no trailing newline.
194    ///
195    /// Wire format (fields appear in this order):
196    ///
197    /// ```json
198    /// {"event":"get.done","ts":1234567890,"src_scheme":"vault","outcome":"ok"}
199    /// ```
200    ///
201    /// `dst_scheme` and `error_kind` appear only when populated. The
202    /// timestamp is UNIX seconds since the epoch; clock-skew anomalies
203    /// fall back to `0` rather than panicking on the secret path.
204    pub fn to_json_line(&self) -> String {
205        let ts = self
206            .ts
207            .duration_since(UNIX_EPOCH)
208            .map(|d| d.as_secs())
209            .unwrap_or(0);
210        let mut obj = serde_json::Map::new();
211        obj.insert("event".into(), serde_json::Value::String(self.event.into()));
212        obj.insert("ts".into(), serde_json::Value::Number(ts.into()));
213        obj.insert(
214            "src_scheme".into(),
215            serde_json::Value::String(self.url_scheme.clone()),
216        );
217        if let Some(d) = &self.dst_scheme {
218            obj.insert("dst_scheme".into(), serde_json::Value::String(d.clone()));
219        }
220        obj.insert(
221            "outcome".into(),
222            serde_json::Value::String(self.outcome.into()),
223        );
224        if let Some(k) = self.error_kind {
225            obj.insert("error_kind".into(), serde_json::Value::String(k.into()));
226        }
227        serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
228    }
229}
230
231/// A sink that consumes [`AuditEvent`]s.
232///
233/// Sinks must be `Send + Sync` because `Store` is shared across
234/// threads. `emit` is intentionally infallible — the audit path must
235/// never poison a verb's result. Sink implementations swallow IO
236/// errors and degrade silently.
237pub trait AuditSink: Send + Sync {
238    fn emit(&self, event: &AuditEvent);
239}
240
241/// A sink that drops every event. Used to disable audit emission
242/// entirely without making the sink itself optional.
243pub struct NoopSink;
244
245impl AuditSink for NoopSink {
246    fn emit(&self, _event: &AuditEvent) {}
247}
248
249/// Writes each event as one JSON line to stderr.
250pub struct StderrSink;
251
252impl AuditSink for StderrSink {
253    fn emit(&self, event: &AuditEvent) {
254        let line = event.to_json_line();
255        let _ = writeln!(io::stderr(), "{line}");
256    }
257}
258
259/// Append-only file sink, one JSON event per line.
260///
261/// The file is opened in append mode with mode `0o600` on Unix (the
262/// audit stream may carry which URLs were accessed and at what time;
263/// that's not a secret value but it is process-history a hostile
264/// uid-peer should not necessarily read). A `Mutex<File>` serializes
265/// writes so concurrent emitters cannot interleave bytes.
266pub struct FileSink {
267    path: PathBuf,
268    file: Mutex<File>,
269}
270
271impl FileSink {
272    /// Open the audit file in append mode, creating it if missing.
273    ///
274    /// On Unix, sets the file mode to `0o600` after open. On other
275    /// platforms the OS-default ACL applies.
276    pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
277        let path = path.as_ref().to_path_buf();
278
279        // On Unix, set mode 0o600 at open time via OpenOptionsExt so a
280        // same-uid attacker cannot win the race between create and the
281        // subsequent chmod. The mode only applies when the file is
282        // newly created; existing files keep their current
283        // permissions (caller's responsibility — we don't widen).
284        let file;
285        #[cfg(unix)]
286        {
287            use std::os::unix::fs::OpenOptionsExt;
288            file = OpenOptions::new()
289                .create(true)
290                .append(true)
291                .mode(0o600)
292                .open(&path)?;
293        }
294        #[cfg(not(unix))]
295        {
296            file = OpenOptions::new().create(true).append(true).open(&path)?;
297        }
298
299        Ok(Self {
300            path,
301            file: Mutex::new(file),
302        })
303    }
304
305    /// Path the sink is writing to.
306    pub fn path(&self) -> &Path {
307        &self.path
308    }
309}
310
311impl AuditSink for FileSink {
312    fn emit(&self, event: &AuditEvent) {
313        let line = event.to_json_line();
314        if let Ok(mut file) = self.file.lock() {
315            let _ = writeln!(file, "{line}");
316            let _ = file.flush();
317        }
318    }
319}
320
321/// Forwards each event to the local syslog daemon via `libc::syslog`.
322///
323/// Unix-only (`#[cfg(unix)]`) — Windows has no syslog equivalent
324/// (`Event Log` / ETW are a different API surface). Windows callers
325/// should use [`FileSink`] and ship the file to whatever ingest the
326/// host runs.
327///
328/// Each call to `emit` invokes `libc::syslog(priority, "%s", line)`
329/// where `priority` is `LOG_INFO | LOG_USER`. The libc client takes
330/// care of socket-path portability (`/dev/log` on Linux,
331/// `/var/run/syslog` on macOS) and RFC3164/5424 framing — we don't
332/// reimplement either.
333///
334/// ## Process-singleton constraint
335///
336/// `openlog(3)` / `closelog(3)` mutate process-global state in the
337/// libc syslog client — a second `openlog` call replaces the first
338/// connection's ident, and `closelog` tears down the connection for
339/// **every** holder. To avoid cross-close hazards, this type
340/// deliberately:
341///
342/// 1. Leaks the `ident` `CString` (`Box::leak`) so the pointer
343///    `openlog` retained stays valid for the process lifetime, and
344/// 2. Does **not** implement `Drop` (no `closelog` call).
345///
346/// Construct at most one `SyslogSink` per process. The CLI does
347/// exactly this via `resolve_audit_sink()`. Library consumers that
348/// need a second syslog destination should wrap an existing
349/// `Arc<SyslogSink>` rather than calling `SyslogSink::open` again.
350#[cfg(unix)]
351#[derive(Debug)]
352pub struct SyslogSink {
353    priority: i32,
354}
355
356#[cfg(unix)]
357impl SyslogSink {
358    /// Open a syslog connection with `ident` (program name shown in
359    /// log entries). Default priority: `LOG_INFO | LOG_USER`.
360    ///
361    /// Returns `Err` only if `ident` contains an interior NUL. The
362    /// underlying `openlog(3)` is infallible — it does not perform
363    /// I/O until the first `syslog(3)` call.
364    pub fn open(ident: &str) -> io::Result<Self> {
365        let cstr = std::ffi::CString::new(ident).map_err(|e| {
366            io::Error::new(
367                io::ErrorKind::InvalidInput,
368                format!("syslog ident contains NUL: {e}"),
369            )
370        })?;
371        // The libc syslog client retains `ident` by pointer for the
372        // life of the connection. Leak the CString so the pointer
373        // stays valid for the process lifetime — see the type's
374        // doc-comment for the singleton rationale.
375        let leaked: &'static std::ffi::CStr = Box::leak(cstr.into_boxed_c_str());
376        // SAFETY: leaked.as_ptr() is valid for 'static; option = 0
377        // and facility = LOG_USER are POSIX-defined constants safe in
378        // any process state.
379        unsafe {
380            libc::openlog(leaked.as_ptr(), 0, libc::LOG_USER);
381        }
382        Ok(Self {
383            priority: libc::LOG_INFO | libc::LOG_USER,
384        })
385    }
386}
387
388#[cfg(unix)]
389impl AuditSink for SyslogSink {
390    fn emit(&self, event: &AuditEvent) {
391        let line = event.to_json_line();
392        // libc::syslog expects a NUL-terminated C string. Constructing
393        // a CString allocates; that's acceptable on the audit path
394        // (one allocation per event, off the hot secret-fetch path).
395        // If the line contains an interior NUL (it can't, JSON has no
396        // NUL bytes by construction) the emit silently drops.
397        if let Ok(c) = std::ffi::CString::new(line) {
398            // SAFETY: priority is a valid combined facility|level
399            // bitmask; format is the literal "%s\0" with one
400            // matching `*const c_char` argument; `c.as_ptr()` is
401            // valid for the duration of the call. No interior NUL
402            // (CString::new enforces).
403            unsafe {
404                libc::syslog(self.priority, c"%s".as_ptr(), c.as_ptr());
405            }
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::sync::Arc;
414
415    #[test]
416    fn verb_labels_are_stable_strings() {
417        // Soft guard against accidental relabeling — every verb's
418        // start/done pair must be discoverable as a `*.start` /
419        // `*.done` pattern.
420        for v in [
421            Verb::Get,
422            Verb::Put,
423            Verb::List,
424            Verb::Delete,
425            Verb::Exists,
426            Verb::Cp,
427            Verb::Diff,
428        ] {
429            assert!(v.start_label().ends_with(".start"));
430            assert!(v.done_label().ends_with(".done"));
431        }
432    }
433
434    #[test]
435    fn start_event_serializes_minimal_fields() {
436        let ev = AuditEvent::start(Verb::Get, "vault");
437        let json = ev.to_json_line();
438        assert!(json.contains("\"event\":\"get.start\""));
439        assert!(json.contains("\"src_scheme\":\"vault\""));
440        assert!(json.contains("\"outcome\":\"started\""));
441        assert!(!json.contains("dst_scheme"));
442        assert!(!json.contains("error_kind"));
443    }
444
445    #[test]
446    fn done_event_with_dst_and_error_kind() {
447        let ev = AuditEvent::done(Verb::Cp, "vault", "error")
448            .with_dst_scheme("aws-sm")
449            .with_error_kind("not_found");
450        let json = ev.to_json_line();
451        assert!(json.contains("\"event\":\"cp.done\""));
452        assert!(json.contains("\"src_scheme\":\"vault\""));
453        assert!(json.contains("\"dst_scheme\":\"aws-sm\""));
454        assert!(json.contains("\"outcome\":\"error\""));
455        assert!(json.contains("\"error_kind\":\"not_found\""));
456    }
457
458    #[test]
459    fn stderr_sink_is_object_safe() {
460        let s: Arc<dyn AuditSink> = Arc::new(StderrSink);
461        s.emit(&AuditEvent::start(Verb::Get, "env"));
462    }
463
464    #[test]
465    fn noop_sink_emits_nothing_observable() {
466        let s: Arc<dyn AuditSink> = Arc::new(NoopSink);
467        s.emit(&AuditEvent::start(Verb::Get, "env"));
468    }
469
470    #[test]
471    fn file_sink_appends_one_line_per_event() {
472        let dir = tempfile::tempdir().unwrap();
473        let path = dir.path().join("audit.log");
474        let sink = FileSink::open(&path).unwrap();
475        sink.emit(&AuditEvent::start(Verb::Get, "env"));
476        sink.emit(&AuditEvent::done(Verb::Get, "env", "ok"));
477        let body = std::fs::read_to_string(&path).unwrap();
478        assert_eq!(body.lines().count(), 2);
479        assert!(body.lines().next().unwrap().contains("get.start"));
480        assert!(body.lines().nth(1).unwrap().contains("get.done"));
481    }
482
483    #[cfg(unix)]
484    #[test]
485    fn file_sink_sets_0600_on_unix() {
486        use std::os::unix::fs::PermissionsExt;
487        let dir = tempfile::tempdir().unwrap();
488        let path = dir.path().join("audit.log");
489        let _sink = FileSink::open(&path).unwrap();
490        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
491        assert_eq!(mode & 0o777, 0o600);
492    }
493
494    #[cfg(unix)]
495    #[test]
496    fn syslog_sink_constructs_and_emits_without_panic() {
497        // We cannot assert that syslogd actually received the message
498        // (no portable way to read the local syslog from a unit test
499        // without depending on the daemon being configured). What we
500        // can assert: openlog/syslog/closelog do not panic, and the
501        // sink is object-safe behind `Arc<dyn AuditSink>`. If syslogd
502        // is absent the libc client silently drops — that is the
503        // documented degrade behavior.
504        let sink: Arc<dyn AuditSink> = Arc::new(SyslogSink::open("hasp-test").expect("openlog"));
505        sink.emit(&AuditEvent::start(Verb::Get, "env"));
506        sink.emit(&AuditEvent::done(Verb::Get, "env", "ok"));
507    }
508
509    #[cfg(unix)]
510    #[test]
511    fn syslog_sink_rejects_ident_with_interior_nul() {
512        let err = SyslogSink::open("hasp\0bad").unwrap_err();
513        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
514    }
515}