Skip to main content

bitwarden_pam/rotation/
actions.rs

1//! Which actions a rotation config currently offers.
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6    configs::RotationConfigView,
7    models::{TargetSystemMethod, TargetSystemStatus},
8};
9
10/// The actions a rotation config offers right now, derived together so no pair contradicts.
11#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
12#[bitwarden_ffi::wasm_record]
13#[serde(rename_all = "camelCase")]
14pub struct RotationConfigActions {
15    /// Whether an on-demand rotation may be dispatched.
16    pub can_rotate_now: bool,
17    /// Whether the operator may record an out-of-band rotation.
18    pub can_record_manual: bool,
19    /// Whether the account identity and delete actions are locked.
20    pub mutations_locked: bool,
21    /// Whether the config may be paused.
22    pub can_pause: bool,
23    /// Whether the config may be resumed.
24    pub can_resume: bool,
25}
26
27/// Derives the actions a config offers, given the status of its target system. Pass `None` while
28/// the target system has not loaded; every dependent predicate then fails closed.
29pub fn rotation_config_actions(
30    config: &RotationConfigView,
31    target_status: Option<TargetSystemStatus>,
32) -> RotationConfigActions {
33    RotationConfigActions {
34        // All four conditions mirror the server's own guard on dispatching a job; `Unknown`
35        // for method or status fails closed.
36        can_rotate_now: config.enabled
37            && config.target_system_method == TargetSystemMethod::Automatic
38            && target_status == Some(TargetSystemStatus::Active)
39            && !config.has_active_job,
40        can_record_manual: config.target_system_method == TargetSystemMethod::Manual,
41        // A running job owns the credential until it resolves; editing the account or deleting
42        // the config underneath it would leave the target and vault disagreeing.
43        mutations_locked: config.has_active_job,
44        can_pause: config.enabled,
45        can_resume: !config.enabled,
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use chrono::{DateTime, Utc};
52    use uuid::uuid;
53
54    use super::*;
55    use crate::{RotationConfigId, RotationConfigSealedFields, TargetSystemId};
56
57    fn timestamp() -> DateTime<Utc> {
58        "2026-01-01T00:00:00Z".parse().expect("a valid timestamp")
59    }
60
61    /// An automatic, enabled, idle config, the one shape that offers a rotation. Each test varies
62    /// one field so the failing condition is unambiguous.
63    fn rotatable_config() -> RotationConfigView {
64        RotationConfigView {
65            id: RotationConfigId::new(uuid!("11111111-1111-1111-1111-111111111111")),
66            organization_id: bitwarden_core::OrganizationId::new(uuid!(
67                "22222222-2222-2222-2222-222222222222"
68            )),
69            cipher_id: bitwarden_vault::CipherId::new(uuid!(
70                "33333333-3333-3333-3333-333333333333"
71            )),
72            target_system_id: TargetSystemId::new(uuid!("44444444-4444-4444-4444-444444444444")),
73            target_system_name: "Prod SQL".to_string(),
74            target_system_method: TargetSystemMethod::Automatic,
75            sealed: Some(RotationConfigSealedFields {
76                account_identity: "svc_rotation".to_string(),
77                terminate_sessions: false,
78            }),
79            schedule_cron: None,
80            rotate_on_access_end: false,
81            enabled: true,
82            last_rotation_at: None,
83            next_rotation_at: None,
84            has_active_job: false,
85            awaiting_manual_rotation: false,
86            creation_date: timestamp(),
87            revision_date: timestamp(),
88        }
89    }
90
91    #[test]
92    fn an_enabled_idle_automatic_config_on_an_active_target_can_rotate() {
93        let actions =
94            rotation_config_actions(&rotatable_config(), Some(TargetSystemStatus::Active));
95
96        assert!(actions.can_rotate_now);
97        assert!(!actions.can_record_manual);
98        assert!(!actions.mutations_locked);
99        assert!(actions.can_pause);
100        assert!(!actions.can_resume);
101    }
102
103    #[test]
104    fn a_paused_config_cannot_rotate_and_offers_resume_instead() {
105        let config = RotationConfigView {
106            enabled: false,
107            ..rotatable_config()
108        };
109
110        let actions = rotation_config_actions(&config, Some(TargetSystemStatus::Active));
111
112        assert!(!actions.can_rotate_now);
113        assert!(!actions.can_pause);
114        assert!(actions.can_resume);
115    }
116
117    #[test]
118    fn a_config_with_a_job_in_flight_cannot_rotate_and_locks_mutations() {
119        let config = RotationConfigView {
120            has_active_job: true,
121            ..rotatable_config()
122        };
123
124        let actions = rotation_config_actions(&config, Some(TargetSystemStatus::Active));
125
126        assert!(!actions.can_rotate_now);
127        assert!(actions.mutations_locked);
128    }
129
130    #[test]
131    fn a_disabled_target_system_blocks_rotation() {
132        let actions =
133            rotation_config_actions(&rotatable_config(), Some(TargetSystemStatus::Disabled));
134
135        assert!(!actions.can_rotate_now);
136    }
137
138    /// The configs list can arrive before the target systems; offering a rotation then could
139    /// target a disabled one.
140    #[test]
141    fn an_unloaded_target_system_blocks_rotation() {
142        let actions = rotation_config_actions(&rotatable_config(), None);
143
144        assert!(!actions.can_rotate_now);
145    }
146
147    #[test]
148    fn a_manual_config_offers_record_manual_rather_than_rotate() {
149        let config = RotationConfigView {
150            target_system_method: TargetSystemMethod::Manual,
151            ..rotatable_config()
152        };
153
154        let actions = rotation_config_actions(&config, Some(TargetSystemStatus::Active));
155
156        assert!(!actions.can_rotate_now);
157        assert!(actions.can_record_manual);
158    }
159
160    /// A newer server naming a method this SDK cannot model must not match either branch:
161    /// the operator would be offered an action the server may reject.
162    #[test]
163    fn an_unrecognized_method_offers_neither_rotation_action() {
164        let config = RotationConfigView {
165            target_system_method: TargetSystemMethod::Unknown,
166            ..rotatable_config()
167        };
168
169        let actions = rotation_config_actions(&config, Some(TargetSystemStatus::Active));
170
171        assert!(!actions.can_rotate_now);
172        assert!(!actions.can_record_manual);
173    }
174
175    #[test]
176    fn an_unrecognized_target_status_blocks_rotation() {
177        let actions =
178            rotation_config_actions(&rotatable_config(), Some(TargetSystemStatus::Unknown));
179
180        assert!(!actions.can_rotate_now);
181    }
182
183    /// Pause and resume are complements, never both available and never both withheld.
184    #[test]
185    fn pause_and_resume_are_mutually_exclusive() {
186        for enabled in [true, false] {
187            let config = RotationConfigView {
188                enabled,
189                ..rotatable_config()
190            };
191
192            let actions = rotation_config_actions(&config, Some(TargetSystemStatus::Active));
193
194            assert_ne!(actions.can_pause, actions.can_resume, "enabled: {enabled}");
195        }
196    }
197}