Skip to main content

tsafe_core/
audit_explain.rs

1//! Plaintext-free audit explanation projections.
2//!
3//! This module turns raw [`crate::audit::AuditEntry`] values into reusable
4//! explanation objects for session views, execution timelines, and authority
5//! diffs. Secret *values* never appear here, and secret *names* are projected
6//! as opaque `key_ref` hashes so higher layers can render "what happened and
7//! why?" without widening the trust boundary.
8
9use chrono::{DateTime, Duration, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::audit::{AuditEntry, AuditExecContext, AuditStatus};
13use crate::contracts::{
14    AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision, AuthorityTrustLevel,
15};
16use crate::events::key_ref;
17use crate::lifecycle::{classify_operation, LifecycleState, OperationKind};
18use crate::rbac::RbacProfile;
19
20pub const DEFAULT_SESSION_GAP_MINUTES: i64 = 30;
21
22/// Timeline-style explanation for a profile's audit history.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct AuditTimeline {
25    pub sessions: Vec<AuditSession>,
26}
27
28impl AuditTimeline {
29    pub fn from_entries(entries: &[AuditEntry]) -> Self {
30        explain_entries_with_gap(entries, Duration::minutes(DEFAULT_SESSION_GAP_MINUTES))
31    }
32}
33
34/// Probable operator session inferred from audit history.
35///
36/// Current audit logs do not carry explicit session ids, so sessions are
37/// grouped heuristically: on the first entry, on `unlock`, on profile changes,
38/// and on long idle gaps.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct AuditSession {
41    pub profile: String,
42    pub session_index: usize,
43    pub start: DateTime<Utc>,
44    pub end: DateTime<Utc>,
45    pub boundary: SessionBoundary,
46    pub operation_count: usize,
47    pub exec_count: usize,
48    pub failure_count: usize,
49    pub operations: Vec<ExplainedAuditOperation>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum SessionBoundary {
55    StartOfLog,
56    UnlockBoundary,
57    TimeGap,
58    ProfileChange,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ExplainedAuditOperation {
63    pub id: String,
64    pub timestamp: DateTime<Utc>,
65    pub operation: String,
66    pub kind: ExplainedOperationKind,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub lifecycle_state: Option<LifecycleState>,
69    pub status: AuditStatus,
70    pub key_ref: Option<String>,
71    pub message: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub authority: Option<ExecutionAuthoritySummary>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum ExplainedOperationKind {
79    SecretLifecycle,
80    VaultLifecycle,
81    Execution,
82    Session,
83    Sync,
84    Share,
85    Team,
86    RotationPolicy,
87    CredentialHelper,
88    Other,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ExecutionAuthoritySummary {
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub contract_name: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub target: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub target_decision: Option<AuthorityTargetDecision>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub matched_target: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub authority_profile: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub authority_namespace: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub trust_level: Option<AuthorityTrustLevel>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub access_profile: Option<RbacProfile>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub inherit: Option<AuthorityInheritMode>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub deny_dangerous_env: Option<bool>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub redact_output: Option<bool>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub network: Option<AuthorityNetworkPolicy>,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub allowed_secret_refs: Vec<String>,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub required_secret_refs: Vec<String>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub injected_secret_refs: Vec<String>,
123    pub contract_diff: AuthorityContractDiff,
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub gaps: Vec<ExecutionGap>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct AuthorityContractDiff {
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub unexpected_injected_secret_refs: Vec<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub missing_required_secret_refs: Vec<String>,
134    pub target_mismatch: bool,
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub dropped_env_names: Vec<String>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ExecutionGap {
142    MissingExecContext,
143    MissingContractName,
144    MissingTarget,
145    MissingInjectedSecretSet,
146    MissingTargetDecision,
147}
148
149pub fn explain_entries(entries: &[AuditEntry]) -> AuditTimeline {
150    AuditTimeline::from_entries(entries)
151}
152
153pub fn explain_entries_with_gap(entries: &[AuditEntry], session_gap: Duration) -> AuditTimeline {
154    let mut ordered = entries.to_vec();
155    ordered.sort_by(|left, right| {
156        left.timestamp
157            .cmp(&right.timestamp)
158            .then_with(|| left.id.cmp(&right.id))
159    });
160
161    let mut sessions = Vec::new();
162    let mut current: Option<SessionAccumulator> = None;
163
164    for entry in ordered {
165        let boundary = match current.as_ref() {
166            None => SessionBoundary::StartOfLog,
167            Some(acc) if acc.profile != entry.profile => SessionBoundary::ProfileChange,
168            Some(_) if entry.operation == "unlock" => SessionBoundary::UnlockBoundary,
169            Some(acc) if entry.timestamp - acc.last_timestamp > session_gap => {
170                SessionBoundary::TimeGap
171            }
172            Some(_) => {
173                if let Some(acc) = current.as_mut() {
174                    acc.push(entry);
175                }
176                continue;
177            }
178        };
179
180        if let Some(acc) = current.take() {
181            sessions.push(acc.finish());
182        }
183        let mut acc = SessionAccumulator::new(boundary, sessions.len(), entry.profile.clone());
184        acc.push(entry);
185        current = Some(acc);
186    }
187
188    if let Some(acc) = current.take() {
189        sessions.push(acc.finish());
190    }
191
192    AuditTimeline { sessions }
193}
194
195impl ExplainedAuditOperation {
196    pub fn from_entry(entry: &AuditEntry) -> Self {
197        let classified = classify_operation(&entry.operation);
198        let authority = if entry.operation == "exec" {
199            Some(ExecutionAuthoritySummary::from_exec_context(
200                &entry.profile,
201                entry
202                    .context
203                    .as_ref()
204                    .and_then(|context| context.exec.as_ref()),
205            ))
206        } else {
207            None
208        };
209
210        Self {
211            id: entry.id.clone(),
212            timestamp: entry.timestamp,
213            operation: entry.operation.clone(),
214            kind: operation_kind(classified.kind),
215            lifecycle_state: classified.lifecycle_state,
216            status: entry.status.clone(),
217            key_ref: entry.key.as_deref().map(|key| key_ref(&entry.profile, key)),
218            message: entry.message.clone(),
219            authority,
220        }
221    }
222}
223
224impl ExecutionAuthoritySummary {
225    fn from_exec_context(profile: &str, context: Option<&AuditExecContext>) -> Self {
226        let Some(context) = context else {
227            return Self {
228                contract_name: None,
229                target: None,
230                target_decision: None,
231                matched_target: None,
232                authority_profile: None,
233                authority_namespace: None,
234                trust_level: None,
235                access_profile: None,
236                inherit: None,
237                deny_dangerous_env: None,
238                redact_output: None,
239                network: None,
240                allowed_secret_refs: Vec::new(),
241                required_secret_refs: Vec::new(),
242                injected_secret_refs: Vec::new(),
243                contract_diff: AuthorityContractDiff {
244                    unexpected_injected_secret_refs: Vec::new(),
245                    missing_required_secret_refs: Vec::new(),
246                    target_mismatch: false,
247                    dropped_env_names: Vec::new(),
248                },
249                gaps: vec![ExecutionGap::MissingExecContext],
250            };
251        };
252
253        let allowed_names = sorted_unique(&context.allowed_secrets);
254        let required_names = sorted_unique(&context.required_secrets);
255        let injected_names = sorted_unique(&context.injected_secrets);
256        let explicit_missing = sorted_unique(&context.missing_required_secrets);
257        let missing_names = if explicit_missing.is_empty() {
258            required_names
259                .iter()
260                .filter(|required| !injected_names.contains(*required))
261                .cloned()
262                .collect::<Vec<_>>()
263        } else {
264            explicit_missing
265        };
266        let unexpected_names = injected_names
267            .iter()
268            .filter(|injected| !allowed_names.contains(*injected))
269            .cloned()
270            .collect::<Vec<_>>();
271
272        let mut gaps = Vec::new();
273        if context.contract_name.as_deref().unwrap_or("").is_empty() {
274            gaps.push(ExecutionGap::MissingContractName);
275        }
276        if context.target.as_deref().unwrap_or("").is_empty() {
277            gaps.push(ExecutionGap::MissingTarget);
278        }
279        if injected_names.is_empty() {
280            gaps.push(ExecutionGap::MissingInjectedSecretSet);
281        }
282        if context.target_allowed.is_none() && context.target_decision.is_none() {
283            gaps.push(ExecutionGap::MissingTargetDecision);
284        }
285
286        let target_mismatch = match context.target_decision {
287            Some(decision) => !decision.is_allowed(),
288            None => matches!(context.target_allowed, Some(false)),
289        };
290
291        Self {
292            contract_name: context.contract_name.clone(),
293            target: context.target.clone(),
294            target_decision: context.target_decision,
295            matched_target: context.matched_target.clone(),
296            authority_profile: context.authority_profile.clone(),
297            authority_namespace: context.authority_namespace.clone(),
298            trust_level: context.trust_level,
299            access_profile: context.access_profile,
300            inherit: context.inherit,
301            deny_dangerous_env: context.deny_dangerous_env,
302            redact_output: context.redact_output,
303            network: context.network,
304            allowed_secret_refs: names_to_refs(profile, &allowed_names),
305            required_secret_refs: names_to_refs(profile, &required_names),
306            injected_secret_refs: names_to_refs(profile, &injected_names),
307            contract_diff: AuthorityContractDiff {
308                unexpected_injected_secret_refs: names_to_refs(profile, &unexpected_names),
309                missing_required_secret_refs: names_to_refs(profile, &missing_names),
310                target_mismatch,
311                dropped_env_names: sorted_unique(&context.dropped_env_names),
312            },
313            gaps,
314        }
315    }
316}
317
318fn operation_kind(kind: OperationKind) -> ExplainedOperationKind {
319    match kind {
320        OperationKind::SecretLifecycle => ExplainedOperationKind::SecretLifecycle,
321        OperationKind::VaultLifecycle => ExplainedOperationKind::VaultLifecycle,
322        OperationKind::Execution => ExplainedOperationKind::Execution,
323        OperationKind::Session => ExplainedOperationKind::Session,
324        OperationKind::Sync => ExplainedOperationKind::Sync,
325        OperationKind::Share => ExplainedOperationKind::Share,
326        OperationKind::Team => ExplainedOperationKind::Team,
327        OperationKind::RotationPolicy => ExplainedOperationKind::RotationPolicy,
328        OperationKind::CredentialHelper => ExplainedOperationKind::CredentialHelper,
329        OperationKind::Other => ExplainedOperationKind::Other,
330    }
331}
332
333fn sorted_unique(items: &[String]) -> Vec<String> {
334    let mut out = items
335        .iter()
336        .map(|item| item.trim().to_string())
337        .filter(|item| !item.is_empty())
338        .collect::<Vec<_>>();
339    out.sort();
340    out.dedup();
341    out
342}
343
344fn names_to_refs(profile: &str, names: &[String]) -> Vec<String> {
345    names.iter().map(|name| key_ref(profile, name)).collect()
346}
347
348#[derive(Debug)]
349struct SessionAccumulator {
350    profile: String,
351    session_index: usize,
352    boundary: SessionBoundary,
353    start: DateTime<Utc>,
354    last_timestamp: DateTime<Utc>,
355    operations: Vec<ExplainedAuditOperation>,
356    exec_count: usize,
357    failure_count: usize,
358}
359
360impl SessionAccumulator {
361    fn new(boundary: SessionBoundary, session_index: usize, profile: String) -> Self {
362        Self {
363            profile,
364            session_index,
365            boundary,
366            start: Utc::now(),
367            last_timestamp: Utc::now(),
368            operations: Vec::new(),
369            exec_count: 0,
370            failure_count: 0,
371        }
372    }
373
374    fn push(&mut self, entry: AuditEntry) {
375        if self.operations.is_empty() {
376            self.start = entry.timestamp;
377        }
378        self.last_timestamp = entry.timestamp;
379        if entry.operation == "exec" {
380            self.exec_count += 1;
381        }
382        if matches!(entry.status, AuditStatus::Failure) {
383            self.failure_count += 1;
384        }
385        self.operations
386            .push(ExplainedAuditOperation::from_entry(&entry));
387    }
388
389    fn finish(self) -> AuditSession {
390        AuditSession {
391            profile: self.profile,
392            session_index: self.session_index,
393            start: self.start,
394            end: self.last_timestamp,
395            boundary: self.boundary,
396            operation_count: self.operations.len(),
397            exec_count: self.exec_count,
398            failure_count: self.failure_count,
399            operations: self.operations,
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::audit::{AuditContext, AuditExecContext};
408    use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
409    use crate::lifecycle::{
410        CredentialHelperLifecycleState, LifecycleState, PolicyLifecycleState, SecretLifecycleState,
411        SessionLifecycleState, ShareLifecycleState, SyncLifecycleState, TeamLifecycleState,
412        VaultLifecycleState,
413    };
414    use crate::rbac::RbacProfile;
415
416    #[test]
417    fn exec_without_context_reports_gap() {
418        let entry = AuditEntry::success("dev", "exec", None);
419        let explained = ExplainedAuditOperation::from_entry(&entry);
420        let authority = explained
421            .authority
422            .expect("exec should produce authority summary");
423        assert_eq!(authority.gaps, vec![ExecutionGap::MissingExecContext]);
424        assert!(authority.injected_secret_refs.is_empty());
425    }
426
427    #[test]
428    fn exec_context_projects_plaintext_free_contract_diff() {
429        let contract = AuthorityContract {
430            name: "deploy".into(),
431            profile: Some("work".into()),
432            namespace: Some("infra".into()),
433            access_profile: RbacProfile::ReadOnly,
434            allow_all_secrets: false,
435            allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
436            required_secrets: vec!["DB_PASSWORD".into()],
437            allowed_targets: vec!["terraform".into()],
438            trust: AuthorityTrust::Hardened,
439            network: AuthorityNetworkPolicy::Restricted,
440        };
441        let context = AuditContext::from_exec(
442            AuditExecContext::from_contract(&contract)
443                .with_target("terraform")
444                .with_injected_secrets(["DB_PASSWORD", "UNPLANNED_TOKEN"])
445                .with_dropped_env_names(["OPENAI_API_KEY"])
446                .with_target_evaluation(&contract.evaluate_target(Some("terraform"))),
447        );
448        let entry = AuditEntry::success("dev", "exec", None).with_context(context);
449
450        let explained = ExplainedAuditOperation::from_entry(&entry);
451        let authority = explained.authority.unwrap();
452        assert_eq!(authority.contract_name.as_deref(), Some("deploy"));
453        assert_eq!(
454            authority.target_decision,
455            Some(AuthorityTargetDecision::AllowedExact)
456        );
457        assert_eq!(authority.access_profile, Some(RbacProfile::ReadOnly));
458        assert_eq!(authority.matched_target.as_deref(), Some("terraform"));
459        assert_eq!(authority.trust_level, Some(AuthorityTrustLevel::Hardened));
460        assert_eq!(authority.inherit, Some(AuthorityInheritMode::Minimal));
461        assert_eq!(authority.network, Some(AuthorityNetworkPolicy::Restricted));
462        assert_eq!(authority.allowed_secret_refs.len(), 2);
463        assert_eq!(authority.injected_secret_refs.len(), 2);
464        assert_eq!(
465            authority
466                .contract_diff
467                .unexpected_injected_secret_refs
468                .len(),
469            1
470        );
471        assert_eq!(
472            authority.contract_diff.missing_required_secret_refs.len(),
473            0
474        );
475        assert_eq!(
476            authority.contract_diff.dropped_env_names,
477            vec!["OPENAI_API_KEY"]
478        );
479        assert!(!authority
480            .allowed_secret_refs
481            .iter()
482            .any(|value| value.contains("DB_PASSWORD")));
483    }
484
485    #[test]
486    fn exec_context_denied_target_sets_mismatch_without_plaintext_leak() {
487        let contract = AuthorityContract {
488            name: "deploy".into(),
489            profile: Some("work".into()),
490            namespace: Some("infra".into()),
491            access_profile: RbacProfile::ReadOnly,
492            allow_all_secrets: false,
493            allowed_secrets: vec!["DB_PASSWORD".into()],
494            required_secrets: vec!["DB_PASSWORD".into()],
495            allowed_targets: vec!["terraform".into()],
496            trust: AuthorityTrust::Hardened,
497            network: AuthorityNetworkPolicy::Restricted,
498        };
499        let context = AuditContext::from_exec(
500            AuditExecContext::from_contract(&contract)
501                .with_target("bash")
502                .with_injected_secrets(["DB_PASSWORD"])
503                .with_target_evaluation(&contract.evaluate_target(Some("bash"))),
504        );
505        let entry = AuditEntry::success("dev", "exec", None).with_context(context);
506
507        let explained = ExplainedAuditOperation::from_entry(&entry);
508        let authority = explained.authority.unwrap();
509        assert_eq!(
510            authority.target_decision,
511            Some(AuthorityTargetDecision::Denied)
512        );
513        assert!(authority.contract_diff.target_mismatch);
514        assert!(authority.matched_target.is_none());
515        assert!(!serde_json::to_string(&authority)
516            .unwrap()
517            .contains("DB_PASSWORD"));
518    }
519
520    #[test]
521    fn timeline_splits_on_unlock_and_idle_gap() {
522        let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
523            .unwrap()
524            .with_timezone(&Utc);
525
526        let mut unlock = AuditEntry::success("dev", "unlock", None);
527        unlock.timestamp = base;
528
529        let mut exec = AuditEntry::success("dev", "exec", None);
530        exec.timestamp = base + Duration::minutes(5);
531
532        let mut get = AuditEntry::success("dev", "get", Some("API_KEY"));
533        get.timestamp = base + Duration::minutes(7);
534
535        let mut later = AuditEntry::failure("dev", "get", Some("MISSING"), "not found");
536        later.timestamp = base + Duration::minutes(80);
537
538        let timeline = explain_entries_with_gap(&[later, exec, unlock, get], Duration::minutes(30));
539        assert_eq!(timeline.sessions.len(), 2);
540        assert_eq!(timeline.sessions[0].boundary, SessionBoundary::StartOfLog);
541        assert_eq!(timeline.sessions[0].operation_count, 3);
542        assert_eq!(timeline.sessions[0].exec_count, 1);
543        assert_eq!(timeline.sessions[1].boundary, SessionBoundary::TimeGap);
544        assert_eq!(timeline.sessions[1].failure_count, 1);
545    }
546
547    #[test]
548    fn timeline_splits_on_profile_change() {
549        let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
550            .unwrap()
551            .with_timezone(&Utc);
552
553        let mut left = AuditEntry::success("dev", "get", Some("A"));
554        left.timestamp = base;
555        let mut right = AuditEntry::success("prod", "get", Some("B"));
556        right.timestamp = base + Duration::minutes(1);
557
558        let timeline = explain_entries_with_gap(&[left, right], Duration::minutes(30));
559        assert_eq!(timeline.sessions.len(), 2);
560        assert_eq!(
561            timeline.sessions[1].boundary,
562            SessionBoundary::ProfileChange
563        );
564    }
565
566    #[test]
567    fn vault_and_share_entries_expose_explicit_lifecycle_state() {
568        let created =
569            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "init", None));
570        assert_eq!(
571            created.lifecycle_state,
572            Some(LifecycleState::Vault(VaultLifecycleState::Created))
573        );
574
575        let published =
576            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "share-once", None));
577        assert_eq!(
578            published.lifecycle_state,
579            Some(LifecycleState::Share(ShareLifecycleState::Published))
580        );
581    }
582
583    #[test]
584    fn secret_entries_expose_explicit_lifecycle_state() {
585        let written = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "set", None));
586        assert_eq!(
587            written.lifecycle_state,
588            Some(LifecycleState::Secret(SecretLifecycleState::Written))
589        );
590
591        let accessed =
592            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "get", None));
593        assert_eq!(
594            accessed.lifecycle_state,
595            Some(LifecycleState::Secret(SecretLifecycleState::Accessed))
596        );
597
598        let deleted =
599            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "delete", None));
600        assert_eq!(
601            deleted.lifecycle_state,
602            Some(LifecycleState::Secret(SecretLifecycleState::Deleted))
603        );
604
605        let imported =
606            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "import", None));
607        assert_eq!(
608            imported.lifecycle_state,
609            Some(LifecycleState::Secret(SecretLifecycleState::Imported))
610        );
611
612        let exported =
613            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "export", None));
614        assert_eq!(
615            exported.lifecycle_state,
616            Some(LifecycleState::Secret(SecretLifecycleState::Exported))
617        );
618
619        let namespace_copy =
620            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-copy", None));
621        assert_eq!(
622            namespace_copy.lifecycle_state,
623            Some(LifecycleState::Secret(SecretLifecycleState::Written))
624        );
625    }
626
627    #[test]
628    fn surface_aliases_reuse_existing_vault_lifecycle_state() {
629        let created =
630            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "create", None));
631        assert_eq!(
632            created.lifecycle_state,
633            Some(LifecycleState::Vault(VaultLifecycleState::Created))
634        );
635
636        let team_created =
637            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "team-init", None));
638        assert_eq!(
639            team_created.lifecycle_state,
640            Some(LifecycleState::Vault(VaultLifecycleState::Created))
641        );
642
643        let namespace_move =
644            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-move", None));
645        assert_eq!(
646            namespace_move.lifecycle_state,
647            Some(LifecycleState::Vault(VaultLifecycleState::SecretMoved))
648        );
649    }
650
651    #[test]
652    fn policy_and_helper_entries_expose_explicit_lifecycle_state() {
653        let policy_set =
654            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "policy-set", None));
655        assert_eq!(
656            policy_set.lifecycle_state,
657            Some(LifecycleState::Policy(PolicyLifecycleState::Set))
658        );
659
660        let policy_due =
661            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "rotate-due", None));
662        assert_eq!(
663            policy_due.lifecycle_state,
664            Some(LifecycleState::Policy(PolicyLifecycleState::DueChecked))
665        );
666
667        let helper_get = ExplainedAuditOperation::from_entry(&AuditEntry::success(
668            "dev",
669            "credential-helper-get",
670            None,
671        ));
672        assert_eq!(
673            helper_get.lifecycle_state,
674            Some(LifecycleState::CredentialHelper(
675                CredentialHelperLifecycleState::Accessed
676            ))
677        );
678
679        let helper_store = ExplainedAuditOperation::from_entry(&AuditEntry::success(
680            "dev",
681            "credential-helper-store",
682            None,
683        ));
684        assert_eq!(
685            helper_store.lifecycle_state,
686            Some(LifecycleState::CredentialHelper(
687                CredentialHelperLifecycleState::Stored
688            ))
689        );
690    }
691
692    #[test]
693    fn team_entries_expose_explicit_lifecycle_state_and_kind() {
694        let added = ExplainedAuditOperation::from_entry(&AuditEntry::success(
695            "dev",
696            "team-add-member",
697            None,
698        ));
699        assert_eq!(added.kind, ExplainedOperationKind::Team);
700        assert_eq!(
701            added.lifecycle_state,
702            Some(LifecycleState::Team(TeamLifecycleState::MemberAdded))
703        );
704
705        let removed = ExplainedAuditOperation::from_entry(&AuditEntry::success(
706            "dev",
707            "team-remove-member",
708            None,
709        ));
710        assert_eq!(removed.kind, ExplainedOperationKind::Team);
711        assert_eq!(
712            removed.lifecycle_state,
713            Some(LifecycleState::Team(TeamLifecycleState::MemberRemoved))
714        );
715    }
716
717    #[test]
718    fn session_and_sync_entries_expose_explicit_lifecycle_state() {
719        let unlocked =
720            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "unlock", None));
721        assert_eq!(
722            unlocked.lifecycle_state,
723            Some(LifecycleState::Session(SessionLifecycleState::Unlocked))
724        );
725
726        let pulled =
727            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "kv-pull", None));
728        assert_eq!(
729            pulled.lifecycle_state,
730            Some(LifecycleState::Sync(SyncLifecycleState::PullCompleted))
731        );
732
733        let merged = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "sync", None));
734        assert_eq!(
735            merged.lifecycle_state,
736            Some(LifecycleState::Sync(SyncLifecycleState::Merged))
737        );
738    }
739}