use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::audit::{AuditEntry, AuditExecContext, AuditStatus};
use crate::contracts::{
AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision, AuthorityTrustLevel,
};
use crate::events::key_ref;
use crate::lifecycle::{classify_operation, LifecycleState, OperationKind};
use crate::rbac::RbacProfile;
pub const DEFAULT_SESSION_GAP_MINUTES: i64 = 30;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditTimeline {
pub sessions: Vec<AuditSession>,
}
impl AuditTimeline {
pub fn from_entries(entries: &[AuditEntry]) -> Self {
explain_entries_with_gap(entries, Duration::minutes(DEFAULT_SESSION_GAP_MINUTES))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditSession {
pub profile: String,
pub session_index: usize,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
pub boundary: SessionBoundary,
pub operation_count: usize,
pub exec_count: usize,
pub failure_count: usize,
pub operations: Vec<ExplainedAuditOperation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionBoundary {
StartOfLog,
UnlockBoundary,
TimeGap,
ProfileChange,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExplainedAuditOperation {
pub id: String,
pub timestamp: DateTime<Utc>,
pub operation: String,
pub kind: ExplainedOperationKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle_state: Option<LifecycleState>,
pub status: AuditStatus,
pub key_ref: Option<String>,
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority: Option<ExecutionAuthoritySummary>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExplainedOperationKind {
SecretLifecycle,
VaultLifecycle,
Execution,
Session,
Sync,
Share,
Team,
RotationPolicy,
CredentialHelper,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionAuthoritySummary {
#[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 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 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_secret_refs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_secret_refs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub injected_secret_refs: Vec<String>,
pub contract_diff: AuthorityContractDiff,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gaps: Vec<ExecutionGap>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityContractDiff {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unexpected_injected_secret_refs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing_required_secret_refs: Vec<String>,
pub target_mismatch: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dropped_env_names: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionGap {
MissingExecContext,
MissingContractName,
MissingTarget,
MissingInjectedSecretSet,
MissingTargetDecision,
}
pub fn explain_entries(entries: &[AuditEntry]) -> AuditTimeline {
AuditTimeline::from_entries(entries)
}
pub fn explain_entries_with_gap(entries: &[AuditEntry], session_gap: Duration) -> AuditTimeline {
let mut ordered = entries.to_vec();
ordered.sort_by(|left, right| {
left.timestamp
.cmp(&right.timestamp)
.then_with(|| left.id.cmp(&right.id))
});
let mut sessions = Vec::new();
let mut current: Option<SessionAccumulator> = None;
for entry in ordered {
let boundary = match current.as_ref() {
None => SessionBoundary::StartOfLog,
Some(acc) if acc.profile != entry.profile => SessionBoundary::ProfileChange,
Some(_) if entry.operation == "unlock" => SessionBoundary::UnlockBoundary,
Some(acc) if entry.timestamp - acc.last_timestamp > session_gap => {
SessionBoundary::TimeGap
}
Some(_) => {
if let Some(acc) = current.as_mut() {
acc.push(entry);
}
continue;
}
};
if let Some(acc) = current.take() {
sessions.push(acc.finish());
}
let mut acc = SessionAccumulator::new(boundary, sessions.len(), entry.profile.clone());
acc.push(entry);
current = Some(acc);
}
if let Some(acc) = current.take() {
sessions.push(acc.finish());
}
AuditTimeline { sessions }
}
impl ExplainedAuditOperation {
pub fn from_entry(entry: &AuditEntry) -> Self {
let classified = classify_operation(&entry.operation);
let authority = if entry.operation == "exec" {
Some(ExecutionAuthoritySummary::from_exec_context(
&entry.profile,
entry
.context
.as_ref()
.and_then(|context| context.exec.as_ref()),
))
} else {
None
};
Self {
id: entry.id.clone(),
timestamp: entry.timestamp,
operation: entry.operation.clone(),
kind: operation_kind(classified.kind),
lifecycle_state: classified.lifecycle_state,
status: entry.status.clone(),
key_ref: entry.key.as_deref().map(|key| key_ref(&entry.profile, key)),
message: entry.message.clone(),
authority,
}
}
}
impl ExecutionAuthoritySummary {
fn from_exec_context(profile: &str, context: Option<&AuditExecContext>) -> Self {
let Some(context) = context else {
return Self {
contract_name: None,
target: None,
target_decision: None,
matched_target: None,
authority_profile: None,
authority_namespace: None,
trust_level: None,
access_profile: None,
inherit: None,
deny_dangerous_env: None,
redact_output: None,
network: None,
allowed_secret_refs: Vec::new(),
required_secret_refs: Vec::new(),
injected_secret_refs: Vec::new(),
contract_diff: AuthorityContractDiff {
unexpected_injected_secret_refs: Vec::new(),
missing_required_secret_refs: Vec::new(),
target_mismatch: false,
dropped_env_names: Vec::new(),
},
gaps: vec![ExecutionGap::MissingExecContext],
};
};
let allowed_names = sorted_unique(&context.allowed_secrets);
let required_names = sorted_unique(&context.required_secrets);
let injected_names = sorted_unique(&context.injected_secrets);
let explicit_missing = sorted_unique(&context.missing_required_secrets);
let missing_names = if explicit_missing.is_empty() {
required_names
.iter()
.filter(|required| !injected_names.contains(*required))
.cloned()
.collect::<Vec<_>>()
} else {
explicit_missing
};
let unexpected_names = injected_names
.iter()
.filter(|injected| !allowed_names.contains(*injected))
.cloned()
.collect::<Vec<_>>();
let mut gaps = Vec::new();
if context.contract_name.as_deref().unwrap_or("").is_empty() {
gaps.push(ExecutionGap::MissingContractName);
}
if context.target.as_deref().unwrap_or("").is_empty() {
gaps.push(ExecutionGap::MissingTarget);
}
if injected_names.is_empty() {
gaps.push(ExecutionGap::MissingInjectedSecretSet);
}
if context.target_allowed.is_none() && context.target_decision.is_none() {
gaps.push(ExecutionGap::MissingTargetDecision);
}
let target_mismatch = match context.target_decision {
Some(decision) => !decision.is_allowed(),
None => matches!(context.target_allowed, Some(false)),
};
Self {
contract_name: context.contract_name.clone(),
target: context.target.clone(),
target_decision: context.target_decision,
matched_target: context.matched_target.clone(),
authority_profile: context.authority_profile.clone(),
authority_namespace: context.authority_namespace.clone(),
trust_level: context.trust_level,
access_profile: context.access_profile,
inherit: context.inherit,
deny_dangerous_env: context.deny_dangerous_env,
redact_output: context.redact_output,
network: context.network,
allowed_secret_refs: names_to_refs(profile, &allowed_names),
required_secret_refs: names_to_refs(profile, &required_names),
injected_secret_refs: names_to_refs(profile, &injected_names),
contract_diff: AuthorityContractDiff {
unexpected_injected_secret_refs: names_to_refs(profile, &unexpected_names),
missing_required_secret_refs: names_to_refs(profile, &missing_names),
target_mismatch,
dropped_env_names: sorted_unique(&context.dropped_env_names),
},
gaps,
}
}
}
fn operation_kind(kind: OperationKind) -> ExplainedOperationKind {
match kind {
OperationKind::SecretLifecycle => ExplainedOperationKind::SecretLifecycle,
OperationKind::VaultLifecycle => ExplainedOperationKind::VaultLifecycle,
OperationKind::Execution => ExplainedOperationKind::Execution,
OperationKind::Session => ExplainedOperationKind::Session,
OperationKind::Sync => ExplainedOperationKind::Sync,
OperationKind::Share => ExplainedOperationKind::Share,
OperationKind::Team => ExplainedOperationKind::Team,
OperationKind::RotationPolicy => ExplainedOperationKind::RotationPolicy,
OperationKind::CredentialHelper => ExplainedOperationKind::CredentialHelper,
OperationKind::Other => ExplainedOperationKind::Other,
}
}
fn sorted_unique(items: &[String]) -> Vec<String> {
let mut out = items
.iter()
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect::<Vec<_>>();
out.sort();
out.dedup();
out
}
fn names_to_refs(profile: &str, names: &[String]) -> Vec<String> {
names.iter().map(|name| key_ref(profile, name)).collect()
}
#[derive(Debug)]
struct SessionAccumulator {
profile: String,
session_index: usize,
boundary: SessionBoundary,
start: DateTime<Utc>,
last_timestamp: DateTime<Utc>,
operations: Vec<ExplainedAuditOperation>,
exec_count: usize,
failure_count: usize,
}
impl SessionAccumulator {
fn new(boundary: SessionBoundary, session_index: usize, profile: String) -> Self {
Self {
profile,
session_index,
boundary,
start: Utc::now(),
last_timestamp: Utc::now(),
operations: Vec::new(),
exec_count: 0,
failure_count: 0,
}
}
fn push(&mut self, entry: AuditEntry) {
if self.operations.is_empty() {
self.start = entry.timestamp;
}
self.last_timestamp = entry.timestamp;
if entry.operation == "exec" {
self.exec_count += 1;
}
if matches!(entry.status, AuditStatus::Failure) {
self.failure_count += 1;
}
self.operations
.push(ExplainedAuditOperation::from_entry(&entry));
}
fn finish(self) -> AuditSession {
AuditSession {
profile: self.profile,
session_index: self.session_index,
start: self.start,
end: self.last_timestamp,
boundary: self.boundary,
operation_count: self.operations.len(),
exec_count: self.exec_count,
failure_count: self.failure_count,
operations: self.operations,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::{AuditContext, AuditExecContext};
use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
use crate::lifecycle::{
CredentialHelperLifecycleState, LifecycleState, PolicyLifecycleState, SecretLifecycleState,
SessionLifecycleState, ShareLifecycleState, SyncLifecycleState, TeamLifecycleState,
VaultLifecycleState,
};
use crate::rbac::RbacProfile;
#[test]
fn exec_without_context_reports_gap() {
let entry = AuditEntry::success("dev", "exec", None);
let explained = ExplainedAuditOperation::from_entry(&entry);
let authority = explained
.authority
.expect("exec should produce authority summary");
assert_eq!(authority.gaps, vec![ExecutionGap::MissingExecContext]);
assert!(authority.injected_secret_refs.is_empty());
}
#[test]
fn exec_context_projects_plaintext_free_contract_diff() {
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 context = AuditContext::from_exec(
AuditExecContext::from_contract(&contract)
.with_target("terraform")
.with_injected_secrets(["DB_PASSWORD", "UNPLANNED_TOKEN"])
.with_dropped_env_names(["OPENAI_API_KEY"])
.with_target_evaluation(&contract.evaluate_target(Some("terraform"))),
);
let entry = AuditEntry::success("dev", "exec", None).with_context(context);
let explained = ExplainedAuditOperation::from_entry(&entry);
let authority = explained.authority.unwrap();
assert_eq!(authority.contract_name.as_deref(), Some("deploy"));
assert_eq!(
authority.target_decision,
Some(AuthorityTargetDecision::AllowedExact)
);
assert_eq!(authority.access_profile, Some(RbacProfile::ReadOnly));
assert_eq!(authority.matched_target.as_deref(), Some("terraform"));
assert_eq!(authority.trust_level, Some(AuthorityTrustLevel::Hardened));
assert_eq!(authority.inherit, Some(AuthorityInheritMode::Minimal));
assert_eq!(authority.network, Some(AuthorityNetworkPolicy::Restricted));
assert_eq!(authority.allowed_secret_refs.len(), 2);
assert_eq!(authority.injected_secret_refs.len(), 2);
assert_eq!(
authority
.contract_diff
.unexpected_injected_secret_refs
.len(),
1
);
assert_eq!(
authority.contract_diff.missing_required_secret_refs.len(),
0
);
assert_eq!(
authority.contract_diff.dropped_env_names,
vec!["OPENAI_API_KEY"]
);
assert!(!authority
.allowed_secret_refs
.iter()
.any(|value| value.contains("DB_PASSWORD")));
}
#[test]
fn exec_context_denied_target_sets_mismatch_without_plaintext_leak() {
let contract = AuthorityContract {
name: "deploy".into(),
profile: Some("work".into()),
namespace: Some("infra".into()),
access_profile: RbacProfile::ReadOnly,
allowed_secrets: vec!["DB_PASSWORD".into()],
required_secrets: vec!["DB_PASSWORD".into()],
allowed_targets: vec!["terraform".into()],
trust: AuthorityTrust::Hardened,
network: AuthorityNetworkPolicy::Restricted,
};
let context = AuditContext::from_exec(
AuditExecContext::from_contract(&contract)
.with_target("bash")
.with_injected_secrets(["DB_PASSWORD"])
.with_target_evaluation(&contract.evaluate_target(Some("bash"))),
);
let entry = AuditEntry::success("dev", "exec", None).with_context(context);
let explained = ExplainedAuditOperation::from_entry(&entry);
let authority = explained.authority.unwrap();
assert_eq!(
authority.target_decision,
Some(AuthorityTargetDecision::Denied)
);
assert!(authority.contract_diff.target_mismatch);
assert!(authority.matched_target.is_none());
assert!(!serde_json::to_string(&authority)
.unwrap()
.contains("DB_PASSWORD"));
}
#[test]
fn timeline_splits_on_unlock_and_idle_gap() {
let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
.unwrap()
.with_timezone(&Utc);
let mut unlock = AuditEntry::success("dev", "unlock", None);
unlock.timestamp = base;
let mut exec = AuditEntry::success("dev", "exec", None);
exec.timestamp = base + Duration::minutes(5);
let mut get = AuditEntry::success("dev", "get", Some("API_KEY"));
get.timestamp = base + Duration::minutes(7);
let mut later = AuditEntry::failure("dev", "get", Some("MISSING"), "not found");
later.timestamp = base + Duration::minutes(80);
let timeline = explain_entries_with_gap(&[later, exec, unlock, get], Duration::minutes(30));
assert_eq!(timeline.sessions.len(), 2);
assert_eq!(timeline.sessions[0].boundary, SessionBoundary::StartOfLog);
assert_eq!(timeline.sessions[0].operation_count, 3);
assert_eq!(timeline.sessions[0].exec_count, 1);
assert_eq!(timeline.sessions[1].boundary, SessionBoundary::TimeGap);
assert_eq!(timeline.sessions[1].failure_count, 1);
}
#[test]
fn timeline_splits_on_profile_change() {
let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
.unwrap()
.with_timezone(&Utc);
let mut left = AuditEntry::success("dev", "get", Some("A"));
left.timestamp = base;
let mut right = AuditEntry::success("prod", "get", Some("B"));
right.timestamp = base + Duration::minutes(1);
let timeline = explain_entries_with_gap(&[left, right], Duration::minutes(30));
assert_eq!(timeline.sessions.len(), 2);
assert_eq!(
timeline.sessions[1].boundary,
SessionBoundary::ProfileChange
);
}
#[test]
fn vault_and_share_entries_expose_explicit_lifecycle_state() {
let created =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "init", None));
assert_eq!(
created.lifecycle_state,
Some(LifecycleState::Vault(VaultLifecycleState::Created))
);
let published =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "share-once", None));
assert_eq!(
published.lifecycle_state,
Some(LifecycleState::Share(ShareLifecycleState::Published))
);
}
#[test]
fn secret_entries_expose_explicit_lifecycle_state() {
let written = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "set", None));
assert_eq!(
written.lifecycle_state,
Some(LifecycleState::Secret(SecretLifecycleState::Written))
);
let accessed =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "get", None));
assert_eq!(
accessed.lifecycle_state,
Some(LifecycleState::Secret(SecretLifecycleState::Accessed))
);
let deleted =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "delete", None));
assert_eq!(
deleted.lifecycle_state,
Some(LifecycleState::Secret(SecretLifecycleState::Deleted))
);
let imported =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "import", None));
assert_eq!(
imported.lifecycle_state,
Some(LifecycleState::Secret(SecretLifecycleState::Imported))
);
let exported =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "export", None));
assert_eq!(
exported.lifecycle_state,
Some(LifecycleState::Secret(SecretLifecycleState::Exported))
);
let namespace_copy =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-copy", None));
assert_eq!(
namespace_copy.lifecycle_state,
Some(LifecycleState::Secret(SecretLifecycleState::Written))
);
}
#[test]
fn surface_aliases_reuse_existing_vault_lifecycle_state() {
let created =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "create", None));
assert_eq!(
created.lifecycle_state,
Some(LifecycleState::Vault(VaultLifecycleState::Created))
);
let team_created =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "team-init", None));
assert_eq!(
team_created.lifecycle_state,
Some(LifecycleState::Vault(VaultLifecycleState::Created))
);
let namespace_move =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-move", None));
assert_eq!(
namespace_move.lifecycle_state,
Some(LifecycleState::Vault(VaultLifecycleState::SecretMoved))
);
}
#[test]
fn policy_and_helper_entries_expose_explicit_lifecycle_state() {
let policy_set =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "policy-set", None));
assert_eq!(
policy_set.lifecycle_state,
Some(LifecycleState::Policy(PolicyLifecycleState::Set))
);
let policy_due =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "rotate-due", None));
assert_eq!(
policy_due.lifecycle_state,
Some(LifecycleState::Policy(PolicyLifecycleState::DueChecked))
);
let helper_get = ExplainedAuditOperation::from_entry(&AuditEntry::success(
"dev",
"credential-helper-get",
None,
));
assert_eq!(
helper_get.lifecycle_state,
Some(LifecycleState::CredentialHelper(
CredentialHelperLifecycleState::Accessed
))
);
let helper_store = ExplainedAuditOperation::from_entry(&AuditEntry::success(
"dev",
"credential-helper-store",
None,
));
assert_eq!(
helper_store.lifecycle_state,
Some(LifecycleState::CredentialHelper(
CredentialHelperLifecycleState::Stored
))
);
}
#[test]
fn team_entries_expose_explicit_lifecycle_state_and_kind() {
let added = ExplainedAuditOperation::from_entry(&AuditEntry::success(
"dev",
"team-add-member",
None,
));
assert_eq!(added.kind, ExplainedOperationKind::Team);
assert_eq!(
added.lifecycle_state,
Some(LifecycleState::Team(TeamLifecycleState::MemberAdded))
);
let removed = ExplainedAuditOperation::from_entry(&AuditEntry::success(
"dev",
"team-remove-member",
None,
));
assert_eq!(removed.kind, ExplainedOperationKind::Team);
assert_eq!(
removed.lifecycle_state,
Some(LifecycleState::Team(TeamLifecycleState::MemberRemoved))
);
}
#[test]
fn session_and_sync_entries_expose_explicit_lifecycle_state() {
let unlocked =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "unlock", None));
assert_eq!(
unlocked.lifecycle_state,
Some(LifecycleState::Session(SessionLifecycleState::Unlocked))
);
let pulled =
ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "kv-pull", None));
assert_eq!(
pulled.lifecycle_state,
Some(LifecycleState::Sync(SyncLifecycleState::PullCompleted))
);
let merged = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "sync", None));
assert_eq!(
merged.lifecycle_state,
Some(LifecycleState::Sync(SyncLifecycleState::Merged))
);
}
}