use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use thiserror::Error;
use uuid::Uuid;
fn bytes_to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
use crate::contracts::{
AuthorityContract, AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision,
AuthorityTargetEvaluation, AuthorityTrustLevel,
};
use crate::deny_reason::DenyReason;
use crate::errors::{SafeError, SafeResult};
use crate::rbac::RbacProfile;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum AuditVerifyError {
#[error("audit chain broken at entry index {at_entry} (id: {entry_id})")]
ChainBroken {
at_entry: usize,
entry_id: String,
},
#[error("could not read audit log: {0}")]
Io(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditStatus {
Success,
Failure,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<AuditExecContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cellos: Option<AuditCellosContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clipboard: Option<AuditClipboardContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reveal: Option<AuditRevealContext>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditCellosContext {
pub cellos_cell_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cell_token: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditClipboardContext {
pub ttl_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excluded_from_history: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleared_verified: Option<bool>,
}
impl AuditContext {
pub fn from_exec(exec: AuditExecContext) -> Self {
Self {
exec: Some(exec),
..Default::default()
}
}
pub fn from_cellos(cellos: AuditCellosContext) -> Self {
Self {
cellos: Some(cellos),
..Default::default()
}
}
pub fn from_clipboard(clipboard: AuditClipboardContext) -> Self {
Self {
clipboard: Some(clipboard),
..Default::default()
}
}
pub fn from_reveal(reveal: AuditRevealContext) -> Self {
Self {
reveal: Some(reveal),
..Default::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditRevealContext {
pub ttl_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEnvMapping {
pub env: String,
pub vault_key: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditExecContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contract_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority_namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trust_level: Option<AuthorityTrustLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_profile: Option<RbacProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inherit: Option<AuthorityInheritMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deny_dangerous_env: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redact_output: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<AuthorityNetworkPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub injected_secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing_required_secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dropped_env_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_mappings: Vec<AuditEnvMapping>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_decision: Option<AuthorityTargetDecision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched_target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deny_reason: Option<DenyReason>,
}
impl AuditExecContext {
pub fn from_contract(contract: &AuthorityContract) -> Self {
let resolved = contract.resolved_exec_policy();
Self {
contract_name: Some(contract.name.clone()),
target: None,
authority_profile: contract.profile.clone(),
authority_namespace: contract.namespace.clone(),
trust_level: Some(resolved.trust_level),
access_profile: Some(resolved.access_profile),
inherit: Some(resolved.inherit),
deny_dangerous_env: Some(resolved.deny_dangerous_env),
redact_output: Some(resolved.redact_output),
network: Some(contract.network),
allowed_secrets: contract.allowed_secrets.clone(),
required_secrets: contract.required_secrets.clone(),
injected_secrets: Vec::new(),
missing_required_secrets: Vec::new(),
dropped_env_names: Vec::new(),
env_mappings: Vec::new(),
target_allowed: None,
target_decision: None,
matched_target: None,
deny_reason: None,
}
}
pub fn with_target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
pub fn with_injected_secrets<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.injected_secrets = normalize_names(names);
self
}
pub fn with_missing_required_secrets<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.missing_required_secrets = normalize_names(names);
self
}
pub fn with_dropped_env_names<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.dropped_env_names = normalize_names(names);
self
}
pub fn with_target_allowed(mut self, allowed: bool) -> Self {
self.target_allowed = Some(allowed);
self
}
pub fn with_target_evaluation(mut self, evaluation: &AuthorityTargetEvaluation) -> Self {
self.target_allowed = Some(evaluation.decision.is_allowed());
self.target_decision = Some(evaluation.decision);
self.matched_target = evaluation.matched_allowlist_entry.clone();
self
}
}
fn normalize_names<I, S>(names: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut out = names
.into_iter()
.map(|name| name.as_ref().trim().to_string())
.filter(|name| !name.is_empty())
.collect::<Vec<_>>();
out.sort();
out.dedup();
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: String,
pub timestamp: DateTime<Utc>,
pub profile: String,
pub operation: String,
pub key: Option<String>,
pub status: AuditStatus,
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<AuditContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prev_entry_hmac: Option<String>,
}
impl AuditEntry {
pub fn success(profile: &str, operation: &str, key: Option<&str>) -> Self {
Self {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now(),
profile: profile.to_string(),
operation: operation.to_string(),
key: key.map(str::to_string),
status: AuditStatus::Success,
message: None,
context: None,
prev_entry_hmac: None,
}
}
pub fn failure(profile: &str, operation: &str, key: Option<&str>, message: &str) -> Self {
Self {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now(),
profile: profile.to_string(),
operation: operation.to_string(),
key: key.map(str::to_string),
status: AuditStatus::Failure,
message: Some(message.to_string()),
context: None,
prev_entry_hmac: None,
}
}
pub fn with_context(mut self, context: AuditContext) -> Self {
self.context = Some(context);
self
}
}
pub fn compute_entry_hmac(entry: &AuditEntry, key: &[u8; 32]) -> String {
let json = serde_json::to_string(entry).expect("AuditEntry is always serializable");
let mut mac =
HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length via padding");
mac.update(json.as_bytes());
let result = mac.finalize().into_bytes();
bytes_to_hex(&result)
}
fn derive_chain_key() -> [u8; 32] {
let mut key = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut key);
key
}
pub struct AuditLog {
path: PathBuf,
chain_key: [u8; 32],
prev_hmac: std::cell::Cell<Option<String>>,
bootstrapped: std::cell::Cell<bool>,
}
impl AuditLog {
pub fn new(path: &Path) -> Self {
Self {
path: path.to_path_buf(),
chain_key: derive_chain_key(),
prev_hmac: std::cell::Cell::new(None),
bootstrapped: std::cell::Cell::new(false),
}
}
fn bootstrap_if_needed(&self) {
if self.bootstrapped.get() {
return;
}
self.bootstrapped.set(true);
self.prev_hmac.set(None);
}
pub fn append(&self, entry: &AuditEntry) -> SafeResult<()> {
self.bootstrap_if_needed();
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
let mut chained = entry.clone();
chained.prev_entry_hmac = self.prev_hmac.take();
let mut line = serde_json::to_string(&chained).map_err(SafeError::Serialization)?;
line.push('\n');
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&self.path)?;
file.write_all(line.as_bytes())?;
let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
self.prev_hmac.set(Some(next_hmac));
Ok(())
}
pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
verify_chain_with_key(&self.path, &self.chain_key)
}
pub fn read(&self, limit: Option<usize>) -> SafeResult<Vec<AuditEntry>> {
if !self.path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&self.path)?;
let mut entries: Vec<AuditEntry> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
entries.reverse(); if let Some(n) = limit {
entries.truncate(n);
}
Ok(entries)
}
pub fn explain(&self, limit: Option<usize>) -> SafeResult<crate::audit_explain::AuditTimeline> {
Ok(crate::audit_explain::explain_entries(&self.read(limit)?))
}
pub fn last_successful_operation(
&self,
profile: &str,
operation: &str,
scan_limit: usize,
) -> SafeResult<Option<DateTime<Utc>>> {
let entries = self.read(Some(scan_limit))?;
Ok(entries
.into_iter()
.find(|e| {
e.profile == profile
&& e.operation == operation
&& matches!(e.status, AuditStatus::Success)
})
.map(|e| e.timestamp))
}
pub fn filter_audit(
&self,
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
command: Option<&str>,
) -> SafeResult<Vec<AuditEntry>> {
let entries = self.read(None)?;
Ok(entries
.into_iter()
.filter(|e| {
if let Some(s) = since {
if e.timestamp < s {
return false;
}
}
if let Some(u) = until {
if e.timestamp > u {
return false;
}
}
if let Some(cmd) = command {
if e.operation != cmd {
return false;
}
}
true
})
.collect())
}
pub fn prune_audit_before(&self, before: DateTime<Utc>) -> SafeResult<usize> {
if !self.path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&self.path)?;
let mut kept: Vec<&str> = Vec::new();
let mut removed = 0usize;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<AuditEntry>(line) {
Ok(entry) if entry.timestamp < before => {
removed += 1;
}
_ => {
kept.push(line);
}
}
}
if removed == 0 {
return Ok(0);
}
let new_content = kept.join("\n") + if kept.is_empty() { "" } else { "\n" };
let tmp = self.path.with_extension("jsonl.tmp");
std::fs::write(&tmp, &new_content)?;
if let Err(e) = std::fs::rename(&tmp, &self.path) {
let _ = std::fs::remove_file(&tmp); return Err(std::io::Error::other(format!(
"audit prune: failed to rename temp file — log unchanged: {e}"
))
.into());
}
let profile_name = self
.path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut sentinel = AuditEntry::success(profile_name, "audit-prune", None);
sentinel.message = Some(format!("pruned {removed} entries older than {before}"));
self.append(&sentinel)?;
Ok(removed)
}
}
pub fn audit_log_size_bytes(path: &Path) -> SafeResult<u64> {
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.len()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(e) => Err(SafeError::Io(e)),
}
}
fn verify_chain_with_key(path: &Path, key: &[u8; 32]) -> Result<(), AuditVerifyError> {
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(path).map_err(|e| AuditVerifyError::Io(e.to_string()))?;
let entries: Vec<AuditEntry> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let mut prev_computed: Option<String> = None;
for (idx, entry) in entries.iter().enumerate() {
match &entry.prev_entry_hmac {
None => {
prev_computed = Some(compute_entry_hmac(entry, key));
}
Some(stored_hmac) => {
match &prev_computed {
Some(expected) if expected == stored_hmac => {
prev_computed = Some(compute_entry_hmac(entry, key));
}
_ => {
return Err(AuditVerifyError::ChainBroken {
at_entry: idx,
entry_id: entry.id.clone(),
});
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
use tempfile::tempdir;
#[test]
fn append_and_read_roundtrip() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("t.jsonl"));
log.append(&AuditEntry::success("dev", "set", Some("DB_PASS")))
.unwrap();
log.append(&AuditEntry::success("dev", "get", Some("DB_PASS")))
.unwrap();
log.append(&AuditEntry::failure(
"dev",
"get",
Some("MISSING"),
"not found",
))
.unwrap();
let entries = log.read(None).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].status, AuditStatus::Failure); }
#[test]
fn limit_truncates() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("t.jsonl"));
for i in 0..10 {
log.append(&AuditEntry::success("dev", "op", Some(&format!("K{i}"))))
.unwrap();
}
assert_eq!(log.read(Some(3)).unwrap().len(), 3);
}
#[test]
fn nonexistent_log_returns_empty() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("does-not-exist.jsonl"));
assert!(log.read(None).unwrap().is_empty());
}
#[test]
fn ids_are_unique() {
let e1 = AuditEntry::success("p", "op", None);
let e2 = AuditEntry::success("p", "op", None);
assert_ne!(e1.id, e2.id);
}
#[test]
fn last_successful_operation_finds_rotate() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("a.jsonl"));
log.append(&AuditEntry::success("dev", "set", Some("K")))
.unwrap();
log.append(&AuditEntry::success("dev", "rotate", None))
.unwrap();
log.append(&AuditEntry::success("dev", "get", Some("K")))
.unwrap();
assert!(log
.last_successful_operation("dev", "rotate", 100)
.unwrap()
.is_some());
assert!(log
.last_successful_operation("dev", "missing-op", 100)
.unwrap()
.is_none());
}
#[test]
fn hmac_chain_intact_and_detects_tampering() {
let dir = tempdir().unwrap();
let path = dir.path().join("chain.jsonl");
let log = AuditLog::new(&path);
log.append(&AuditEntry::success("dev", "set", Some("A")))
.unwrap();
log.append(&AuditEntry::success("dev", "get", Some("A")))
.unwrap();
log.append(&AuditEntry::failure(
"dev",
"get",
Some("MISSING"),
"not found",
))
.unwrap();
log.verify_chain()
.expect("chain must be intact after write");
let content = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
tampered.operation = "TAMPERED".to_string();
lines[1] = serde_json::to_string(&tampered).unwrap();
let tampered_content = lines.join("\n") + "\n";
std::fs::write(&path, tampered_content).unwrap();
let err = log
.verify_chain()
.expect_err("tampered log must fail verification");
match err {
AuditVerifyError::ChainBroken { at_entry, .. } => {
assert_eq!(
at_entry, 2,
"chain should break at the entry after the tampered one"
);
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn first_entry_has_no_prev_hmac() {
let dir = tempdir().unwrap();
let path = dir.path().join("first.jsonl");
let log = AuditLog::new(&path);
log.append(&AuditEntry::success("dev", "set", Some("K")))
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let entry: AuditEntry = serde_json::from_str(content.trim()).unwrap();
assert!(
entry.prev_entry_hmac.is_none(),
"first entry must have no prev_entry_hmac"
);
}
#[test]
fn subsequent_entries_carry_prev_hmac() {
let dir = tempdir().unwrap();
let path = dir.path().join("chain2.jsonl");
let log = AuditLog::new(&path);
log.append(&AuditEntry::success("dev", "set", Some("A")))
.unwrap();
log.append(&AuditEntry::success("dev", "get", Some("A")))
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
let second: AuditEntry = serde_json::from_str(lines[1]).unwrap();
assert!(
second.prev_entry_hmac.is_some(),
"second entry must carry prev_entry_hmac"
);
}
#[test]
fn old_entries_deserialize_without_prev_hmac() {
let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
let entry: AuditEntry = serde_json::from_str(raw).unwrap();
assert!(entry.prev_entry_hmac.is_none());
assert!(entry.context.is_none());
}
#[test]
fn audit_integrity_contract_v2() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("integrity.jsonl"));
log.append(&AuditEntry::success("dev", "set", Some("A")))
.unwrap();
log.append(&AuditEntry::success("dev", "get", Some("A")))
.unwrap();
log.append(&AuditEntry::failure(
"dev",
"get",
Some("MISSING"),
"not found",
))
.unwrap();
let entries = log.read(None).unwrap();
assert_eq!(entries.len(), 3, "all appended entries must be retained");
assert_eq!(entries[0].status, AuditStatus::Failure);
assert_eq!(entries[1].operation, "get");
assert_eq!(entries[2].operation, "set");
let ids: std::collections::HashSet<_> = entries.iter().map(|e| &e.id).collect();
assert_eq!(ids.len(), 3, "every entry must have a distinct UUID");
let mut ordered = entries.clone();
ordered.reverse();
for w in ordered.windows(2) {
assert!(
w[0].timestamp <= w[1].timestamp,
"timestamps should be non-decreasing in append order"
);
}
log.verify_chain()
.expect("integrity contract: chain must be intact");
}
#[test]
fn old_entries_deserialize_without_context() {
let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
let entry: AuditEntry = serde_json::from_str(raw).unwrap();
assert!(entry.context.is_none());
}
#[test]
fn filter_audit_by_command() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("t.jsonl"));
log.append(&AuditEntry::success("dev", "get", Some("A")))
.unwrap();
log.append(&AuditEntry::success("dev", "set", Some("B")))
.unwrap();
log.append(&AuditEntry::success("dev", "get", Some("C")))
.unwrap();
let gets = log.filter_audit(None, None, Some("get")).unwrap();
assert_eq!(gets.len(), 2);
assert!(gets.iter().all(|e| e.operation == "get"));
let sets = log.filter_audit(None, None, Some("set")).unwrap();
assert_eq!(sets.len(), 1);
}
#[test]
fn filter_audit_by_time_range() {
use chrono::Duration;
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("t.jsonl"));
let now = Utc::now();
let old = now - Duration::hours(2);
let recent = now - Duration::minutes(30);
let mut e_old = AuditEntry::success("dev", "get", Some("OLD"));
e_old.timestamp = old;
let mut e_recent = AuditEntry::success("dev", "get", Some("RECENT"));
e_recent.timestamp = recent;
let mut e_now = AuditEntry::success("dev", "set", Some("NOW"));
e_now.timestamp = now;
log.append(&e_old).unwrap();
log.append(&e_recent).unwrap();
log.append(&e_now).unwrap();
let since_cutoff = now - Duration::hours(1);
let results = log.filter_audit(Some(since_cutoff), None, None).unwrap();
assert_eq!(results.len(), 2);
let results = log
.filter_audit(None, Some(now - Duration::hours(1)), None)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].key.as_deref(), Some("OLD"));
}
#[test]
fn filter_audit_combined_since_and_command() {
use chrono::Duration;
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("t.jsonl"));
let now = Utc::now();
let mut old_get = AuditEntry::success("dev", "get", Some("OLD"));
old_get.timestamp = now - Duration::hours(3);
let mut new_get = AuditEntry::success("dev", "get", Some("NEW"));
new_get.timestamp = now - Duration::minutes(5);
let mut new_set = AuditEntry::success("dev", "set", Some("S"));
new_set.timestamp = now - Duration::minutes(5);
log.append(&old_get).unwrap();
log.append(&new_get).unwrap();
log.append(&new_set).unwrap();
let results = log
.filter_audit(Some(now - Duration::hours(1)), None, Some("get"))
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].key.as_deref(), Some("NEW"));
}
#[test]
fn filter_audit_empty_log_returns_empty() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("missing.jsonl"));
let results = log.filter_audit(None, None, Some("get")).unwrap();
assert!(results.is_empty());
}
#[test]
fn prune_audit_before_removes_old_entries() {
use chrono::Duration;
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("t.jsonl"));
let now = Utc::now();
let mut old = AuditEntry::success("dev", "get", Some("A"));
old.timestamp = now - Duration::hours(48);
let mut recent = AuditEntry::success("dev", "set", Some("B"));
recent.timestamp = now - Duration::hours(1);
log.append(&old).unwrap();
log.append(&recent).unwrap();
let cutoff = now - Duration::hours(24);
let removed = log.prune_audit_before(cutoff).unwrap();
assert_eq!(removed, 1);
let remaining = log.read(None).unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].key.as_deref(), Some("B"));
}
#[test]
fn prune_audit_before_noop_on_empty_log() {
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("missing.jsonl"));
let removed = log.prune_audit_before(Utc::now()).unwrap();
assert_eq!(removed, 0);
}
#[test]
fn prune_audit_before_keeps_all_if_none_old() {
use chrono::Duration;
let dir = tempdir().unwrap();
let log = AuditLog::new(&dir.path().join("t.jsonl"));
log.append(&AuditEntry::success("dev", "get", Some("A")))
.unwrap();
log.append(&AuditEntry::success("dev", "set", Some("B")))
.unwrap();
let removed = log
.prune_audit_before(Utc::now() - Duration::days(1))
.unwrap();
assert_eq!(removed, 0);
assert_eq!(log.read(None).unwrap().len(), 2);
}
#[test]
fn exec_context_from_contract_seeds_trust_shape() {
let contract = AuthorityContract {
name: "deploy".into(),
profile: Some("work".into()),
namespace: Some("infra".into()),
access_profile: RbacProfile::ReadOnly,
allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
required_secrets: vec!["DB_PASSWORD".into()],
allowed_targets: vec!["terraform".into()],
trust: AuthorityTrust::Hardened,
network: AuthorityNetworkPolicy::Restricted,
};
let exec = AuditExecContext::from_contract(&contract)
.with_target("terraform")
.with_injected_secrets(["DB_PASSWORD", "DB_PASSWORD", "API_KEY"])
.with_missing_required_secrets(["DB_PASSWORD"])
.with_dropped_env_names(["OPENAI_API_KEY", "OPENAI_API_KEY"])
.with_target_evaluation(&contract.evaluate_target(Some("terraform")));
assert_eq!(exec.contract_name.as_deref(), Some("deploy"));
assert_eq!(exec.authority_profile.as_deref(), Some("work"));
assert_eq!(exec.authority_namespace.as_deref(), Some("infra"));
assert_eq!(exec.access_profile, Some(RbacProfile::ReadOnly));
assert_eq!(exec.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
assert_eq!(exec.required_secrets, vec!["DB_PASSWORD"]);
assert_eq!(exec.injected_secrets, vec!["API_KEY", "DB_PASSWORD"]);
assert_eq!(exec.missing_required_secrets, vec!["DB_PASSWORD"]);
assert_eq!(exec.dropped_env_names, vec!["OPENAI_API_KEY"]);
assert_eq!(exec.target_allowed, Some(true));
assert_eq!(
exec.target_decision,
Some(AuthorityTargetDecision::AllowedExact)
);
assert_eq!(exec.matched_target.as_deref(), Some("terraform"));
}
}