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 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}