1use std::io::Write;
30use std::path::{Path, PathBuf};
31
32use chrono::{DateTime, Utc};
33use hmac::{Hmac, Mac};
34use rand::RngCore;
35use serde::{Deserialize, Serialize};
36use sha2::Sha256;
37use thiserror::Error;
38use uuid::Uuid;
39
40fn bytes_to_hex(bytes: &[u8]) -> String {
41 bytes.iter().map(|b| format!("{b:02x}")).collect()
42}
43
44use crate::contracts::{
45 AuthorityContract, AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision,
46 AuthorityTargetEvaluation, AuthorityTrustLevel,
47};
48use crate::deny_reason::DenyReason;
49use crate::errors::{SafeError, SafeResult};
50use crate::rbac::RbacProfile;
51
52type HmacSha256 = Hmac<Sha256>;
53
54#[derive(Debug, Error, PartialEq, Eq)]
56pub enum AuditVerifyError {
57 #[error("audit chain broken at entry index {at_entry} (id: {entry_id})")]
60 ChainBroken {
61 at_entry: usize,
63 entry_id: String,
65 },
66
67 #[error("could not read audit log: {0}")]
69 Io(String),
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum AuditStatus {
76 Success,
78 Failure,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub struct AuditContext {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub exec: Option<AuditExecContext>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub cellos: Option<AuditCellosContext>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub clipboard: Option<AuditClipboardContext>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub reveal: Option<AuditRevealContext>,
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct AuditCellosContext {
105 pub cellos_cell_id: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub cell_token: Option<String>,
111}
112
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
119pub struct AuditClipboardContext {
120 pub ttl_secs: u64,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub reason: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub excluded_from_history: Option<bool>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub cleared_verified: Option<bool>,
141}
142
143impl AuditContext {
144 pub fn from_exec(exec: AuditExecContext) -> Self {
145 Self {
146 exec: Some(exec),
147 ..Default::default()
148 }
149 }
150
151 pub fn from_cellos(cellos: AuditCellosContext) -> Self {
152 Self {
153 cellos: Some(cellos),
154 ..Default::default()
155 }
156 }
157
158 pub fn from_clipboard(clipboard: AuditClipboardContext) -> Self {
159 Self {
160 clipboard: Some(clipboard),
161 ..Default::default()
162 }
163 }
164
165 pub fn from_reveal(reveal: AuditRevealContext) -> Self {
166 Self {
167 reveal: Some(reveal),
168 ..Default::default()
169 }
170 }
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
180pub struct AuditRevealContext {
181 pub ttl_secs: u64,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct AuditEnvMapping {
191 pub env: String,
193 pub vault_key: String,
195}
196
197#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
199pub struct AuditExecContext {
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub contract_name: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub target: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub authority_profile: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub authority_namespace: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub trust_level: Option<AuthorityTrustLevel>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub access_profile: Option<RbacProfile>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub inherit: Option<AuthorityInheritMode>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub deny_dangerous_env: Option<bool>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub redact_output: Option<bool>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub network: Option<AuthorityNetworkPolicy>,
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub allowed_secrets: Vec<String>,
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub required_secrets: Vec<String>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub injected_secrets: Vec<String>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub missing_required_secrets: Vec<String>,
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub dropped_env_names: Vec<String>,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub env_mappings: Vec<AuditEnvMapping>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub target_allowed: Option<bool>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub target_decision: Option<AuthorityTargetDecision>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub matched_target: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub deny_reason: Option<DenyReason>,
242}
243
244impl AuditExecContext {
245 pub fn from_contract(contract: &AuthorityContract) -> Self {
248 let resolved = contract.resolved_exec_policy();
249 Self {
250 contract_name: Some(contract.name.clone()),
251 target: None,
252 authority_profile: contract.profile.clone(),
253 authority_namespace: contract.namespace.clone(),
254 trust_level: Some(resolved.trust_level),
255 access_profile: Some(resolved.access_profile),
256 inherit: Some(resolved.inherit),
257 deny_dangerous_env: Some(resolved.deny_dangerous_env),
258 redact_output: Some(resolved.redact_output),
259 network: Some(contract.network),
260 allowed_secrets: contract.allowed_secrets.clone(),
261 required_secrets: contract.required_secrets.clone(),
262 injected_secrets: Vec::new(),
263 missing_required_secrets: Vec::new(),
264 dropped_env_names: Vec::new(),
265 env_mappings: Vec::new(),
266 target_allowed: None,
267 target_decision: None,
268 matched_target: None,
269 deny_reason: None,
270 }
271 }
272
273 pub fn with_target(mut self, target: impl Into<String>) -> Self {
274 self.target = Some(target.into());
275 self
276 }
277
278 pub fn with_injected_secrets<I, S>(mut self, names: I) -> Self
279 where
280 I: IntoIterator<Item = S>,
281 S: AsRef<str>,
282 {
283 self.injected_secrets = normalize_names(names);
284 self
285 }
286
287 pub fn with_missing_required_secrets<I, S>(mut self, names: I) -> Self
288 where
289 I: IntoIterator<Item = S>,
290 S: AsRef<str>,
291 {
292 self.missing_required_secrets = normalize_names(names);
293 self
294 }
295
296 pub fn with_dropped_env_names<I, S>(mut self, names: I) -> Self
297 where
298 I: IntoIterator<Item = S>,
299 S: AsRef<str>,
300 {
301 self.dropped_env_names = normalize_names(names);
302 self
303 }
304
305 pub fn with_target_allowed(mut self, allowed: bool) -> Self {
306 self.target_allowed = Some(allowed);
307 self
308 }
309
310 pub fn with_target_evaluation(mut self, evaluation: &AuthorityTargetEvaluation) -> Self {
311 self.target_allowed = Some(evaluation.decision.is_allowed());
312 self.target_decision = Some(evaluation.decision);
313 self.matched_target = evaluation.matched_allowlist_entry.clone();
314 self
315 }
316}
317
318fn normalize_names<I, S>(names: I) -> Vec<String>
319where
320 I: IntoIterator<Item = S>,
321 S: AsRef<str>,
322{
323 let mut out = names
324 .into_iter()
325 .map(|name| name.as_ref().trim().to_string())
326 .filter(|name| !name.is_empty())
327 .collect::<Vec<_>>();
328 out.sort();
329 out.dedup();
330 out
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct AuditEntry {
356 pub id: String,
357 pub timestamp: DateTime<Utc>,
358 pub profile: String,
359 pub operation: String,
360 pub key: Option<String>,
361 pub status: AuditStatus,
362 pub message: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub context: Option<AuditContext>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub prev_entry_hmac: Option<String>,
372}
373
374impl AuditEntry {
375 pub fn success(profile: &str, operation: &str, key: Option<&str>) -> Self {
377 Self {
378 id: Uuid::new_v4().to_string(),
379 timestamp: Utc::now(),
380 profile: profile.to_string(),
381 operation: operation.to_string(),
382 key: key.map(str::to_string),
383 status: AuditStatus::Success,
384 message: None,
385 context: None,
386 prev_entry_hmac: None,
387 }
388 }
389
390 pub fn failure(profile: &str, operation: &str, key: Option<&str>, message: &str) -> Self {
392 Self {
393 id: Uuid::new_v4().to_string(),
394 timestamp: Utc::now(),
395 profile: profile.to_string(),
396 operation: operation.to_string(),
397 key: key.map(str::to_string),
398 status: AuditStatus::Failure,
399 message: Some(message.to_string()),
400 context: None,
401 prev_entry_hmac: None,
402 }
403 }
404
405 pub fn with_context(mut self, context: AuditContext) -> Self {
407 self.context = Some(context);
408 self
409 }
410}
411
412pub fn compute_entry_hmac(entry: &AuditEntry, key: &[u8; 32]) -> String {
418 let json = serde_json::to_string(entry).expect("AuditEntry is always serializable");
419 let mut mac =
420 HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length via padding");
421 mac.update(json.as_bytes());
422 let result = mac.finalize().into_bytes();
423 bytes_to_hex(&result)
424}
425
426fn derive_chain_key() -> [u8; 32] {
428 let mut key = [0u8; 32];
429 rand::rngs::OsRng.fill_bytes(&mut key);
430 key
431}
432
433pub struct AuditLog {
441 path: PathBuf,
442 chain_key: [u8; 32],
444 prev_hmac: std::cell::Cell<Option<String>>,
448 bootstrapped: std::cell::Cell<bool>,
450}
451
452impl AuditLog {
453 pub fn new(path: &Path) -> Self {
455 Self {
456 path: path.to_path_buf(),
457 chain_key: derive_chain_key(),
458 prev_hmac: std::cell::Cell::new(None),
459 bootstrapped: std::cell::Cell::new(false),
460 }
461 }
462
463 fn bootstrap_if_needed(&self) {
468 if self.bootstrapped.get() {
469 return;
470 }
471 self.bootstrapped.set(true);
472 self.prev_hmac.set(None);
476 }
477
478 pub fn append(&self, entry: &AuditEntry) -> SafeResult<()> {
484 self.bootstrap_if_needed();
485
486 if let Some(parent) = self.path.parent() {
487 std::fs::create_dir_all(parent)?;
488 #[cfg(unix)]
489 {
490 use std::os::unix::fs::PermissionsExt;
491 let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
492 }
493 }
494
495 let mut chained = entry.clone();
497 chained.prev_entry_hmac = self.prev_hmac.take();
498
499 let mut line = serde_json::to_string(&chained).map_err(SafeError::Serialization)?;
500 line.push('\n');
501
502 let mut opts = std::fs::OpenOptions::new();
503 opts.create(true).append(true);
504 #[cfg(unix)]
505 {
506 use std::os::unix::fs::OpenOptionsExt;
507 opts.mode(0o600);
508 }
509 let mut file = opts.open(&self.path)?;
510 file.write_all(line.as_bytes())?;
511
512 let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
514 self.prev_hmac.set(Some(next_hmac));
515
516 Ok(())
517 }
518
519 pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
530 verify_chain_with_key(&self.path, &self.chain_key)
531 }
532
533 pub fn read(&self, limit: Option<usize>) -> SafeResult<Vec<AuditEntry>> {
535 if !self.path.exists() {
536 return Ok(Vec::new());
537 }
538 let content = std::fs::read_to_string(&self.path)?;
539 let mut entries: Vec<AuditEntry> = content
540 .lines()
541 .filter(|l| !l.trim().is_empty())
542 .filter_map(|l| serde_json::from_str(l).ok())
543 .collect();
544 entries.reverse(); if let Some(n) = limit {
546 entries.truncate(n);
547 }
548 Ok(entries)
549 }
550
551 pub fn explain(&self, limit: Option<usize>) -> SafeResult<crate::audit_explain::AuditTimeline> {
553 Ok(crate::audit_explain::explain_entries(&self.read(limit)?))
554 }
555
556 pub fn last_successful_operation(
559 &self,
560 profile: &str,
561 operation: &str,
562 scan_limit: usize,
563 ) -> SafeResult<Option<DateTime<Utc>>> {
564 let entries = self.read(Some(scan_limit))?;
565 Ok(entries
566 .into_iter()
567 .find(|e| {
568 e.profile == profile
569 && e.operation == operation
570 && matches!(e.status, AuditStatus::Success)
571 })
572 .map(|e| e.timestamp))
573 }
574
575 pub fn filter_audit(
584 &self,
585 since: Option<DateTime<Utc>>,
586 until: Option<DateTime<Utc>>,
587 command: Option<&str>,
588 ) -> SafeResult<Vec<AuditEntry>> {
589 let entries = self.read(None)?;
590 Ok(entries
591 .into_iter()
592 .filter(|e| {
593 if let Some(s) = since {
594 if e.timestamp < s {
595 return false;
596 }
597 }
598 if let Some(u) = until {
599 if e.timestamp > u {
600 return false;
601 }
602 }
603 if let Some(cmd) = command {
604 if e.operation != cmd {
605 return false;
606 }
607 }
608 true
609 })
610 .collect())
611 }
612
613 pub fn prune_audit_before(&self, before: DateTime<Utc>) -> SafeResult<usize> {
623 if !self.path.exists() {
624 return Ok(0);
625 }
626 let content = std::fs::read_to_string(&self.path)?;
627 let mut kept: Vec<&str> = Vec::new();
628 let mut removed = 0usize;
629 for line in content.lines() {
630 if line.trim().is_empty() {
631 continue;
632 }
633 match serde_json::from_str::<AuditEntry>(line) {
634 Ok(entry) if entry.timestamp < before => {
635 removed += 1;
636 }
637 _ => {
638 kept.push(line);
639 }
640 }
641 }
642 if removed == 0 {
643 return Ok(0);
644 }
645 let new_content = kept.join("\n") + if kept.is_empty() { "" } else { "\n" };
646 let tmp = self.path.with_extension("jsonl.tmp");
647 std::fs::write(&tmp, &new_content)?;
648 if let Err(e) = std::fs::rename(&tmp, &self.path) {
650 let _ = std::fs::remove_file(&tmp); return Err(std::io::Error::other(format!(
652 "audit prune: failed to rename temp file — log unchanged: {e}"
653 ))
654 .into());
655 }
656 let profile_name = self
660 .path
661 .file_stem()
662 .and_then(|s| s.to_str())
663 .unwrap_or("unknown");
664 let mut sentinel = AuditEntry::success(profile_name, "audit-prune", None);
665 sentinel.message = Some(format!("pruned {removed} entries older than {before}"));
666 self.append(&sentinel)?;
667 Ok(removed)
668 }
669}
670
671pub fn audit_log_size_bytes(path: &Path) -> SafeResult<u64> {
675 match std::fs::metadata(path) {
676 Ok(meta) => Ok(meta.len()),
677 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
678 Err(e) => Err(SafeError::Io(e)),
679 }
680}
681
682fn verify_chain_with_key(path: &Path, key: &[u8; 32]) -> Result<(), AuditVerifyError> {
688 if !path.exists() {
689 return Ok(());
690 }
691 let content = std::fs::read_to_string(path).map_err(|e| AuditVerifyError::Io(e.to_string()))?;
692
693 let entries: Vec<AuditEntry> = content
694 .lines()
695 .filter(|l| !l.trim().is_empty())
696 .filter_map(|l| serde_json::from_str(l).ok())
697 .collect();
698
699 let mut prev_computed: Option<String> = None;
703
704 for (idx, entry) in entries.iter().enumerate() {
705 match &entry.prev_entry_hmac {
706 None => {
707 prev_computed = Some(compute_entry_hmac(entry, key));
710 }
711 Some(stored_hmac) => {
712 match &prev_computed {
714 Some(expected) if expected == stored_hmac => {
715 prev_computed = Some(compute_entry_hmac(entry, key));
716 }
717 _ => {
718 return Err(AuditVerifyError::ChainBroken {
719 at_entry: idx,
720 entry_id: entry.id.clone(),
721 });
722 }
723 }
724 }
725 }
726 }
727
728 Ok(())
729}
730
731#[cfg(test)]
734mod tests {
735 use super::*;
736 use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
737 use tempfile::tempdir;
738
739 #[test]
740 fn append_and_read_roundtrip() {
741 let dir = tempdir().unwrap();
742 let log = AuditLog::new(&dir.path().join("t.jsonl"));
743 log.append(&AuditEntry::success("dev", "set", Some("DB_PASS")))
744 .unwrap();
745 log.append(&AuditEntry::success("dev", "get", Some("DB_PASS")))
746 .unwrap();
747 log.append(&AuditEntry::failure(
748 "dev",
749 "get",
750 Some("MISSING"),
751 "not found",
752 ))
753 .unwrap();
754 let entries = log.read(None).unwrap();
755 assert_eq!(entries.len(), 3);
756 assert_eq!(entries[0].status, AuditStatus::Failure); }
758
759 #[test]
760 fn limit_truncates() {
761 let dir = tempdir().unwrap();
762 let log = AuditLog::new(&dir.path().join("t.jsonl"));
763 for i in 0..10 {
764 log.append(&AuditEntry::success("dev", "op", Some(&format!("K{i}"))))
765 .unwrap();
766 }
767 assert_eq!(log.read(Some(3)).unwrap().len(), 3);
768 }
769
770 #[test]
771 fn nonexistent_log_returns_empty() {
772 let dir = tempdir().unwrap();
773 let log = AuditLog::new(&dir.path().join("does-not-exist.jsonl"));
774 assert!(log.read(None).unwrap().is_empty());
775 }
776
777 #[test]
778 fn ids_are_unique() {
779 let e1 = AuditEntry::success("p", "op", None);
780 let e2 = AuditEntry::success("p", "op", None);
781 assert_ne!(e1.id, e2.id);
782 }
783
784 #[test]
785 fn last_successful_operation_finds_rotate() {
786 let dir = tempdir().unwrap();
787 let log = AuditLog::new(&dir.path().join("a.jsonl"));
788 log.append(&AuditEntry::success("dev", "set", Some("K")))
789 .unwrap();
790 log.append(&AuditEntry::success("dev", "rotate", None))
791 .unwrap();
792 log.append(&AuditEntry::success("dev", "get", Some("K")))
793 .unwrap();
794 assert!(log
795 .last_successful_operation("dev", "rotate", 100)
796 .unwrap()
797 .is_some());
798 assert!(log
799 .last_successful_operation("dev", "missing-op", 100)
800 .unwrap()
801 .is_none());
802 }
803
804 #[test]
810 fn hmac_chain_intact_and_detects_tampering() {
811 let dir = tempdir().unwrap();
812 let path = dir.path().join("chain.jsonl");
813 let log = AuditLog::new(&path);
814
815 log.append(&AuditEntry::success("dev", "set", Some("A")))
816 .unwrap();
817 log.append(&AuditEntry::success("dev", "get", Some("A")))
818 .unwrap();
819 log.append(&AuditEntry::failure(
820 "dev",
821 "get",
822 Some("MISSING"),
823 "not found",
824 ))
825 .unwrap();
826
827 log.verify_chain()
829 .expect("chain must be intact after write");
830
831 let content = std::fs::read_to_string(&path).unwrap();
833 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
834
835 let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
837 tampered.operation = "TAMPERED".to_string();
838 lines[1] = serde_json::to_string(&tampered).unwrap();
840
841 let tampered_content = lines.join("\n") + "\n";
842 std::fs::write(&path, tampered_content).unwrap();
843
844 let err = log
847 .verify_chain()
848 .expect_err("tampered log must fail verification");
849 match err {
850 AuditVerifyError::ChainBroken { at_entry, .. } => {
851 assert_eq!(
852 at_entry, 2,
853 "chain should break at the entry after the tampered one"
854 );
855 }
856 other => panic!("unexpected error: {other}"),
857 }
858 }
859
860 #[test]
862 fn first_entry_has_no_prev_hmac() {
863 let dir = tempdir().unwrap();
864 let path = dir.path().join("first.jsonl");
865 let log = AuditLog::new(&path);
866 log.append(&AuditEntry::success("dev", "set", Some("K")))
867 .unwrap();
868
869 let content = std::fs::read_to_string(&path).unwrap();
870 let entry: AuditEntry = serde_json::from_str(content.trim()).unwrap();
871 assert!(
872 entry.prev_entry_hmac.is_none(),
873 "first entry must have no prev_entry_hmac"
874 );
875 }
876
877 #[test]
879 fn subsequent_entries_carry_prev_hmac() {
880 let dir = tempdir().unwrap();
881 let path = dir.path().join("chain2.jsonl");
882 let log = AuditLog::new(&path);
883 log.append(&AuditEntry::success("dev", "set", Some("A")))
884 .unwrap();
885 log.append(&AuditEntry::success("dev", "get", Some("A")))
886 .unwrap();
887
888 let content = std::fs::read_to_string(&path).unwrap();
889 let lines: Vec<&str> = content.lines().collect();
890 let second: AuditEntry = serde_json::from_str(lines[1]).unwrap();
891 assert!(
892 second.prev_entry_hmac.is_some(),
893 "second entry must carry prev_entry_hmac"
894 );
895 }
896
897 #[test]
899 fn old_entries_deserialize_without_prev_hmac() {
900 let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
901 let entry: AuditEntry = serde_json::from_str(raw).unwrap();
902 assert!(entry.prev_entry_hmac.is_none());
903 assert!(entry.context.is_none());
904 }
905
906 #[test]
908 fn audit_integrity_contract_v2() {
909 let dir = tempdir().unwrap();
910 let log = AuditLog::new(&dir.path().join("integrity.jsonl"));
911
912 log.append(&AuditEntry::success("dev", "set", Some("A")))
913 .unwrap();
914 log.append(&AuditEntry::success("dev", "get", Some("A")))
915 .unwrap();
916 log.append(&AuditEntry::failure(
917 "dev",
918 "get",
919 Some("MISSING"),
920 "not found",
921 ))
922 .unwrap();
923
924 let entries = log.read(None).unwrap();
925 assert_eq!(entries.len(), 3, "all appended entries must be retained");
926
927 assert_eq!(entries[0].status, AuditStatus::Failure);
929 assert_eq!(entries[1].operation, "get");
930 assert_eq!(entries[2].operation, "set");
931
932 let ids: std::collections::HashSet<_> = entries.iter().map(|e| &e.id).collect();
934 assert_eq!(ids.len(), 3, "every entry must have a distinct UUID");
935
936 let mut ordered = entries.clone();
938 ordered.reverse();
939 for w in ordered.windows(2) {
940 assert!(
941 w[0].timestamp <= w[1].timestamp,
942 "timestamps should be non-decreasing in append order"
943 );
944 }
945
946 log.verify_chain()
948 .expect("integrity contract: chain must be intact");
949 }
950
951 #[test]
952 fn old_entries_deserialize_without_context() {
953 let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
954 let entry: AuditEntry = serde_json::from_str(raw).unwrap();
955 assert!(entry.context.is_none());
956 }
957
958 #[test]
961 fn filter_audit_by_command() {
962 let dir = tempdir().unwrap();
963 let log = AuditLog::new(&dir.path().join("t.jsonl"));
964 log.append(&AuditEntry::success("dev", "get", Some("A")))
965 .unwrap();
966 log.append(&AuditEntry::success("dev", "set", Some("B")))
967 .unwrap();
968 log.append(&AuditEntry::success("dev", "get", Some("C")))
969 .unwrap();
970
971 let gets = log.filter_audit(None, None, Some("get")).unwrap();
972 assert_eq!(gets.len(), 2);
973 assert!(gets.iter().all(|e| e.operation == "get"));
974
975 let sets = log.filter_audit(None, None, Some("set")).unwrap();
976 assert_eq!(sets.len(), 1);
977 }
978
979 #[test]
980 fn filter_audit_by_time_range() {
981 use chrono::Duration;
982 let dir = tempdir().unwrap();
983 let log = AuditLog::new(&dir.path().join("t.jsonl"));
984
985 let now = Utc::now();
986 let old = now - Duration::hours(2);
987 let recent = now - Duration::minutes(30);
988
989 let mut e_old = AuditEntry::success("dev", "get", Some("OLD"));
991 e_old.timestamp = old;
992 let mut e_recent = AuditEntry::success("dev", "get", Some("RECENT"));
993 e_recent.timestamp = recent;
994 let mut e_now = AuditEntry::success("dev", "set", Some("NOW"));
995 e_now.timestamp = now;
996 log.append(&e_old).unwrap();
997 log.append(&e_recent).unwrap();
998 log.append(&e_now).unwrap();
999
1000 let since_cutoff = now - Duration::hours(1);
1002 let results = log.filter_audit(Some(since_cutoff), None, None).unwrap();
1003 assert_eq!(results.len(), 2);
1004
1005 let results = log
1007 .filter_audit(None, Some(now - Duration::hours(1)), None)
1008 .unwrap();
1009 assert_eq!(results.len(), 1);
1010 assert_eq!(results[0].key.as_deref(), Some("OLD"));
1011 }
1012
1013 #[test]
1014 fn filter_audit_combined_since_and_command() {
1015 use chrono::Duration;
1016 let dir = tempdir().unwrap();
1017 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1018
1019 let now = Utc::now();
1020 let mut old_get = AuditEntry::success("dev", "get", Some("OLD"));
1021 old_get.timestamp = now - Duration::hours(3);
1022 let mut new_get = AuditEntry::success("dev", "get", Some("NEW"));
1023 new_get.timestamp = now - Duration::minutes(5);
1024 let mut new_set = AuditEntry::success("dev", "set", Some("S"));
1025 new_set.timestamp = now - Duration::minutes(5);
1026 log.append(&old_get).unwrap();
1027 log.append(&new_get).unwrap();
1028 log.append(&new_set).unwrap();
1029
1030 let results = log
1031 .filter_audit(Some(now - Duration::hours(1)), None, Some("get"))
1032 .unwrap();
1033 assert_eq!(results.len(), 1);
1034 assert_eq!(results[0].key.as_deref(), Some("NEW"));
1035 }
1036
1037 #[test]
1038 fn filter_audit_empty_log_returns_empty() {
1039 let dir = tempdir().unwrap();
1040 let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1041 let results = log.filter_audit(None, None, Some("get")).unwrap();
1042 assert!(results.is_empty());
1043 }
1044
1045 #[test]
1046 fn prune_audit_before_removes_old_entries() {
1047 use chrono::Duration;
1048 let dir = tempdir().unwrap();
1049 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1050
1051 let now = Utc::now();
1052 let mut old = AuditEntry::success("dev", "get", Some("A"));
1053 old.timestamp = now - Duration::hours(48);
1054 let mut recent = AuditEntry::success("dev", "set", Some("B"));
1055 recent.timestamp = now - Duration::hours(1);
1056 log.append(&old).unwrap();
1057 log.append(&recent).unwrap();
1058
1059 let cutoff = now - Duration::hours(24);
1060 let removed = log.prune_audit_before(cutoff).unwrap();
1061 assert_eq!(removed, 1);
1062
1063 let remaining = log.read(None).unwrap();
1064 assert_eq!(remaining.len(), 1);
1065 assert_eq!(remaining[0].key.as_deref(), Some("B"));
1066 }
1067
1068 #[test]
1069 fn prune_audit_before_noop_on_empty_log() {
1070 let dir = tempdir().unwrap();
1071 let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1072 let removed = log.prune_audit_before(Utc::now()).unwrap();
1073 assert_eq!(removed, 0);
1074 }
1075
1076 #[test]
1077 fn prune_audit_before_keeps_all_if_none_old() {
1078 use chrono::Duration;
1079 let dir = tempdir().unwrap();
1080 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1081 log.append(&AuditEntry::success("dev", "get", Some("A")))
1082 .unwrap();
1083 log.append(&AuditEntry::success("dev", "set", Some("B")))
1084 .unwrap();
1085
1086 let removed = log
1088 .prune_audit_before(Utc::now() - Duration::days(1))
1089 .unwrap();
1090 assert_eq!(removed, 0);
1091 assert_eq!(log.read(None).unwrap().len(), 2);
1092 }
1093
1094 #[test]
1095 fn exec_context_from_contract_seeds_trust_shape() {
1096 let contract = AuthorityContract {
1097 name: "deploy".into(),
1098 profile: Some("work".into()),
1099 namespace: Some("infra".into()),
1100 access_profile: RbacProfile::ReadOnly,
1101 allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
1102 required_secrets: vec!["DB_PASSWORD".into()],
1103 allowed_targets: vec!["terraform".into()],
1104 trust: AuthorityTrust::Hardened,
1105 network: AuthorityNetworkPolicy::Restricted,
1106 };
1107
1108 let exec = AuditExecContext::from_contract(&contract)
1109 .with_target("terraform")
1110 .with_injected_secrets(["DB_PASSWORD", "DB_PASSWORD", "API_KEY"])
1111 .with_missing_required_secrets(["DB_PASSWORD"])
1112 .with_dropped_env_names(["OPENAI_API_KEY", "OPENAI_API_KEY"])
1113 .with_target_evaluation(&contract.evaluate_target(Some("terraform")));
1114
1115 assert_eq!(exec.contract_name.as_deref(), Some("deploy"));
1116 assert_eq!(exec.authority_profile.as_deref(), Some("work"));
1117 assert_eq!(exec.authority_namespace.as_deref(), Some("infra"));
1118 assert_eq!(exec.access_profile, Some(RbacProfile::ReadOnly));
1119 assert_eq!(exec.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1120 assert_eq!(exec.required_secrets, vec!["DB_PASSWORD"]);
1121 assert_eq!(exec.injected_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1122 assert_eq!(exec.missing_required_secrets, vec!["DB_PASSWORD"]);
1123 assert_eq!(exec.dropped_env_names, vec!["OPENAI_API_KEY"]);
1124 assert_eq!(exec.target_allowed, Some(true));
1125 assert_eq!(
1126 exec.target_decision,
1127 Some(AuthorityTargetDecision::AllowedExact)
1128 );
1129 assert_eq!(exec.matched_target.as_deref(), Some("terraform"));
1130 }
1131}