1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum AuditAction {
35 Access,
37 Reveal,
39 Inject,
41 Approve,
43 Deny,
45 Timeout,
47 SensitivityDowngrade,
49 UnattendedDelivery,
51 Package,
54 ProviderInvocation,
56 Create,
58 Edit,
60 Delete,
62 ScopeGrant,
64 OutOfScopeAttempt,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct AuditEvent {
72 pub ts: String,
74 pub action: AuditAction,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub coordinate: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub environment: Option<String>,
82 pub result: String,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub origin: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub fingerprint: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub requester_note: Option<String>,
93}
94
95impl AuditEvent {
96 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 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 pub fn by(mut self, origin: Origin) -> Self {
119 self.origin = Some(origin.as_str().to_string());
120 self
121 }
122
123 pub fn with_fingerprint(mut self, truncated: impl Into<String>) -> Self {
126 self.fingerprint = Some(truncated.into());
127 self
128 }
129
130 pub fn with_note(mut self, note: &Untrusted) -> Self {
132 self.requester_note = Some(note.0.clone());
133 self
134 }
135}
136
137pub 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
146pub trait AuditSink {
149 fn record(&self, event: &AuditEvent) -> Result<(), CoreError>;
151}
152
153pub struct FileAuditSink {
155 path: PathBuf,
156}
157
158impl FileAuditSink {
159 pub fn new(path: impl Into<PathBuf>) -> Self {
162 Self { path: path.into() }
163 }
164
165 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
197pub const AUDIT_LOG: &str = "audit.log";
199
200pub 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#[derive(Debug, Clone, Default)]
222pub struct AuditQuery {
223 pub coordinate: Option<String>,
225 pub environment: Option<String>,
227 pub component: Option<String>,
229 pub since: Option<String>,
231 pub until: Option<String>,
233 pub action: Option<AuditAction>,
235}
236
237impl AuditQuery {
238 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
275pub fn query_log(events: Vec<AuditEvent>, query: &AuditQuery) -> Vec<AuditEvent> {
277 events.into_iter().filter(|e| query.matches(e)).collect()
278}
279
280pub 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
317fn 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#[derive(Default)]
342pub struct MockAuditSink {
343 events: Mutex<Vec<AuditEvent>>,
344}
345
346impl MockAuditSink {
347 pub fn new() -> Self {
349 Self::default()
350 }
351
352 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 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 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 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 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 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 assert!(table.contains("prod/db/password"));
583 assert!(table.contains("high"));
584 assert!(table.contains(&fingerprint(value)));
585 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}