Skip to main content

tsafe_core/
lifecycle.rs

1//! Shared audit/event lifecycle classification.
2//!
3//! This module gives `tsafe-core` one place to answer:
4//! - what broad kind of operation happened
5//! - whether that operation belongs to an explicit lifecycle state
6//! - which CloudEvents type should be emitted when the operation is well-known
7//!
8//! The goal is to keep audit explanation and event projection aligned instead of
9//! carrying multiple hard-coded operation taxonomies.
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum OperationKind {
16    SecretLifecycle,
17    VaultLifecycle,
18    Execution,
19    Session,
20    Sync,
21    Share,
22    Team,
23    RotationPolicy,
24    CredentialHelper,
25    Other,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum VaultLifecycleState {
31    Created,
32    PasswordRotated,
33    SnapshotRestored,
34    SecretMoved,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum SecretLifecycleState {
40    Written,
41    Accessed,
42    Deleted,
43    Imported,
44    Exported,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ShareLifecycleState {
50    Published,
51    Consumed,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum TeamLifecycleState {
57    MemberAdded,
58    MemberRemoved,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum PolicyLifecycleState {
64    Set,
65    Removed,
66    DueChecked,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum SessionLifecycleState {
72    Unlocked,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SyncLifecycleState {
78    PullCompleted,
79    Merged,
80    TeamKeysSynced,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum CredentialHelperLifecycleState {
86    Accessed,
87    Stored,
88    Erased,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(tag = "domain", content = "state", rename_all = "snake_case")]
93pub enum LifecycleState {
94    Secret(SecretLifecycleState),
95    Vault(VaultLifecycleState),
96    Share(ShareLifecycleState),
97    Team(TeamLifecycleState),
98    Policy(PolicyLifecycleState),
99    Session(SessionLifecycleState),
100    Sync(SyncLifecycleState),
101    CredentialHelper(CredentialHelperLifecycleState),
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct OperationClassification {
106    pub kind: OperationKind,
107    pub lifecycle_state: Option<LifecycleState>,
108    pub event_type: Option<&'static str>,
109}
110
111pub fn classify_operation(operation: &str) -> OperationClassification {
112    match operation {
113        "set" => secret(
114            SecretLifecycleState::Written,
115            Some("com.tsafe.vault.secret.set.v1"),
116        ),
117        "delete" => secret(
118            SecretLifecycleState::Deleted,
119            Some("com.tsafe.vault.secret.deleted.v1"),
120        ),
121        "get" | "browser-get" | "browser-list" => secret(
122            SecretLifecycleState::Accessed,
123            Some("com.tsafe.vault.secret.accessed.v1"),
124        ),
125        "alias" | "gen" | "pin" | "unpin" | "ssh-add" | "ssh-import" | "totp-add"
126        | "browser-save" => secret(SecretLifecycleState::Written, None),
127        "totp-get" | "qr" => secret(SecretLifecycleState::Accessed, None),
128        "import" => secret(
129            SecretLifecycleState::Imported,
130            Some("com.tsafe.vault.imported.v1"),
131        ),
132        "export" => secret(
133            SecretLifecycleState::Exported,
134            Some("com.tsafe.vault.exported.v1"),
135        ),
136
137        "init" | "create" | "team-init" => vault(
138            VaultLifecycleState::Created,
139            Some("com.tsafe.vault.created.v1"),
140        ),
141        "rotate" => vault(
142            VaultLifecycleState::PasswordRotated,
143            Some("com.tsafe.vault.rotated.v1"),
144        ),
145        "snapshot-restore" => vault(VaultLifecycleState::SnapshotRestored, None),
146        "mv" | "ns-move" => vault(VaultLifecycleState::SecretMoved, None),
147        "ns-copy" => secret(SecretLifecycleState::Written, None),
148
149        "exec" | "plugin" => OperationClassification {
150            kind: OperationKind::Execution,
151            lifecycle_state: None,
152            event_type: Some("com.tsafe.vault.exec.v1"),
153        },
154        "unlock" => session(
155            SessionLifecycleState::Unlocked,
156            Some("com.tsafe.session.unlocked.v1"),
157        ),
158        "kv-pull" | "vault-pull" | "op-pull" | "pull" => sync(
159            SyncLifecycleState::PullCompleted,
160            Some("com.tsafe.sync.pull.completed.v1"),
161        ),
162        "sync" => sync(SyncLifecycleState::Merged, None),
163        "team-sync-keys" => sync(SyncLifecycleState::TeamKeysSynced, None),
164        "team-add-member" => team(TeamLifecycleState::MemberAdded),
165        "team-remove-member" => team(TeamLifecycleState::MemberRemoved),
166        "share-once" | "snap" => share(
167            ShareLifecycleState::Published,
168            Some("com.tsafe.share.published.v1"),
169        ),
170        "receive-once" | "snap-receive" => share(
171            ShareLifecycleState::Consumed,
172            Some("com.tsafe.share.consumed.v1"),
173        ),
174        "policy-set" => policy(PolicyLifecycleState::Set, None),
175        "policy-remove" => policy(PolicyLifecycleState::Removed, None),
176        "doctor" => OperationClassification {
177            kind: OperationKind::RotationPolicy,
178            lifecycle_state: None,
179            event_type: None,
180        },
181        "rotate-due" => policy(
182            PolicyLifecycleState::DueChecked,
183            Some("com.tsafe.secret.rotation_due.v1"),
184        ),
185        "credential-helper-get" => helper(CredentialHelperLifecycleState::Accessed),
186        "credential-helper-store" => helper(CredentialHelperLifecycleState::Stored),
187        "credential-helper-erase" => helper(CredentialHelperLifecycleState::Erased),
188        _ => OperationClassification {
189            kind: OperationKind::Other,
190            lifecycle_state: None,
191            event_type: None,
192        },
193    }
194}
195
196fn secret(
197    state: SecretLifecycleState,
198    event_type: Option<&'static str>,
199) -> OperationClassification {
200    OperationClassification {
201        kind: OperationKind::SecretLifecycle,
202        lifecycle_state: Some(LifecycleState::Secret(state)),
203        event_type,
204    }
205}
206
207fn vault(state: VaultLifecycleState, event_type: Option<&'static str>) -> OperationClassification {
208    OperationClassification {
209        kind: OperationKind::VaultLifecycle,
210        lifecycle_state: Some(LifecycleState::Vault(state)),
211        event_type,
212    }
213}
214
215fn share(state: ShareLifecycleState, event_type: Option<&'static str>) -> OperationClassification {
216    OperationClassification {
217        kind: OperationKind::Share,
218        lifecycle_state: Some(LifecycleState::Share(state)),
219        event_type,
220    }
221}
222
223fn policy(
224    state: PolicyLifecycleState,
225    event_type: Option<&'static str>,
226) -> OperationClassification {
227    OperationClassification {
228        kind: OperationKind::RotationPolicy,
229        lifecycle_state: Some(LifecycleState::Policy(state)),
230        event_type,
231    }
232}
233
234fn team(state: TeamLifecycleState) -> OperationClassification {
235    OperationClassification {
236        kind: OperationKind::Team,
237        lifecycle_state: Some(LifecycleState::Team(state)),
238        event_type: None,
239    }
240}
241
242fn session(
243    state: SessionLifecycleState,
244    event_type: Option<&'static str>,
245) -> OperationClassification {
246    OperationClassification {
247        kind: OperationKind::Session,
248        lifecycle_state: Some(LifecycleState::Session(state)),
249        event_type,
250    }
251}
252
253fn sync(state: SyncLifecycleState, event_type: Option<&'static str>) -> OperationClassification {
254    OperationClassification {
255        kind: OperationKind::Sync,
256        lifecycle_state: Some(LifecycleState::Sync(state)),
257        event_type,
258    }
259}
260
261fn helper(state: CredentialHelperLifecycleState) -> OperationClassification {
262    OperationClassification {
263        kind: OperationKind::CredentialHelper,
264        lifecycle_state: Some(LifecycleState::CredentialHelper(state)),
265        event_type: None,
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn init_is_classified_as_created_vault_lifecycle() {
275        let classified = classify_operation("init");
276        assert_eq!(classified.kind, OperationKind::VaultLifecycle);
277        assert_eq!(
278            classified.lifecycle_state,
279            Some(LifecycleState::Vault(VaultLifecycleState::Created))
280        );
281        assert_eq!(classified.event_type, Some("com.tsafe.vault.created.v1"));
282    }
283
284    #[test]
285    fn create_and_team_init_reuse_created_vault_lifecycle() {
286        let created = classify_operation("create");
287        assert_eq!(created.kind, OperationKind::VaultLifecycle);
288        assert_eq!(
289            created.lifecycle_state,
290            Some(LifecycleState::Vault(VaultLifecycleState::Created))
291        );
292        assert_eq!(created.event_type, Some("com.tsafe.vault.created.v1"));
293
294        let team_created = classify_operation("team-init");
295        assert_eq!(team_created.kind, OperationKind::VaultLifecycle);
296        assert_eq!(
297            team_created.lifecycle_state,
298            Some(LifecycleState::Vault(VaultLifecycleState::Created))
299        );
300        assert_eq!(team_created.event_type, Some("com.tsafe.vault.created.v1"));
301    }
302
303    #[test]
304    fn share_once_is_classified_as_published_share() {
305        let classified = classify_operation("share-once");
306        assert_eq!(classified.kind, OperationKind::Share);
307        assert_eq!(
308            classified.lifecycle_state,
309            Some(LifecycleState::Share(ShareLifecycleState::Published))
310        );
311        assert_eq!(classified.event_type, Some("com.tsafe.share.published.v1"));
312    }
313
314    #[test]
315    fn snapshot_restore_keeps_explicit_vault_state_without_forcing_new_event_type() {
316        let classified = classify_operation("snapshot-restore");
317        assert_eq!(classified.kind, OperationKind::VaultLifecycle);
318        assert_eq!(
319            classified.lifecycle_state,
320            Some(LifecycleState::Vault(VaultLifecycleState::SnapshotRestored))
321        );
322        assert_eq!(classified.event_type, None);
323    }
324
325    #[test]
326    fn unlock_is_classified_as_unlocked_session() {
327        let classified = classify_operation("unlock");
328        assert_eq!(classified.kind, OperationKind::Session);
329        assert_eq!(
330            classified.lifecycle_state,
331            Some(LifecycleState::Session(SessionLifecycleState::Unlocked))
332        );
333        assert_eq!(classified.event_type, Some("com.tsafe.session.unlocked.v1"));
334    }
335
336    #[test]
337    fn pull_and_sync_operations_have_explicit_sync_states() {
338        let pull = classify_operation("kv-pull");
339        assert_eq!(pull.kind, OperationKind::Sync);
340        assert_eq!(
341            pull.lifecycle_state,
342            Some(LifecycleState::Sync(SyncLifecycleState::PullCompleted))
343        );
344        assert_eq!(pull.event_type, Some("com.tsafe.sync.pull.completed.v1"));
345
346        let merged = classify_operation("sync");
347        assert_eq!(
348            merged.lifecycle_state,
349            Some(LifecycleState::Sync(SyncLifecycleState::Merged))
350        );
351        assert_eq!(merged.event_type, None);
352
353        let team = classify_operation("team-sync-keys");
354        assert_eq!(
355            team.lifecycle_state,
356            Some(LifecycleState::Sync(SyncLifecycleState::TeamKeysSynced))
357        );
358        assert_eq!(team.event_type, None);
359    }
360
361    #[test]
362    fn secret_operations_have_explicit_secret_states() {
363        let set = classify_operation("set");
364        assert_eq!(set.kind, OperationKind::SecretLifecycle);
365        assert_eq!(
366            set.lifecycle_state,
367            Some(LifecycleState::Secret(SecretLifecycleState::Written))
368        );
369        assert_eq!(set.event_type, Some("com.tsafe.vault.secret.set.v1"));
370
371        let get = classify_operation("get");
372        assert_eq!(
373            get.lifecycle_state,
374            Some(LifecycleState::Secret(SecretLifecycleState::Accessed))
375        );
376        assert_eq!(get.event_type, Some("com.tsafe.vault.secret.accessed.v1"));
377
378        let delete = classify_operation("delete");
379        assert_eq!(
380            delete.lifecycle_state,
381            Some(LifecycleState::Secret(SecretLifecycleState::Deleted))
382        );
383        assert_eq!(delete.event_type, Some("com.tsafe.vault.secret.deleted.v1"));
384
385        let imported = classify_operation("import");
386        assert_eq!(
387            imported.lifecycle_state,
388            Some(LifecycleState::Secret(SecretLifecycleState::Imported))
389        );
390
391        let exported = classify_operation("export");
392        assert_eq!(
393            exported.lifecycle_state,
394            Some(LifecycleState::Secret(SecretLifecycleState::Exported))
395        );
396
397        let generated = classify_operation("gen");
398        assert_eq!(
399            generated.lifecycle_state,
400            Some(LifecycleState::Secret(SecretLifecycleState::Written))
401        );
402        assert_eq!(generated.event_type, None);
403
404        let qr = classify_operation("qr");
405        assert_eq!(
406            qr.lifecycle_state,
407            Some(LifecycleState::Secret(SecretLifecycleState::Accessed))
408        );
409        assert_eq!(qr.event_type, None);
410
411        let namespace_copy = classify_operation("ns-copy");
412        assert_eq!(
413            namespace_copy.lifecycle_state,
414            Some(LifecycleState::Secret(SecretLifecycleState::Written))
415        );
416        assert_eq!(namespace_copy.event_type, None);
417    }
418
419    #[test]
420    fn namespace_move_reuses_secret_moved_vault_state() {
421        let namespace_move = classify_operation("ns-move");
422        assert_eq!(namespace_move.kind, OperationKind::VaultLifecycle);
423        assert_eq!(
424            namespace_move.lifecycle_state,
425            Some(LifecycleState::Vault(VaultLifecycleState::SecretMoved))
426        );
427        assert_eq!(namespace_move.event_type, None);
428    }
429
430    #[test]
431    fn policy_operations_have_explicit_policy_states() {
432        let set = classify_operation("policy-set");
433        assert_eq!(set.kind, OperationKind::RotationPolicy);
434        assert_eq!(
435            set.lifecycle_state,
436            Some(LifecycleState::Policy(PolicyLifecycleState::Set))
437        );
438        assert_eq!(set.event_type, None);
439
440        let removed = classify_operation("policy-remove");
441        assert_eq!(
442            removed.lifecycle_state,
443            Some(LifecycleState::Policy(PolicyLifecycleState::Removed))
444        );
445        assert_eq!(removed.event_type, None);
446
447        let due = classify_operation("rotate-due");
448        assert_eq!(
449            due.lifecycle_state,
450            Some(LifecycleState::Policy(PolicyLifecycleState::DueChecked))
451        );
452        assert_eq!(due.event_type, Some("com.tsafe.secret.rotation_due.v1"));
453    }
454
455    #[test]
456    fn credential_helper_operations_have_explicit_helper_states() {
457        let get = classify_operation("credential-helper-get");
458        assert_eq!(get.kind, OperationKind::CredentialHelper);
459        assert_eq!(
460            get.lifecycle_state,
461            Some(LifecycleState::CredentialHelper(
462                CredentialHelperLifecycleState::Accessed
463            ))
464        );
465        assert_eq!(get.event_type, None);
466
467        let store = classify_operation("credential-helper-store");
468        assert_eq!(
469            store.lifecycle_state,
470            Some(LifecycleState::CredentialHelper(
471                CredentialHelperLifecycleState::Stored
472            ))
473        );
474
475        let erase = classify_operation("credential-helper-erase");
476        assert_eq!(
477            erase.lifecycle_state,
478            Some(LifecycleState::CredentialHelper(
479                CredentialHelperLifecycleState::Erased
480            ))
481        );
482    }
483
484    #[test]
485    fn team_membership_operations_have_explicit_team_states() {
486        let add = classify_operation("team-add-member");
487        assert_eq!(add.kind, OperationKind::Team);
488        assert_eq!(
489            add.lifecycle_state,
490            Some(LifecycleState::Team(TeamLifecycleState::MemberAdded))
491        );
492        assert_eq!(add.event_type, None);
493
494        let remove = classify_operation("team-remove-member");
495        assert_eq!(
496            remove.lifecycle_state,
497            Some(LifecycleState::Team(TeamLifecycleState::MemberRemoved))
498        );
499        assert_eq!(remove.event_type, None);
500    }
501
502    #[test]
503    fn unknown_operation_falls_back_to_other() {
504        let classified = classify_operation("custom-op");
505        assert_eq!(classified.kind, OperationKind::Other);
506        assert_eq!(classified.lifecycle_state, None);
507        assert_eq!(classified.event_type, None);
508    }
509}