Skip to main content

kovra_core/
audit.rs

1//! The audit log (spec §11; invariant I12).
2//!
3//! Append-only record of every security-relevant action — access/delivery,
4//! reveal, injection, approve/deny/timeout, sensitivity downgrade, unattended
5//! delivery, provider invocation, create/edit/delete, and agent-scope grants
6//! and out-of-scope attempts. It **never** records a value, and any recorded
7//! fingerprint is the truncated one (§10.4, I12). It is detection, not
8//! prevention — the supervision that makes granting the agent autonomy
9//! comfortable.
10//!
11//! L3 backs it with a JSON-lines file at `~/.vaults/audit.log` (the §11
12//! literal). L12 (`kovra audit`, KOV-20) adds the queryable view: [`read_log`] +
13//! [`query_log`] + [`render_log`], annotated with sensitivity from the redb
14//! metadata index (ADR-0001, a rebuildable cache). The view is value-free —
15//! coordinates, truncated fingerprints, sensitivity, timestamps, and origin
16//! only (I11/I12).
17
18use std::fs::OpenOptions;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21use std::sync::Mutex;
22
23use serde::{Deserialize, Serialize};
24
25use crate::clock::Clock;
26use crate::confirm::{ConfirmOutcome, Untrusted};
27use crate::error::CoreError;
28use crate::scope::Origin;
29use crate::store;
30
31/// The kind of action recorded (§11).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum AuditAction {
35    /// Access / delivery of a value through an operation.
36    Access,
37    /// Plaintext revealed (into a context).
38    Reveal,
39    /// Value injected into a child process.
40    Inject,
41    /// A confirmation was approved.
42    Approve,
43    /// A confirmation was denied.
44    Deny,
45    /// A confirmation timed out (treated as denial).
46    Timeout,
47    /// A secret's sensitivity was lowered (I5 deliberate, audited downgrade).
48    SensitivityDowngrade,
49    /// An unattended (token) delivery occurred.
50    UnattendedDelivery,
51    /// An encrypted package was sealed (L7, §7) — records the env/component
52    /// scope + entry count, never a value (I12).
53    Package,
54    /// An external provider was invoked to materialize a reference.
55    ProviderInvocation,
56    /// A secret was created.
57    Create,
58    /// A secret was edited.
59    Edit,
60    /// A secret was deleted.
61    Delete,
62    /// An agent scope was granted.
63    ScopeGrant,
64    /// An out-of-scope coordinate was attempted (I13).
65    OutOfScopeAttempt,
66}
67
68/// One append-only audit entry. **No field ever holds a value** (I12); any
69/// fingerprint is the truncated form (§10.4).
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct AuditEvent {
72    /// RFC-3339 UTC timestamp.
73    pub ts: String,
74    /// What happened.
75    pub action: AuditAction,
76    /// The coordinate acted on (an address, never a value).
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub coordinate: Option<String>,
79    /// Environment.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub environment: Option<String>,
82    /// Outcome / result text (e.g. `allowed`, `denied:McpCriticalForbidden`).
83    pub result: String,
84    /// Who initiated it (`agent` / `human`).
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub origin: Option<String>,
87    /// Truncated fingerprint (§10.4); never the full hash, never the value.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub fingerprint: Option<String>,
90    /// Requester-supplied note, segregated as untrusted (mirrors I16).
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub requester_note: Option<String>,
93}
94
95impl AuditEvent {
96    /// Start an event stamped `now` by the clock.
97    pub fn new(clock: &dyn Clock, action: AuditAction, result: impl Into<String>) -> Self {
98        Self {
99            ts: clock.now_rfc3339(),
100            action,
101            coordinate: None,
102            environment: None,
103            result: result.into(),
104            origin: None,
105            fingerprint: None,
106            requester_note: None,
107        }
108    }
109
110    /// Record which coordinate (address) and environment the event concerns.
111    pub fn at(mut self, coordinate: impl Into<String>, environment: impl Into<String>) -> Self {
112        self.coordinate = Some(coordinate.into());
113        self.environment = Some(environment.into());
114        self
115    }
116
117    /// Record the initiating origin.
118    pub fn by(mut self, origin: Origin) -> Self {
119        self.origin = Some(origin.as_str().to_string());
120        self
121    }
122
123    /// Record a **truncated** fingerprint (callers must pass the truncated form
124    /// from [`crate::fingerprint`], never a full hash or a value).
125    pub fn with_fingerprint(mut self, truncated: impl Into<String>) -> Self {
126        self.fingerprint = Some(truncated.into());
127        self
128    }
129
130    /// Attach a requester note, stored as the (untrusted) text.
131    pub fn with_note(mut self, note: &Untrusted) -> Self {
132        self.requester_note = Some(note.0.clone());
133        self
134    }
135}
136
137/// A confirmation outcome rendered as an audit result string.
138pub fn outcome_result(outcome: ConfirmOutcome) -> &'static str {
139    match outcome {
140        ConfirmOutcome::Approved => "approved",
141        ConfirmOutcome::Denied => "denied",
142        ConfirmOutcome::TimedOut => "timeout",
143    }
144}
145
146/// Where audit events go. The store/policy depend on this trait, so they are
147/// testable with [`MockAuditSink`]; production uses [`FileAuditSink`].
148pub trait AuditSink {
149    /// Append an event. Append-only — never updates or deletes.
150    fn record(&self, event: &AuditEvent) -> Result<(), CoreError>;
151}
152
153/// Append-only JSON-lines sink at a file path (default `~/.vaults/audit.log`).
154pub struct FileAuditSink {
155    path: PathBuf,
156}
157
158impl FileAuditSink {
159    /// A sink writing to `path`. The parent directory is created `0700` and the
160    /// log file `0600` on first write.
161    pub fn new(path: impl Into<PathBuf>) -> Self {
162        Self { path: path.into() }
163    }
164
165    /// The conventional audit log path under a registry root: `<root>/audit.log`.
166    pub fn under_root(root: &Path) -> Self {
167        Self::new(root.join("audit.log"))
168    }
169}
170
171impl AuditSink for FileAuditSink {
172    fn record(&self, event: &AuditEvent) -> Result<(), CoreError> {
173        if let Some(parent) = self.path.parent() {
174            store::ensure_dir(parent)?;
175        }
176        let existed = self.path.exists();
177        let mut line =
178            serde_json::to_string(event).map_err(|e| CoreError::Serialization(e.to_string()))?;
179        line.push('\n');
180
181        let mut file = OpenOptions::new()
182            .create(true)
183            .append(true)
184            .open(&self.path)
185            .map_err(|e| CoreError::Audit(format!("open audit log: {e}")))?;
186        if !existed {
187            store::restrict(&self.path, 0o600)?;
188        }
189        file.write_all(line.as_bytes())
190            .map_err(|e| CoreError::Audit(format!("append audit log: {e}")))?;
191        file.sync_all()
192            .map_err(|e| CoreError::Audit(format!("fsync audit log: {e}")))?;
193        Ok(())
194    }
195}
196
197/// The conventional audit-log filename under a registry root.
198pub const AUDIT_LOG: &str = "audit.log";
199
200/// Read an append-only audit log (JSON lines) into events, in file
201/// (chronological) order. **Tolerant**: a malformed or partial trailing line is
202/// skipped rather than failing the whole read (mirrors the store's tolerant
203/// loader). A missing log is an empty history, not an error.
204pub fn read_log(path: &Path) -> Result<Vec<AuditEvent>, CoreError> {
205    let content = match std::fs::read_to_string(path) {
206        Ok(c) => c,
207        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
208        Err(e) => return Err(CoreError::Audit(format!("read audit log: {e}"))),
209    };
210    Ok(content
211        .lines()
212        .filter(|l| !l.trim().is_empty())
213        .filter_map(|l| serde_json::from_str::<AuditEvent>(l).ok())
214        .collect())
215}
216
217/// A filter over audit events for `kovra audit` (L12). All set conditions AND
218/// together; a `None` field is unconstrained. Time bounds compare RFC-3339
219/// strings, which sort chronologically for the UTC `Z` timestamps the sink
220/// writes.
221#[derive(Debug, Clone, Default)]
222pub struct AuditQuery {
223    /// Exact coordinate (`env/component/key`).
224    pub coordinate: Option<String>,
225    /// Environment segment.
226    pub environment: Option<String>,
227    /// Component segment (the middle of the coordinate).
228    pub component: Option<String>,
229    /// Only events at/after this RFC-3339 instant (inclusive).
230    pub since: Option<String>,
231    /// Only events at/before this RFC-3339 instant (inclusive).
232    pub until: Option<String>,
233    /// Only this action kind.
234    pub action: Option<AuditAction>,
235}
236
237impl AuditQuery {
238    /// Whether `ev` satisfies every set constraint.
239    pub fn matches(&self, ev: &AuditEvent) -> bool {
240        if let Some(c) = &self.coordinate
241            && ev.coordinate.as_deref() != Some(c.as_str())
242        {
243            return false;
244        }
245        if let Some(e) = &self.environment
246            && ev.environment.as_deref() != Some(e.as_str())
247        {
248            return false;
249        }
250        if let Some(comp) = &self.component {
251            let got = ev.coordinate.as_deref().and_then(|c| c.split('/').nth(1));
252            if got != Some(comp.as_str()) {
253                return false;
254            }
255        }
256        if let Some(s) = &self.since
257            && ev.ts.as_str() < s.as_str()
258        {
259            return false;
260        }
261        if let Some(u) = &self.until
262            && ev.ts.as_str() > u.as_str()
263        {
264            return false;
265        }
266        if let Some(a) = &self.action
267            && ev.action != *a
268        {
269            return false;
270        }
271        true
272    }
273}
274
275/// Filter `events` by `query`, preserving chronological order.
276pub fn query_log(events: Vec<AuditEvent>, query: &AuditQuery) -> Vec<AuditEvent> {
277    events.into_iter().filter(|e| query.matches(e)).collect()
278}
279
280/// Render events as value-free rows: timestamp, action, coordinate, sensitivity
281/// (from the redb metadata index — see [`crate::Index`]), origin, **truncated**
282/// fingerprint, and result. The [`AuditEvent`] type holds neither a value nor a
283/// full fingerprint (I12), and `sensitivity_by_coord` is metadata only, so the
284/// render path cannot leak a value or a full hash (I11/I12).
285pub fn render_log(
286    events: &[AuditEvent],
287    sensitivity_by_coord: &std::collections::BTreeMap<String, crate::sensitivity::Sensitivity>,
288) -> String {
289    let mut out = String::new();
290    out.push_str(
291        "TIMESTAMP             ACTION                COORDINATE                 SENS    ORIGIN  FPR       RESULT\n",
292    );
293    for ev in events {
294        let coord = ev.coordinate.as_deref().unwrap_or("-");
295        let sens = ev
296            .coordinate
297            .as_deref()
298            .and_then(|c| sensitivity_by_coord.get(c))
299            .map(|s| format!("{s:?}").to_lowercase())
300            .unwrap_or_else(|| "-".to_string());
301        let origin = ev.origin.as_deref().unwrap_or("-");
302        let fpr = ev.fingerprint.as_deref().unwrap_or("-");
303        out.push_str(&format!(
304            "{:<21} {:<21} {:<26} {:<7} {:<7} {:<9} {}\n",
305            ev.ts,
306            action_label(ev.action),
307            coord,
308            sens,
309            origin,
310            fpr,
311            ev.result
312        ));
313    }
314    out
315}
316
317/// The kebab-case label for an action (e.g. `provider-invocation`). Mirrors the
318/// `#[serde(rename_all = "kebab-case")]` on-disk spelling without allocating or
319/// invoking the serializer per row.
320fn action_label(action: AuditAction) -> &'static str {
321    match action {
322        AuditAction::Access => "access",
323        AuditAction::Reveal => "reveal",
324        AuditAction::Inject => "inject",
325        AuditAction::Approve => "approve",
326        AuditAction::Deny => "deny",
327        AuditAction::Timeout => "timeout",
328        AuditAction::SensitivityDowngrade => "sensitivity-downgrade",
329        AuditAction::UnattendedDelivery => "unattended-delivery",
330        AuditAction::Package => "package",
331        AuditAction::ProviderInvocation => "provider-invocation",
332        AuditAction::Create => "create",
333        AuditAction::Edit => "edit",
334        AuditAction::Delete => "delete",
335        AuditAction::ScopeGrant => "scope-grant",
336        AuditAction::OutOfScopeAttempt => "out-of-scope-attempt",
337    }
338}
339
340/// In-memory sink for tests.
341#[derive(Default)]
342pub struct MockAuditSink {
343    events: Mutex<Vec<AuditEvent>>,
344}
345
346impl MockAuditSink {
347    /// A new, empty in-memory sink.
348    pub fn new() -> Self {
349        Self::default()
350    }
351
352    /// A snapshot of recorded events.
353    pub fn events(&self) -> Vec<AuditEvent> {
354        self.events.lock().expect("audit mutex poisoned").clone()
355    }
356}
357
358impl AuditSink for MockAuditSink {
359    fn record(&self, event: &AuditEvent) -> Result<(), CoreError> {
360        self.events
361            .lock()
362            .expect("audit mutex poisoned")
363            .push(event.clone());
364        Ok(())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::clock::MockClock;
372    use crate::fingerprint::fingerprint;
373
374    #[test]
375    fn mock_sink_records_events_in_order() {
376        let clock = MockClock::default();
377        let sink = MockAuditSink::new();
378        sink.record(&AuditEvent::new(&clock, AuditAction::Create, "ok"))
379            .unwrap();
380        sink.record(&AuditEvent::new(
381            &clock,
382            AuditAction::OutOfScopeAttempt,
383            "blocked",
384        ))
385        .unwrap();
386        let evs = sink.events();
387        assert_eq!(evs.len(), 2);
388        assert_eq!(evs[0].action, AuditAction::Create);
389        assert_eq!(evs[1].action, AuditAction::OutOfScopeAttempt);
390    }
391
392    #[test]
393    fn event_serialization_holds_no_value_only_truncated_fingerprint() {
394        let clock = MockClock::default();
395        let value = "super-secret";
396        let ev = AuditEvent::new(&clock, AuditAction::Reveal, "allowed")
397            .at("prod/db/password", "prod")
398            .by(Origin::Human)
399            .with_fingerprint(fingerprint(value.as_bytes()));
400        let json = serde_json::to_string(&ev).unwrap();
401        assert!(
402            !json.contains(value),
403            "audit event must not contain the value"
404        );
405        // fingerprint present and truncated (8 hex chars), never the full hash
406        assert!(json.contains(&fingerprint(value.as_bytes())));
407        let full = blake3::hash(value.as_bytes()).to_hex().to_string();
408        assert!(!json.contains(&full));
409        // timestamp from the (mock) clock
410        assert!(ev.ts.ends_with('Z'));
411    }
412
413    #[test]
414    fn file_sink_appends_jsonl_and_is_0600() {
415        let dir = tempfile::tempdir().unwrap();
416        let clock = MockClock::default();
417        let sink = FileAuditSink::under_root(dir.path());
418        sink.record(&AuditEvent::new(&clock, AuditAction::Create, "ok"))
419            .unwrap();
420        sink.record(&AuditEvent::new(&clock, AuditAction::Delete, "ok"))
421            .unwrap();
422
423        let path = dir.path().join("audit.log");
424        let body = std::fs::read_to_string(&path).unwrap();
425        let lines: Vec<&str> = body.lines().collect();
426        assert_eq!(lines.len(), 2, "one JSON object per line, appended");
427        // each line is valid JSON
428        for line in &lines {
429            let _: AuditEvent = serde_json::from_str(line).unwrap();
430        }
431
432        #[cfg(unix)]
433        {
434            use std::os::unix::fs::PermissionsExt;
435            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
436            assert_eq!(mode & 0o777, 0o600);
437        }
438    }
439
440    // ── KOV-20: read / query / render the audit view ──
441
442    fn write_log(dir: &std::path::Path, events: &[AuditEvent]) {
443        let sink = FileAuditSink::under_root(dir);
444        for ev in events {
445            sink.record(ev).unwrap();
446        }
447    }
448
449    fn ev(ts: &str, action: AuditAction, coord: &str, env: &str) -> AuditEvent {
450        AuditEvent {
451            ts: ts.to_string(),
452            action,
453            coordinate: Some(coord.to_string()),
454            environment: Some(env.to_string()),
455            result: "ok".to_string(),
456            origin: Some("human".to_string()),
457            fingerprint: None,
458            requester_note: None,
459        }
460    }
461
462    #[test]
463    fn read_log_is_tolerant_and_chronological() {
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join(AUDIT_LOG);
466        // valid lines + a garbage line in the middle (must be skipped).
467        let good1 = serde_json::to_string(&ev(
468            "2026-06-01T00:00:00Z",
469            AuditAction::Create,
470            "dev/db/password",
471            "dev",
472        ))
473        .unwrap();
474        let good2 = serde_json::to_string(&ev(
475            "2026-06-01T00:00:01Z",
476            AuditAction::Inject,
477            "dev/db/password",
478            "dev",
479        ))
480        .unwrap();
481        std::fs::write(&path, format!("{good1}\n{{not json}}\n{good2}\n")).unwrap();
482
483        let events = read_log(&path).unwrap();
484        assert_eq!(events.len(), 2, "the malformed line is skipped");
485        assert_eq!(events[0].action, AuditAction::Create);
486        assert_eq!(events[1].action, AuditAction::Inject);
487    }
488
489    #[test]
490    fn missing_log_is_empty_history() {
491        let dir = tempfile::tempdir().unwrap();
492        assert!(read_log(&dir.path().join("nope.log")).unwrap().is_empty());
493    }
494
495    #[test]
496    fn query_filters_by_coordinate_component_env_time_and_action() {
497        let dir = tempfile::tempdir().unwrap();
498        write_log(
499            dir.path(),
500            &[
501                ev(
502                    "2026-06-01T00:00:00Z",
503                    AuditAction::Create,
504                    "dev/db/password",
505                    "dev",
506                ),
507                ev(
508                    "2026-06-01T00:00:05Z",
509                    AuditAction::Inject,
510                    "dev/db/password",
511                    "dev",
512                ),
513                ev(
514                    "2026-06-02T00:00:00Z",
515                    AuditAction::Reveal,
516                    "prod/api/key",
517                    "prod",
518                ),
519            ],
520        );
521        let all = read_log(&dir.path().join(AUDIT_LOG)).unwrap();
522
523        let by_env = query_log(
524            all.clone(),
525            &AuditQuery {
526                environment: Some("prod".into()),
527                ..Default::default()
528            },
529        );
530        assert_eq!(by_env.len(), 1);
531        assert_eq!(by_env[0].action, AuditAction::Reveal);
532
533        let by_component = query_log(
534            all.clone(),
535            &AuditQuery {
536                component: Some("db".into()),
537                ..Default::default()
538            },
539        );
540        assert_eq!(by_component.len(), 2);
541
542        let by_action = query_log(
543            all.clone(),
544            &AuditQuery {
545                action: Some(AuditAction::Inject),
546                ..Default::default()
547            },
548        );
549        assert_eq!(by_action.len(), 1);
550
551        let by_window = query_log(
552            all,
553            &AuditQuery {
554                since: Some("2026-06-01T00:00:03Z".into()),
555                until: Some("2026-06-01T23:59:59Z".into()),
556                ..Default::default()
557            },
558        );
559        assert_eq!(by_window.len(), 1, "only the 00:00:05 inject is in window");
560        assert_eq!(by_window[0].action, AuditAction::Inject);
561    }
562
563    #[test]
564    fn render_is_value_free_and_only_truncated_fingerprint() {
565        use crate::sensitivity::Sensitivity;
566        let value = b"super-secret-value";
567        let event = AuditEvent {
568            ts: "2026-06-01T00:00:00Z".to_string(),
569            action: AuditAction::Reveal,
570            coordinate: Some("prod/db/password".to_string()),
571            environment: Some("prod".to_string()),
572            result: "allowed".to_string(),
573            origin: Some("human".to_string()),
574            fingerprint: Some(fingerprint(value)),
575            requester_note: None,
576        };
577        let mut sens = std::collections::BTreeMap::new();
578        sens.insert("prod/db/password".to_string(), Sensitivity::High);
579
580        let table = render_log(&[event], &sens);
581        // coordinate, sensitivity, origin, truncated fingerprint all present
582        assert!(table.contains("prod/db/password"));
583        assert!(table.contains("high"));
584        assert!(table.contains(&fingerprint(value)));
585        // anti-leak: never the value, never the FULL fingerprint hash (I11/I12)
586        assert!(!table.contains("super-secret-value"));
587        let full = blake3::hash(value).to_hex().to_string();
588        assert!(
589            !table.contains(&full),
590            "render must not emit a full fingerprint"
591        );
592    }
593}