Skip to main content

bitwarden_pam/rotation/
actions.rs

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