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}