1use 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#[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#[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 allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
435 required_secrets: vec!["DB_PASSWORD".into()],
436 allowed_targets: vec!["terraform".into()],
437 trust: AuthorityTrust::Hardened,
438 network: AuthorityNetworkPolicy::Restricted,
439 };
440 let context = AuditContext::from_exec(
441 AuditExecContext::from_contract(&contract)
442 .with_target("terraform")
443 .with_injected_secrets(["DB_PASSWORD", "UNPLANNED_TOKEN"])
444 .with_dropped_env_names(["OPENAI_API_KEY"])
445 .with_target_evaluation(&contract.evaluate_target(Some("terraform"))),
446 );
447 let entry = AuditEntry::success("dev", "exec", None).with_context(context);
448
449 let explained = ExplainedAuditOperation::from_entry(&entry);
450 let authority = explained.authority.unwrap();
451 assert_eq!(authority.contract_name.as_deref(), Some("deploy"));
452 assert_eq!(
453 authority.target_decision,
454 Some(AuthorityTargetDecision::AllowedExact)
455 );
456 assert_eq!(authority.access_profile, Some(RbacProfile::ReadOnly));
457 assert_eq!(authority.matched_target.as_deref(), Some("terraform"));
458 assert_eq!(authority.trust_level, Some(AuthorityTrustLevel::Hardened));
459 assert_eq!(authority.inherit, Some(AuthorityInheritMode::Minimal));
460 assert_eq!(authority.network, Some(AuthorityNetworkPolicy::Restricted));
461 assert_eq!(authority.allowed_secret_refs.len(), 2);
462 assert_eq!(authority.injected_secret_refs.len(), 2);
463 assert_eq!(
464 authority
465 .contract_diff
466 .unexpected_injected_secret_refs
467 .len(),
468 1
469 );
470 assert_eq!(
471 authority.contract_diff.missing_required_secret_refs.len(),
472 0
473 );
474 assert_eq!(
475 authority.contract_diff.dropped_env_names,
476 vec!["OPENAI_API_KEY"]
477 );
478 assert!(!authority
479 .allowed_secret_refs
480 .iter()
481 .any(|value| value.contains("DB_PASSWORD")));
482 }
483
484 #[test]
485 fn exec_context_denied_target_sets_mismatch_without_plaintext_leak() {
486 let contract = AuthorityContract {
487 name: "deploy".into(),
488 profile: Some("work".into()),
489 namespace: Some("infra".into()),
490 access_profile: RbacProfile::ReadOnly,
491 allowed_secrets: vec!["DB_PASSWORD".into()],
492 required_secrets: vec!["DB_PASSWORD".into()],
493 allowed_targets: vec!["terraform".into()],
494 trust: AuthorityTrust::Hardened,
495 network: AuthorityNetworkPolicy::Restricted,
496 };
497 let context = AuditContext::from_exec(
498 AuditExecContext::from_contract(&contract)
499 .with_target("bash")
500 .with_injected_secrets(["DB_PASSWORD"])
501 .with_target_evaluation(&contract.evaluate_target(Some("bash"))),
502 );
503 let entry = AuditEntry::success("dev", "exec", None).with_context(context);
504
505 let explained = ExplainedAuditOperation::from_entry(&entry);
506 let authority = explained.authority.unwrap();
507 assert_eq!(
508 authority.target_decision,
509 Some(AuthorityTargetDecision::Denied)
510 );
511 assert!(authority.contract_diff.target_mismatch);
512 assert!(authority.matched_target.is_none());
513 assert!(!serde_json::to_string(&authority)
514 .unwrap()
515 .contains("DB_PASSWORD"));
516 }
517
518 #[test]
519 fn timeline_splits_on_unlock_and_idle_gap() {
520 let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
521 .unwrap()
522 .with_timezone(&Utc);
523
524 let mut unlock = AuditEntry::success("dev", "unlock", None);
525 unlock.timestamp = base;
526
527 let mut exec = AuditEntry::success("dev", "exec", None);
528 exec.timestamp = base + Duration::minutes(5);
529
530 let mut get = AuditEntry::success("dev", "get", Some("API_KEY"));
531 get.timestamp = base + Duration::minutes(7);
532
533 let mut later = AuditEntry::failure("dev", "get", Some("MISSING"), "not found");
534 later.timestamp = base + Duration::minutes(80);
535
536 let timeline = explain_entries_with_gap(&[later, exec, unlock, get], Duration::minutes(30));
537 assert_eq!(timeline.sessions.len(), 2);
538 assert_eq!(timeline.sessions[0].boundary, SessionBoundary::StartOfLog);
539 assert_eq!(timeline.sessions[0].operation_count, 3);
540 assert_eq!(timeline.sessions[0].exec_count, 1);
541 assert_eq!(timeline.sessions[1].boundary, SessionBoundary::TimeGap);
542 assert_eq!(timeline.sessions[1].failure_count, 1);
543 }
544
545 #[test]
546 fn timeline_splits_on_profile_change() {
547 let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
548 .unwrap()
549 .with_timezone(&Utc);
550
551 let mut left = AuditEntry::success("dev", "get", Some("A"));
552 left.timestamp = base;
553 let mut right = AuditEntry::success("prod", "get", Some("B"));
554 right.timestamp = base + Duration::minutes(1);
555
556 let timeline = explain_entries_with_gap(&[left, right], Duration::minutes(30));
557 assert_eq!(timeline.sessions.len(), 2);
558 assert_eq!(
559 timeline.sessions[1].boundary,
560 SessionBoundary::ProfileChange
561 );
562 }
563
564 #[test]
565 fn vault_and_share_entries_expose_explicit_lifecycle_state() {
566 let created =
567 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "init", None));
568 assert_eq!(
569 created.lifecycle_state,
570 Some(LifecycleState::Vault(VaultLifecycleState::Created))
571 );
572
573 let published =
574 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "share-once", None));
575 assert_eq!(
576 published.lifecycle_state,
577 Some(LifecycleState::Share(ShareLifecycleState::Published))
578 );
579 }
580
581 #[test]
582 fn secret_entries_expose_explicit_lifecycle_state() {
583 let written = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "set", None));
584 assert_eq!(
585 written.lifecycle_state,
586 Some(LifecycleState::Secret(SecretLifecycleState::Written))
587 );
588
589 let accessed =
590 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "get", None));
591 assert_eq!(
592 accessed.lifecycle_state,
593 Some(LifecycleState::Secret(SecretLifecycleState::Accessed))
594 );
595
596 let deleted =
597 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "delete", None));
598 assert_eq!(
599 deleted.lifecycle_state,
600 Some(LifecycleState::Secret(SecretLifecycleState::Deleted))
601 );
602
603 let imported =
604 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "import", None));
605 assert_eq!(
606 imported.lifecycle_state,
607 Some(LifecycleState::Secret(SecretLifecycleState::Imported))
608 );
609
610 let exported =
611 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "export", None));
612 assert_eq!(
613 exported.lifecycle_state,
614 Some(LifecycleState::Secret(SecretLifecycleState::Exported))
615 );
616
617 let namespace_copy =
618 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-copy", None));
619 assert_eq!(
620 namespace_copy.lifecycle_state,
621 Some(LifecycleState::Secret(SecretLifecycleState::Written))
622 );
623 }
624
625 #[test]
626 fn surface_aliases_reuse_existing_vault_lifecycle_state() {
627 let created =
628 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "create", None));
629 assert_eq!(
630 created.lifecycle_state,
631 Some(LifecycleState::Vault(VaultLifecycleState::Created))
632 );
633
634 let team_created =
635 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "team-init", None));
636 assert_eq!(
637 team_created.lifecycle_state,
638 Some(LifecycleState::Vault(VaultLifecycleState::Created))
639 );
640
641 let namespace_move =
642 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-move", None));
643 assert_eq!(
644 namespace_move.lifecycle_state,
645 Some(LifecycleState::Vault(VaultLifecycleState::SecretMoved))
646 );
647 }
648
649 #[test]
650 fn policy_and_helper_entries_expose_explicit_lifecycle_state() {
651 let policy_set =
652 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "policy-set", None));
653 assert_eq!(
654 policy_set.lifecycle_state,
655 Some(LifecycleState::Policy(PolicyLifecycleState::Set))
656 );
657
658 let policy_due =
659 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "rotate-due", None));
660 assert_eq!(
661 policy_due.lifecycle_state,
662 Some(LifecycleState::Policy(PolicyLifecycleState::DueChecked))
663 );
664
665 let helper_get = ExplainedAuditOperation::from_entry(&AuditEntry::success(
666 "dev",
667 "credential-helper-get",
668 None,
669 ));
670 assert_eq!(
671 helper_get.lifecycle_state,
672 Some(LifecycleState::CredentialHelper(
673 CredentialHelperLifecycleState::Accessed
674 ))
675 );
676
677 let helper_store = ExplainedAuditOperation::from_entry(&AuditEntry::success(
678 "dev",
679 "credential-helper-store",
680 None,
681 ));
682 assert_eq!(
683 helper_store.lifecycle_state,
684 Some(LifecycleState::CredentialHelper(
685 CredentialHelperLifecycleState::Stored
686 ))
687 );
688 }
689
690 #[test]
691 fn team_entries_expose_explicit_lifecycle_state_and_kind() {
692 let added = ExplainedAuditOperation::from_entry(&AuditEntry::success(
693 "dev",
694 "team-add-member",
695 None,
696 ));
697 assert_eq!(added.kind, ExplainedOperationKind::Team);
698 assert_eq!(
699 added.lifecycle_state,
700 Some(LifecycleState::Team(TeamLifecycleState::MemberAdded))
701 );
702
703 let removed = ExplainedAuditOperation::from_entry(&AuditEntry::success(
704 "dev",
705 "team-remove-member",
706 None,
707 ));
708 assert_eq!(removed.kind, ExplainedOperationKind::Team);
709 assert_eq!(
710 removed.lifecycle_state,
711 Some(LifecycleState::Team(TeamLifecycleState::MemberRemoved))
712 );
713 }
714
715 #[test]
716 fn session_and_sync_entries_expose_explicit_lifecycle_state() {
717 let unlocked =
718 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "unlock", None));
719 assert_eq!(
720 unlocked.lifecycle_state,
721 Some(LifecycleState::Session(SessionLifecycleState::Unlocked))
722 );
723
724 let pulled =
725 ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "kv-pull", None));
726 assert_eq!(
727 pulled.lifecycle_state,
728 Some(LifecycleState::Sync(SyncLifecycleState::PullCompleted))
729 );
730
731 let merged = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "sync", None));
732 assert_eq!(
733 merged.lifecycle_state,
734 Some(LifecycleState::Sync(SyncLifecycleState::Merged))
735 );
736 }
737}