1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4 CreateRotationConfigRequestModel, PamRotationConfigDetailResponseModel,
5 PamRotationConfigResponseModel, PamTargetSystemResponseModel, UpdateRotationConfigRequestModel,
6};
7use bitwarden_core::{
8 FromClient, OrganizationId,
9 client::ApiConfigurations,
10 key_management::{KeySlotIds, SymmetricKeySlotId},
11 require,
12};
13use bitwarden_crypto::{
14 CryptoError, Decryptable, EncString, IdentifyKey, KeyStore, KeyStoreContext, safe::DataEnvelope,
15};
16use bitwarden_pam_sealed::{
17 RotationConfigSealedV1, Sealed, seal_rotation_config, unseal_rotation_config,
18};
19use bitwarden_vault::CipherId;
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22#[cfg(feature = "wasm")]
23use tsify::Tsify;
24use uuid::Uuid;
25#[cfg(feature = "wasm")]
26use wasm_bindgen::prelude::wasm_bindgen;
27
28use super::{
29 actions::{RotationConfigActions, rotation_config_actions},
30 error::RotationError,
31 models::{RotationJob, TargetSystemMethod, TargetSystemStatus},
32 target_systems::{TargetSystem, TargetSystemView},
33 validate::{RotationValidationError, validate_config_request},
34};
35use crate::{RotationConfigId, TargetSystemId};
36
37#[derive(Debug, Clone, PartialEq)]
39pub(crate) struct RotationConfig {
40 id: RotationConfigId,
41 organization_id: OrganizationId,
42 cipher_id: CipherId,
43 target_system_id: TargetSystemId,
44 target_system_name: String,
45 target_system_method: TargetSystemMethod,
46 sealed_data: Option<String>,
47 wrapped_key: Option<String>,
48 schedule_cron: Option<String>,
49 rotate_on_access_end: bool,
50 enabled: bool,
51 last_rotation_at: Option<DateTime<Utc>>,
52 next_rotation_at: Option<DateTime<Utc>>,
53 has_active_job: bool,
54 awaiting_manual_rotation: bool,
55 creation_date: DateTime<Utc>,
56 revision_date: DateTime<Utc>,
57}
58
59impl RotationConfig {
60 fn from_response(
63 organization_id: OrganizationId,
64 response: PamRotationConfigResponseModel,
65 ) -> Result<Self, RotationError> {
66 let (sealed_data, wrapped_key) = if response.organization_id == Some(organization_id.into())
67 {
68 (response.sealed_data, response.wrapped_key)
69 } else {
70 tracing::debug!("PAM rotation config organization_id mismatch");
71 (None, None)
72 };
73
74 Ok(Self {
75 id: RotationConfigId::new(require!(response.id)),
76 organization_id,
77 cipher_id: CipherId::new(require!(response.cipher_id)),
78 target_system_id: TargetSystemId::new(require!(response.target_system_id)),
79 target_system_name: response.target_system_name.unwrap_or_default(),
80 target_system_method: require!(response.target_system_method).into(),
81 sealed_data,
82 wrapped_key,
83 schedule_cron: response.schedule_cron,
84 rotate_on_access_end: response.rotate_on_access_end.unwrap_or(false),
85 enabled: response.enabled.unwrap_or(true),
86 last_rotation_at: response
87 .last_rotation_at
88 .map(|date| date.parse())
89 .transpose()?,
90 next_rotation_at: response
91 .next_rotation_at
92 .map(|date| date.parse())
93 .transpose()?,
94 has_active_job: response.has_active_job.unwrap_or(false),
95 awaiting_manual_rotation: response.awaiting_manual_rotation.unwrap_or(false),
96 creation_date: require!(response.creation_date).parse()?,
97 revision_date: require!(response.revision_date).parse()?,
98 })
99 }
100
101 fn unseal(
104 &self,
105 key: SymmetricKeySlotId,
106 ctx: &mut KeyStoreContext<KeySlotIds>,
107 ) -> Option<RotationConfigSealedFields> {
108 self.try_unseal(key, ctx)
109 .inspect_err(|reason| {
110 tracing::debug!(
111 config_id = %self.id,
112 reason,
113 "PAM rotation config sealed blob rejected"
114 )
115 })
116 .ok()
117 }
118
119 fn try_unseal(
120 &self,
121 key: SymmetricKeySlotId,
122 ctx: &mut KeyStoreContext<KeySlotIds>,
123 ) -> Result<RotationConfigSealedFields, &'static str> {
124 let sealed_data = self.sealed_data.as_deref().ok_or("missing blob")?;
125 let wrapped_key = self.wrapped_key.as_deref().ok_or("missing wrapped key")?;
126 let envelope: DataEnvelope = sealed_data.parse().map_err(|_| "malformed sealed data")?;
127 let wrapped_key = EncString::from(wrapped_key);
129
130 let unsealed = unseal_rotation_config::<KeySlotIds>(&envelope, &wrapped_key, &key, ctx)
131 .map_err(|_| "unseal failed")?;
132
133 if unsealed.target_system_id != Uuid::from(self.target_system_id) {
134 return Err("target_system_id mismatch");
135 }
136 if unsealed.cipher_id != Uuid::from(self.cipher_id) {
137 return Err("cipher_id mismatch");
138 }
139
140 Ok(RotationConfigSealedFields {
141 account_identity: unsealed.account_identity,
142 terminate_sessions: unsealed.terminate_sessions,
143 })
144 }
145}
146
147impl IdentifyKey<SymmetricKeySlotId> for RotationConfig {
148 fn key_identifier(&self) -> SymmetricKeySlotId {
149 SymmetricKeySlotId::Organization(self.organization_id)
150 }
151}
152
153impl Decryptable<KeySlotIds, SymmetricKeySlotId, RotationConfigView> for RotationConfig {
154 fn decrypt(
156 &self,
157 ctx: &mut KeyStoreContext<KeySlotIds>,
158 key: SymmetricKeySlotId,
159 ) -> Result<RotationConfigView, CryptoError> {
160 Ok(RotationConfigView {
161 id: self.id,
162 organization_id: self.organization_id,
163 cipher_id: self.cipher_id,
164 target_system_id: self.target_system_id,
165 target_system_name: self.target_system_name.clone(),
166 target_system_method: self.target_system_method,
167 sealed: self.unseal(key, ctx),
168 schedule_cron: self.schedule_cron.clone(),
169 rotate_on_access_end: self.rotate_on_access_end,
170 enabled: self.enabled,
171 last_rotation_at: self.last_rotation_at,
172 next_rotation_at: self.next_rotation_at,
173 has_active_job: self.has_active_job,
174 awaiting_manual_rotation: self.awaiting_manual_rotation,
175 creation_date: self.creation_date,
176 revision_date: self.revision_date,
177 })
178 }
179}
180
181#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
184#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
185#[serde(rename_all = "camelCase")]
186pub struct RotationConfigView {
187 pub id: RotationConfigId,
189 pub organization_id: OrganizationId,
191 pub cipher_id: CipherId,
194 pub target_system_id: TargetSystemId,
196 pub target_system_name: String,
198 pub target_system_method: TargetSystemMethod,
200 pub sealed: Option<RotationConfigSealedFields>,
202 pub schedule_cron: Option<String>,
204 pub rotate_on_access_end: bool,
206 pub enabled: bool,
208 pub last_rotation_at: Option<DateTime<Utc>>,
210 pub next_rotation_at: Option<DateTime<Utc>>,
212 pub has_active_job: bool,
215 pub awaiting_manual_rotation: bool,
218 pub creation_date: DateTime<Utc>,
220 pub revision_date: DateTime<Utc>,
222}
223
224#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
226#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
227#[serde(rename_all = "camelCase")]
228pub struct RotationConfigSealedFields {
229 pub account_identity: String,
231 pub terminate_sessions: bool,
233}
234
235impl RotationConfigView {
236 pub fn actions(&self, target_status: Option<TargetSystemStatus>) -> RotationConfigActions {
241 rotation_config_actions(self, target_status)
242 }
243}
244
245#[derive(Debug, Clone, PartialEq)]
247pub(crate) struct RotationConfigDetail {
248 config: RotationConfig,
249 jobs: Vec<RotationJob>,
250}
251
252impl RotationConfigDetail {
253 fn from_response(
254 organization_id: OrganizationId,
255 response: PamRotationConfigDetailResponseModel,
256 ) -> Result<Self, RotationError> {
257 let jobs = response
258 .jobs
259 .clone()
260 .unwrap_or_default()
261 .into_iter()
262 .map(RotationJob::try_from)
263 .collect::<Result<Vec<_>, _>>()?;
264
265 let config = RotationConfig::from_response(
268 organization_id,
269 PamRotationConfigResponseModel {
270 object: response.object,
271 id: response.id,
272 organization_id: response.organization_id,
273 cipher_id: response.cipher_id,
274 target_system_id: response.target_system_id,
275 target_system_name: response.target_system_name,
276 target_system_method: response.target_system_method,
277 sealed_data: response.sealed_data,
278 wrapped_key: response.wrapped_key,
279 schedule_cron: response.schedule_cron,
280 rotate_on_access_end: response.rotate_on_access_end,
281 next_rotation_at: response.next_rotation_at,
282 enabled: response.enabled,
283 last_rotation_at: response.last_rotation_at,
284 has_active_job: response.has_active_job,
285 awaiting_manual_rotation: response.awaiting_manual_rotation,
286 creation_date: response.creation_date,
287 revision_date: response.revision_date,
288 },
289 )?;
290
291 Ok(Self { config, jobs })
292 }
293}
294
295impl IdentifyKey<SymmetricKeySlotId> for RotationConfigDetail {
296 fn key_identifier(&self) -> SymmetricKeySlotId {
297 self.config.key_identifier()
298 }
299}
300
301impl Decryptable<KeySlotIds, SymmetricKeySlotId, RotationConfigDetailView>
302 for RotationConfigDetail
303{
304 fn decrypt(
305 &self,
306 ctx: &mut KeyStoreContext<KeySlotIds>,
307 key: SymmetricKeySlotId,
308 ) -> Result<RotationConfigDetailView, CryptoError> {
309 Ok(RotationConfigDetailView {
310 config: self.config.decrypt(ctx, key)?,
311 jobs: self.jobs.clone(),
312 })
313 }
314}
315
316#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
318#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
319#[serde(rename_all = "camelCase")]
320pub struct RotationConfigDetailView {
321 pub config: RotationConfigView,
323 pub jobs: Vec<RotationJob>,
325}
326
327#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
329#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
330#[serde(rename_all = "camelCase")]
331pub struct RotationConfigCreateRequest {
332 pub cipher_id: CipherId,
334 pub target_system_id: TargetSystemId,
336 pub account_identity: String,
338 pub terminate_sessions: bool,
341 pub schedule_cron: Option<String>,
343 pub rotate_on_access_end: bool,
345}
346
347struct ValidatedRotationConfig(RotationConfigCreateRequest);
350
351impl ValidatedRotationConfig {
352 fn request(&self) -> &RotationConfigCreateRequest {
353 &self.0
354 }
355
356 fn seal(
359 &self,
360 target: &TargetSystemView,
361 key: SymmetricKeySlotId,
362 ctx: &mut KeyStoreContext<KeySlotIds>,
363 ) -> Result<Sealed, RotationError> {
364 let request = self.request();
365 debug_assert_eq!(target.id, request.target_system_id);
367
368 let supports_termination = target
369 .sealed
370 .as_ref()
371 .and_then(|sealed| sealed.supports_session_termination);
372 if request.terminate_sessions && supports_termination != Some(true) {
373 return Err(RotationValidationError::SessionTerminationUnsupported.into());
374 }
375
376 if !ctx.has_symmetric_key(key) {
377 return Err(RotationError::MissingOrganizationKey);
378 }
379
380 let data = RotationConfigSealedV1 {
381 target_system_id: request.target_system_id.into(),
382 cipher_id: request.cipher_id.into(),
383 account_identity: request.account_identity.clone(),
384 terminate_sessions: request.terminate_sessions,
385 };
386 Ok(seal_rotation_config::<KeySlotIds>(data, &key, ctx)?)
387 }
388}
389
390impl TryFrom<RotationConfigCreateRequest> for ValidatedRotationConfig {
391 type Error = RotationError;
392
393 fn try_from(request: RotationConfigCreateRequest) -> Result<Self, Self::Error> {
394 validate_config_request(&request)?;
395 Ok(Self(request))
396 }
397}
398
399#[cfg_attr(feature = "wasm", wasm_bindgen)]
401#[derive(FromClient)]
402pub struct RotationConfigsClient {
403 pub(crate) key_store: KeyStore<KeySlotIds>,
404 pub(crate) api_configurations: Arc<ApiConfigurations>,
405}
406
407#[cfg_attr(feature = "wasm", wasm_bindgen)]
408impl RotationConfigsClient {
409 pub async fn list(
411 &self,
412 organization_id: OrganizationId,
413 ) -> Result<Vec<RotationConfigView>, RotationError> {
414 let response = self
415 .api_configurations
416 .api_client
417 .pam_access_connector_rotation_configs_api()
418 .get_all(organization_id.into())
419 .await?;
420
421 let configs = response
422 .data
423 .unwrap_or_default()
424 .into_iter()
425 .map(|item| RotationConfig::from_response(organization_id, item))
426 .collect::<Result<Vec<_>, _>>()?;
427 Ok(self.key_store.decrypt_list(&configs)?)
428 }
429
430 pub async fn get(
432 &self,
433 organization_id: OrganizationId,
434 id: RotationConfigId,
435 ) -> Result<RotationConfigDetailView, RotationError> {
436 let response = self
437 .api_configurations
438 .api_client
439 .pam_access_connector_rotation_configs_api()
440 .get(organization_id.into(), id.into())
441 .await?;
442
443 let detail = RotationConfigDetail::from_response(organization_id, response)?;
444 Ok(self.key_store.decrypt(&detail)?)
445 }
446
447 pub async fn create(
449 &self,
450 organization_id: OrganizationId,
451 request: RotationConfigCreateRequest,
452 ) -> Result<RotationConfigDetailView, RotationError> {
453 let config = ValidatedRotationConfig::try_from(request)?;
454 let target = self
455 .verified_target(organization_id, config.request().target_system_id)
456 .await?;
457
458 let sealed = {
459 let mut ctx = self.key_store.context_mut();
460 config.seal(
461 &target,
462 SymmetricKeySlotId::Organization(organization_id),
463 &mut ctx,
464 )?
465 };
466 let request = config.request();
467 let response = self
468 .api_configurations
469 .api_client
470 .pam_access_connector_rotation_configs_api()
471 .post(
472 organization_id.into(),
473 CreateRotationConfigRequestModel {
474 cipher_id: request.cipher_id.into(),
475 target_system_id: request.target_system_id.into(),
476 sealed_data: sealed.envelope.to_string(),
477 wrapped_key: sealed.wrapped_key.to_string(),
478 schedule_cron: request.schedule_cron.clone(),
479 rotate_on_access_end: Some(request.rotate_on_access_end),
480 },
481 )
482 .await?;
483
484 let detail = RotationConfigDetail::from_response(organization_id, response)?;
485 Ok(self.key_store.decrypt(&detail)?)
486 }
487
488 pub async fn update(
491 &self,
492 organization_id: OrganizationId,
493 id: RotationConfigId,
494 request: RotationConfigCreateRequest,
495 ) -> Result<RotationConfigDetailView, RotationError> {
496 let config = ValidatedRotationConfig::try_from(request)?;
497
498 let existing = self.get(organization_id, id).await?;
499 if existing.config.cipher_id != config.request().cipher_id
500 || existing.config.target_system_id != config.request().target_system_id
501 {
502 return Err(RotationValidationError::RotationConfigImmutableFieldMismatch.into());
503 }
504
505 let target = self
506 .verified_target(organization_id, config.request().target_system_id)
507 .await?;
508
509 let sealed = {
510 let mut ctx = self.key_store.context_mut();
511 config.seal(
512 &target,
513 SymmetricKeySlotId::Organization(organization_id),
514 &mut ctx,
515 )?
516 };
517 let request = config.request();
518 let response = self
519 .api_configurations
520 .api_client
521 .pam_access_connector_rotation_configs_api()
522 .put(
523 organization_id.into(),
524 id.into(),
525 UpdateRotationConfigRequestModel {
526 sealed_data: sealed.envelope.to_string(),
527 wrapped_key: sealed.wrapped_key.to_string(),
528 schedule_cron: request.schedule_cron.clone(),
529 rotate_on_access_end: Some(request.rotate_on_access_end),
530 },
531 )
532 .await?;
533
534 let detail = RotationConfigDetail::from_response(organization_id, response)?;
535 Ok(self.key_store.decrypt(&detail)?)
536 }
537
538 async fn verified_target(
540 &self,
541 organization_id: OrganizationId,
542 target_system_id: TargetSystemId,
543 ) -> Result<TargetSystemView, RotationError> {
544 let response = self
545 .api_configurations
546 .api_client
547 .pam_access_connector_rotation_target_systems_api()
548 .get_all(organization_id.into())
549 .await?;
550
551 let item: PamTargetSystemResponseModel = response
552 .data
553 .unwrap_or_default()
554 .into_iter()
555 .find(|item| item.id == Some(target_system_id.into()))
556 .ok_or(RotationError::TargetSystemNotFound)?;
557
558 let target = self
559 .key_store
560 .decrypt(&TargetSystem::from_response(organization_id, item)?)?;
561
562 if target.status != TargetSystemStatus::PendingSeal && target.sealed.is_none() {
563 return Err(RotationValidationError::TargetSystemSealInvalid.into());
564 }
565
566 Ok(target)
567 }
568
569 pub async fn pause(
571 &self,
572 organization_id: OrganizationId,
573 id: RotationConfigId,
574 ) -> Result<(), RotationError> {
575 self.api_configurations
576 .api_client
577 .pam_access_connector_rotation_configs_api()
578 .pause(organization_id.into(), id.into())
579 .await?;
580
581 Ok(())
582 }
583
584 pub async fn resume(
586 &self,
587 organization_id: OrganizationId,
588 id: RotationConfigId,
589 ) -> Result<(), RotationError> {
590 self.api_configurations
591 .api_client
592 .pam_access_connector_rotation_configs_api()
593 .resume(organization_id.into(), id.into())
594 .await?;
595
596 Ok(())
597 }
598
599 pub async fn rotate_now(
601 &self,
602 organization_id: OrganizationId,
603 id: RotationConfigId,
604 ) -> Result<(), RotationError> {
605 self.api_configurations
606 .api_client
607 .pam_access_connector_rotation_configs_api()
608 .rotate(organization_id.into(), id.into())
609 .await?;
610
611 Ok(())
612 }
613
614 pub async fn record_manual_rotation(
617 &self,
618 organization_id: OrganizationId,
619 id: RotationConfigId,
620 ) -> Result<(), RotationError> {
621 self.api_configurations
622 .api_client
623 .pam_access_connector_rotation_configs_api()
624 .record_manual(organization_id.into(), id.into())
625 .await?;
626
627 Ok(())
628 }
629
630 pub async fn delete(
633 &self,
634 organization_id: OrganizationId,
635 id: RotationConfigId,
636 ) -> Result<(), RotationError> {
637 self.api_configurations
638 .api_client
639 .pam_access_connector_rotation_configs_api()
640 .delete(organization_id.into(), id.into())
641 .await?;
642
643 Ok(())
644 }
645
646 pub fn actions(
650 &self,
651 config: RotationConfigView,
652 target_status: Option<TargetSystemStatus>,
653 ) -> RotationConfigActions {
654 config.actions(target_status)
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use bitwarden_api_api::{
661 apis::ApiClient,
662 models::{
663 PamRotationConfigResponseModelListResponseModel, PamRotationJobResponseModel,
664 PamRotationJobStatus as ApiRotationJobStatus, PamRotationSource as ApiRotationSource,
665 PamTargetSystemMethod as ApiTargetSystemMethod,
666 PamTargetSystemResponseModelListResponseModel,
667 PamTargetSystemStatus as ApiTargetSystemStatus,
668 },
669 };
670 use bitwarden_core::key_management::create_test_crypto_with_user_and_org_key;
671 use bitwarden_crypto::{
672 EncString, SymmetricCryptoKey, SymmetricKeyAlgorithm, safe::DataEnvelope,
673 };
674 use bitwarden_pam_sealed::{
675 RotationConfigSealedV1, SealedPasswordPolicy, SealedTargetSystemKind,
676 SealedTargetSystemMethod, TargetSystemSealedV1, seal_target_system, unseal_rotation_config,
677 };
678 use uuid::{Uuid, uuid};
679
680 use super::*;
681 use crate::rotation::{
682 models::{RotationJobStatus, RotationSource},
683 validate::RotationValidationError,
684 };
685
686 fn organization_id() -> OrganizationId {
687 OrganizationId::new(uuid!("11111111-1111-1111-1111-111111111111"))
688 }
689
690 fn config_id() -> RotationConfigId {
691 RotationConfigId::new(uuid!("22222222-2222-2222-2222-222222222222"))
692 }
693
694 fn cipher_id() -> CipherId {
695 CipherId::new(uuid!("33333333-3333-3333-3333-333333333333"))
696 }
697
698 fn target_system_id() -> TargetSystemId {
699 TargetSystemId::new(uuid!("44444444-4444-4444-4444-444444444444"))
700 }
701
702 fn job_id() -> Uuid {
703 uuid!("55555555-5555-5555-5555-555555555555")
704 }
705
706 fn organization_key() -> SymmetricCryptoKey {
707 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac)
708 }
709
710 fn client(api_client: ApiClient) -> RotationConfigsClient {
711 client_with_org_key(api_client, organization_key())
712 }
713
714 fn client_with_org_key(
715 api_client: ApiClient,
716 organization_key: SymmetricCryptoKey,
717 ) -> RotationConfigsClient {
718 RotationConfigsClient {
719 key_store: create_test_crypto_with_user_and_org_key(
720 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
721 organization_id(),
722 organization_key,
723 ),
724 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
725 }
726 }
727
728 fn seal_config_with(
729 organization_key: &SymmetricCryptoKey,
730 data: RotationConfigSealedV1,
731 ) -> Sealed {
732 let store = create_test_crypto_with_user_and_org_key(
733 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
734 organization_id(),
735 organization_key.clone(),
736 );
737 let mut ctx = store.context_mut();
738 seal_rotation_config::<KeySlotIds>(
739 data,
740 &SymmetricKeySlotId::Organization(organization_id()),
741 &mut ctx,
742 )
743 .expect("sealing succeeds")
744 }
745
746 fn seal_target_with(
747 organization_key: &SymmetricCryptoKey,
748 data: TargetSystemSealedV1,
749 ) -> Sealed {
750 let store = create_test_crypto_with_user_and_org_key(
751 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
752 organization_id(),
753 organization_key.clone(),
754 );
755 let mut ctx = store.context_mut();
756 seal_target_system::<KeySlotIds>(
757 data,
758 &SymmetricKeySlotId::Organization(organization_id()),
759 &mut ctx,
760 )
761 .expect("sealing succeeds")
762 }
763
764 fn sample_sealed_data() -> RotationConfigSealedV1 {
765 RotationConfigSealedV1 {
766 target_system_id: target_system_id().into(),
767 cipher_id: cipher_id().into(),
768 account_identity: "svc_rotation".to_string(),
769 terminate_sessions: true,
770 }
771 }
772
773 fn config_view(
774 organization_id: OrganizationId,
775 response: PamRotationConfigResponseModel,
776 ctx: &mut KeyStoreContext<KeySlotIds>,
777 ) -> Result<RotationConfigView, RotationError> {
778 let config = RotationConfig::from_response(organization_id, response)?;
779 let key = config.key_identifier();
780 Ok(config.decrypt(ctx, key)?)
781 }
782
783 fn detail_view(
784 organization_id: OrganizationId,
785 response: PamRotationConfigDetailResponseModel,
786 ctx: &mut KeyStoreContext<KeySlotIds>,
787 ) -> Result<RotationConfigDetailView, RotationError> {
788 let detail = RotationConfigDetail::from_response(organization_id, response)?;
789 let key = detail.key_identifier();
790 Ok(detail.decrypt(ctx, key)?)
791 }
792
793 fn sample_response(organization_key: &SymmetricCryptoKey) -> PamRotationConfigResponseModel {
796 let payload = seal_config_with(organization_key, sample_sealed_data());
797 PamRotationConfigResponseModel {
798 id: Some(config_id().into()),
799 organization_id: Some(organization_id().into()),
800 cipher_id: Some(cipher_id().into()),
801 target_system_id: Some(target_system_id().into()),
802 target_system_name: Some("Prod SQL".to_string()),
803 target_system_method: Some(ApiTargetSystemMethod::Automatic),
804 sealed_data: Some(payload.envelope.to_string()),
805 wrapped_key: Some(payload.wrapped_key.to_string()),
806 schedule_cron: Some("0 0 0 * * ?".to_string()),
807 rotate_on_access_end: Some(true),
808 next_rotation_at: Some("2026-02-02T00:00:00Z".to_string()),
809 enabled: Some(true),
810 last_rotation_at: Some("2026-02-01T00:00:00Z".to_string()),
811 has_active_job: Some(true),
812 awaiting_manual_rotation: Some(true),
813 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
814 revision_date: Some("2026-01-15T00:00:00Z".to_string()),
815 ..Default::default()
816 }
817 }
818
819 fn sample_detail_response(
822 organization_key: &SymmetricCryptoKey,
823 jobs: Option<Vec<PamRotationJobResponseModel>>,
824 ) -> PamRotationConfigDetailResponseModel {
825 let config = sample_response(organization_key);
826 PamRotationConfigDetailResponseModel {
827 object: config.object,
828 id: config.id,
829 organization_id: config.organization_id,
830 cipher_id: config.cipher_id,
831 target_system_id: config.target_system_id,
832 target_system_name: config.target_system_name,
833 target_system_method: config.target_system_method,
834 sealed_data: config.sealed_data,
835 wrapped_key: config.wrapped_key,
836 schedule_cron: config.schedule_cron,
837 rotate_on_access_end: config.rotate_on_access_end,
838 next_rotation_at: config.next_rotation_at,
839 enabled: config.enabled,
840 last_rotation_at: config.last_rotation_at,
841 has_active_job: config.has_active_job,
842 awaiting_manual_rotation: config.awaiting_manual_rotation,
843 creation_date: config.creation_date,
844 revision_date: config.revision_date,
845 jobs,
846 }
847 }
848
849 fn sample_job(id: Uuid) -> PamRotationJobResponseModel {
850 PamRotationJobResponseModel {
851 id: Some(id),
852 rotation_config_id: Some(config_id().into()),
853 source: Some(ApiRotationSource::OnDemand),
854 status: Some(ApiRotationJobStatus::Pending),
855 creation_date: Some("2026-01-10T00:00:00Z".to_string()),
856 ..Default::default()
857 }
858 }
859
860 fn create_request() -> RotationConfigCreateRequest {
861 RotationConfigCreateRequest {
862 cipher_id: cipher_id(),
863 target_system_id: target_system_id(),
864 account_identity: "svc_rotation".to_string(),
865 terminate_sessions: true,
866 schedule_cron: Some("0 0 0 * * ?".to_string()),
867 rotate_on_access_end: true,
868 }
869 }
870
871 fn terminable_target_response(
873 organization_key: &SymmetricCryptoKey,
874 ) -> PamTargetSystemResponseModel {
875 let payload = seal_target_with(
876 organization_key,
877 TargetSystemSealedV1 {
878 target_system_id: target_system_id().into(),
879 method: SealedTargetSystemMethod::Automatic,
880 kind: Some(SealedTargetSystemKind::Mssql),
881 password_policy: SealedPasswordPolicy {
882 min_length: 14,
883 max_length: 20,
884 include_uppercase: true,
885 include_lowercase: true,
886 include_digits: true,
887 include_symbols: false,
888 },
889 supports_session_termination: Some(true),
890 },
891 );
892 PamTargetSystemResponseModel {
893 id: Some(target_system_id().into()),
894 organization_id: Some(organization_id().into()),
895 name: Some("Prod SQL".to_string()),
896 method: Some(ApiTargetSystemMethod::Automatic),
897 status: Some(ApiTargetSystemStatus::Active),
898 sealed_data: Some(payload.envelope.to_string()),
899 wrapped_key: Some(payload.wrapped_key.to_string()),
900 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
901 revision_date: Some("2026-01-01T00:00:00Z".to_string()),
902 ..Default::default()
903 }
904 }
905
906 fn target_systems_response(
907 targets: Vec<PamTargetSystemResponseModel>,
908 ) -> PamTargetSystemResponseModelListResponseModel {
909 PamTargetSystemResponseModelListResponseModel {
910 data: Some(targets),
911 ..Default::default()
912 }
913 }
914
915 #[test]
916 fn a_full_payload_maps_every_field() {
917 let organization_key = organization_key();
918 let store = create_test_crypto_with_user_and_org_key(
919 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
920 organization_id(),
921 organization_key.clone(),
922 );
923 let mut ctx = store.context_mut();
924
925 let config = config_view(
926 organization_id(),
927 sample_response(&organization_key),
928 &mut ctx,
929 )
930 .expect("the payload is valid");
931
932 assert_eq!(config.id, config_id());
933 assert_eq!(config.organization_id, organization_id());
934 assert_eq!(config.cipher_id, cipher_id());
935 assert_eq!(config.target_system_id, target_system_id());
936 assert_eq!(config.target_system_name, "Prod SQL");
937 assert_eq!(config.target_system_method, TargetSystemMethod::Automatic);
938 let sealed = config.sealed.expect("the blob is sealed");
939 assert_eq!(sealed.account_identity, "svc_rotation");
940 assert!(sealed.terminate_sessions);
941 assert_eq!(config.schedule_cron.as_deref(), Some("0 0 0 * * ?"));
942 assert!(config.rotate_on_access_end);
943 assert!(config.enabled);
944 assert_eq!(
945 config.last_rotation_at,
946 Some("2026-02-01T00:00:00Z".parse().expect("a valid timestamp"))
947 );
948 assert_eq!(
949 config.next_rotation_at,
950 Some("2026-02-02T00:00:00Z".parse().expect("a valid timestamp"))
951 );
952 assert!(config.has_active_job);
953 assert!(config.awaiting_manual_rotation);
954 assert_eq!(
955 config.creation_date,
956 "2026-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
957 );
958 assert_eq!(
959 config.revision_date,
960 "2026-01-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
961 );
962 }
963
964 #[test]
966 fn an_invalid_blob_degrades_to_no_sealed_fields() {
967 let organization_key = organization_key();
968 let wrong_cipher_blob = seal_config_with(
969 &organization_key,
970 RotationConfigSealedV1 {
971 cipher_id: uuid!("99999999-9999-9999-9999-999999999999"),
972 ..sample_sealed_data()
973 },
974 );
975
976 let sample = sample_response(&organization_key);
977 for (sealed_data, wrapped_key) in [
978 (
979 Some("not a sealed blob".to_string()),
980 sample.wrapped_key.clone(),
981 ),
982 (
983 Some(wrong_cipher_blob.envelope.to_string()),
984 Some(wrong_cipher_blob.wrapped_key.to_string()),
985 ),
986 (None, sample.wrapped_key.clone()),
987 (sample.sealed_data.clone(), None),
988 ] {
989 let store = create_test_crypto_with_user_and_org_key(
990 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
991 organization_id(),
992 organization_key.clone(),
993 );
994 let mut ctx = store.context_mut();
995
996 let response = PamRotationConfigResponseModel {
997 sealed_data,
998 wrapped_key,
999 ..sample_response(&organization_key)
1000 };
1001
1002 let config =
1003 config_view(organization_id(), response, &mut ctx).expect("the payload is valid");
1004
1005 assert_eq!(config.sealed, None);
1006 }
1007 }
1008
1009 #[test]
1011 fn a_wrapped_key_from_a_different_seal_degrades_to_no_sealed_fields() {
1012 let organization_key = organization_key();
1013 let store = create_test_crypto_with_user_and_org_key(
1014 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1015 organization_id(),
1016 organization_key.clone(),
1017 );
1018 let mut ctx = store.context_mut();
1019
1020 let other = seal_config_with(&organization_key, sample_sealed_data());
1021 let response = PamRotationConfigResponseModel {
1022 wrapped_key: Some(other.wrapped_key.to_string()),
1023 ..sample_response(&organization_key)
1024 };
1025
1026 let config =
1027 config_view(organization_id(), response, &mut ctx).expect("the payload is valid");
1028
1029 assert_eq!(config.sealed, None);
1030 }
1031
1032 #[test]
1034 fn a_blob_from_a_different_organization_is_not_trusted() {
1035 let org_a = organization_id();
1036 let org_b = OrganizationId::new(uuid!("66666666-6666-6666-6666-666666666666"));
1037 let org_a_key = organization_key();
1038 let org_b_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
1039
1040 let org_b_blob = seal_config_with(&org_b_key, sample_sealed_data());
1042
1043 let store = KeyStore::<KeySlotIds>::default();
1044 {
1045 let mut ctx = store.context_mut();
1046 #[allow(deprecated)]
1047 ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_a), org_a_key)
1048 .expect("mutable context");
1049 #[allow(deprecated)]
1050 ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_b), org_b_key)
1051 .expect("mutable context");
1052 }
1053
1054 let response = PamRotationConfigResponseModel {
1055 organization_id: Some(org_b.into()),
1056 sealed_data: Some(org_b_blob.envelope.to_string()),
1057 wrapped_key: Some(org_b_blob.wrapped_key.to_string()),
1058 ..sample_response(&organization_key())
1059 };
1060
1061 let mut ctx = store.context_mut();
1062 let config = config_view(org_a, response, &mut ctx)
1063 .expect("the payload is still structurally valid");
1064
1065 assert_eq!(config.organization_id, org_a);
1066 assert_eq!(config.sealed, None);
1067 }
1068
1069 #[test]
1070 fn an_omitted_enabled_flag_means_the_config_is_active() {
1071 let organization_key = organization_key();
1072 let store = create_test_crypto_with_user_and_org_key(
1073 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1074 organization_id(),
1075 organization_key.clone(),
1076 );
1077 let mut ctx = store.context_mut();
1078
1079 let config = config_view(
1080 organization_id(),
1081 PamRotationConfigResponseModel {
1082 enabled: None,
1083 ..sample_response(&organization_key)
1084 },
1085 &mut ctx,
1086 )
1087 .expect("the payload is valid");
1088
1089 assert!(config.enabled);
1090 assert!(config.actions(Some(TargetSystemStatus::Active)).can_pause);
1091 assert!(!config.actions(Some(TargetSystemStatus::Active)).can_resume);
1092 }
1093
1094 #[test]
1095 fn the_remaining_absent_fields_default_to_not_set() {
1096 let organization_key = organization_key();
1097 let store = create_test_crypto_with_user_and_org_key(
1098 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1099 organization_id(),
1100 organization_key.clone(),
1101 );
1102 let mut ctx = store.context_mut();
1103
1104 let config = config_view(
1105 organization_id(),
1106 PamRotationConfigResponseModel {
1107 target_system_name: None,
1108 schedule_cron: None,
1109 rotate_on_access_end: None,
1110 last_rotation_at: None,
1111 next_rotation_at: None,
1112 has_active_job: None,
1113 awaiting_manual_rotation: None,
1114 ..sample_response(&organization_key)
1115 },
1116 &mut ctx,
1117 )
1118 .expect("the payload is valid");
1119
1120 assert_eq!(config.target_system_name, "");
1121 assert_eq!(config.schedule_cron, None);
1122 assert!(!config.rotate_on_access_end);
1123 assert_eq!(config.last_rotation_at, None);
1124 assert_eq!(config.next_rotation_at, None);
1125 assert!(!config.has_active_job);
1126 assert!(!config.awaiting_manual_rotation);
1127 }
1128
1129 #[test]
1130 fn a_missing_required_field_is_reported_rather_than_defaulted() {
1131 let organization_key = organization_key();
1132 let store = create_test_crypto_with_user_and_org_key(
1133 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1134 organization_id(),
1135 organization_key.clone(),
1136 );
1137 let mut ctx = store.context_mut();
1138
1139 for response in [
1140 PamRotationConfigResponseModel {
1141 id: None,
1142 ..sample_response(&organization_key)
1143 },
1144 PamRotationConfigResponseModel {
1147 cipher_id: None,
1148 ..sample_response(&organization_key)
1149 },
1150 PamRotationConfigResponseModel {
1151 target_system_id: None,
1152 ..sample_response(&organization_key)
1153 },
1154 PamRotationConfigResponseModel {
1155 target_system_method: None,
1156 ..sample_response(&organization_key)
1157 },
1158 PamRotationConfigResponseModel {
1159 creation_date: None,
1160 ..sample_response(&organization_key)
1161 },
1162 PamRotationConfigResponseModel {
1163 revision_date: None,
1164 ..sample_response(&organization_key)
1165 },
1166 ] {
1167 assert!(matches!(
1168 config_view(organization_id(), response, &mut ctx),
1169 Err(RotationError::MissingField(_))
1170 ));
1171 }
1172 }
1173
1174 #[test]
1175 fn an_unparseable_date_is_reported() {
1176 let organization_key = organization_key();
1177 let store = create_test_crypto_with_user_and_org_key(
1178 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1179 organization_id(),
1180 organization_key.clone(),
1181 );
1182 let mut ctx = store.context_mut();
1183
1184 for response in [
1185 PamRotationConfigResponseModel {
1186 creation_date: Some("not a date".to_string()),
1187 ..sample_response(&organization_key)
1188 },
1189 PamRotationConfigResponseModel {
1190 revision_date: Some("not a date".to_string()),
1191 ..sample_response(&organization_key)
1192 },
1193 PamRotationConfigResponseModel {
1194 last_rotation_at: Some("not a date".to_string()),
1195 ..sample_response(&organization_key)
1196 },
1197 PamRotationConfigResponseModel {
1198 next_rotation_at: Some("not a date".to_string()),
1199 ..sample_response(&organization_key)
1200 },
1201 ] {
1202 assert!(matches!(
1203 config_view(organization_id(), response, &mut ctx),
1204 Err(RotationError::Chrono(_))
1205 ));
1206 }
1207 }
1208
1209 #[test]
1210 fn an_unrecognized_method_degrades_to_unknown() {
1211 let organization_key = organization_key();
1212 let store = create_test_crypto_with_user_and_org_key(
1213 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1214 organization_id(),
1215 organization_key.clone(),
1216 );
1217 let mut ctx = store.context_mut();
1218
1219 let config = config_view(
1220 organization_id(),
1221 PamRotationConfigResponseModel {
1222 target_system_method: Some(ApiTargetSystemMethod::__Unknown(9)),
1223 ..sample_response(&organization_key)
1224 },
1225 &mut ctx,
1226 )
1227 .expect("an unknown method still maps");
1228
1229 assert_eq!(config.target_system_method, TargetSystemMethod::Unknown);
1230 }
1231
1232 #[test]
1235 fn the_detail_payload_yields_the_same_config_as_the_list_payload() {
1236 let organization_key = organization_key();
1237 let store = create_test_crypto_with_user_and_org_key(
1238 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1239 organization_id(),
1240 organization_key.clone(),
1241 );
1242 let mut ctx = store.context_mut();
1243
1244 let detail = detail_view(
1245 organization_id(),
1246 sample_detail_response(&organization_key, Some(vec![sample_job(job_id())])),
1247 &mut ctx,
1248 )
1249 .expect("the payload is valid");
1250
1251 assert_eq!(
1252 detail.config,
1253 config_view(
1254 organization_id(),
1255 sample_response(&organization_key),
1256 &mut ctx
1257 )
1258 .expect("the payload is valid")
1259 );
1260 }
1261
1262 #[test]
1263 fn detail_jobs_keep_the_order_the_server_sent() {
1264 let organization_key = organization_key();
1265 let store = create_test_crypto_with_user_and_org_key(
1266 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1267 organization_id(),
1268 organization_key.clone(),
1269 );
1270 let mut ctx = store.context_mut();
1271
1272 let second = uuid!("66666666-6666-6666-6666-666666666666");
1273 let detail = detail_view(
1274 organization_id(),
1275 sample_detail_response(
1276 &organization_key,
1277 Some(vec![sample_job(job_id()), sample_job(second)]),
1278 ),
1279 &mut ctx,
1280 )
1281 .expect("the payload is valid");
1282
1283 let ids: Vec<Uuid> = detail.jobs.iter().map(|job| job.id.into()).collect();
1284 assert_eq!(ids, [job_id(), second]);
1285 assert_eq!(detail.jobs[0].source, RotationSource::OnDemand);
1286 assert_eq!(detail.jobs[0].status, RotationJobStatus::Pending);
1287 }
1288
1289 #[test]
1291 fn a_detail_payload_without_jobs_has_no_jobs() {
1292 let organization_key = organization_key();
1293 let store = create_test_crypto_with_user_and_org_key(
1294 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1295 organization_id(),
1296 organization_key.clone(),
1297 );
1298 let mut ctx = store.context_mut();
1299
1300 let detail = detail_view(
1301 organization_id(),
1302 sample_detail_response(&organization_key, None),
1303 &mut ctx,
1304 )
1305 .expect("no jobs is fine");
1306
1307 assert!(detail.jobs.is_empty());
1308 }
1309
1310 #[test]
1311 fn a_malformed_job_fails_the_whole_detail() {
1312 let organization_key = organization_key();
1313 let store = create_test_crypto_with_user_and_org_key(
1314 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1315 organization_id(),
1316 organization_key.clone(),
1317 );
1318 let mut ctx = store.context_mut();
1319
1320 let malformed = PamRotationJobResponseModel {
1321 rotation_config_id: None,
1322 ..sample_job(job_id())
1323 };
1324
1325 let result = detail_view(
1326 organization_id(),
1327 sample_detail_response(&organization_key, Some(vec![malformed])),
1328 &mut ctx,
1329 );
1330
1331 assert!(matches!(result, Err(RotationError::MissingField(_))));
1332 }
1333
1334 #[tokio::test]
1335 async fn list_maps_the_organizations_configs() {
1336 let organization_key = organization_key();
1337 let api_client = {
1338 let organization_key = organization_key.clone();
1339 ApiClient::new_mocked(move |mock| {
1340 mock.pam_access_connector_rotation_configs_api
1341 .expect_get_all()
1342 .withf(|org_id| *org_id == Uuid::from(organization_id()))
1343 .returning(move |_org_id| {
1344 Ok(PamRotationConfigResponseModelListResponseModel {
1345 data: Some(vec![sample_response(&organization_key)]),
1346 ..Default::default()
1347 })
1348 })
1349 .once();
1350 })
1351 };
1352
1353 let configs = client_with_org_key(api_client, organization_key)
1354 .list(organization_id())
1355 .await
1356 .expect("the list succeeds");
1357
1358 assert_eq!(configs.len(), 1);
1359 assert_eq!(configs[0].id, config_id());
1360 assert!(configs[0].sealed.is_some());
1361 }
1362
1363 #[tokio::test]
1364 async fn an_organization_with_no_configs_lists_empty() {
1365 let api_client = ApiClient::new_mocked(move |mock| {
1366 mock.pam_access_connector_rotation_configs_api
1367 .expect_get_all()
1368 .returning(move |_org_id| {
1369 Ok(PamRotationConfigResponseModelListResponseModel::default())
1370 })
1371 .once();
1372 });
1373
1374 let configs = client(api_client)
1375 .list(organization_id())
1376 .await
1377 .expect("an absent list is not an error");
1378
1379 assert!(configs.is_empty());
1380 }
1381
1382 #[tokio::test]
1383 async fn list_returns_the_rest_when_one_item_has_an_invalid_blob() {
1384 let organization_key = organization_key();
1385 let api_client = {
1386 let organization_key = organization_key.clone();
1387 ApiClient::new_mocked(move |mock| {
1388 mock.pam_access_connector_rotation_configs_api
1389 .expect_get_all()
1390 .returning(move |_org_id| {
1391 let bad = PamRotationConfigResponseModel {
1392 id: Some(uuid!("77777777-7777-7777-7777-777777777777")),
1393 sealed_data: Some("not a sealed blob".to_string()),
1394 ..sample_response(&organization_key)
1395 };
1396 Ok(PamRotationConfigResponseModelListResponseModel {
1397 data: Some(vec![sample_response(&organization_key), bad]),
1398 ..Default::default()
1399 })
1400 })
1401 .once();
1402 })
1403 };
1404
1405 let configs = client_with_org_key(api_client, organization_key)
1406 .list(organization_id())
1407 .await
1408 .expect("one invalid item does not fail the list");
1409
1410 assert_eq!(configs.len(), 2);
1411 assert!(configs[0].sealed.is_some());
1412 assert_eq!(configs[1].sealed, None);
1413 }
1414
1415 #[tokio::test]
1416 async fn list_fails_if_a_required_field_is_missing() {
1417 let organization_key = organization_key();
1418 let api_client = {
1419 let organization_key = organization_key.clone();
1420 ApiClient::new_mocked(move |mock| {
1421 mock.pam_access_connector_rotation_configs_api
1422 .expect_get_all()
1423 .returning(move |_org_id| {
1424 Ok(PamRotationConfigResponseModelListResponseModel {
1425 data: Some(vec![PamRotationConfigResponseModel {
1426 cipher_id: None,
1427 ..sample_response(&organization_key)
1428 }]),
1429 ..Default::default()
1430 })
1431 })
1432 .once();
1433 })
1434 };
1435
1436 let result = client_with_org_key(api_client, organization_key)
1437 .list(organization_id())
1438 .await;
1439
1440 assert!(matches!(result, Err(RotationError::MissingField(_))));
1441 }
1442
1443 #[tokio::test]
1444 async fn list_surfaces_an_api_error() {
1445 let api_client = ApiClient::new_mocked(move |mock| {
1446 mock.pam_access_connector_rotation_configs_api
1447 .expect_get_all()
1448 .returning(move |_org_id| {
1449 Err(bitwarden_api_api::apis::Error::Response(
1450 bitwarden_api_api::apis::ResponseContent {
1451 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1452 message: String::new(),
1453 },
1454 ))
1455 })
1456 .once();
1457 });
1458
1459 let result = client(api_client).list(organization_id()).await;
1460
1461 assert!(matches!(result, Err(RotationError::Api(_))));
1462 }
1463
1464 #[tokio::test]
1465 async fn get_reads_the_requested_config_with_its_history() {
1466 let organization_key = organization_key();
1467 let api_client = {
1468 let organization_key = organization_key.clone();
1469 ApiClient::new_mocked(move |mock| {
1470 mock.pam_access_connector_rotation_configs_api
1471 .expect_get()
1472 .withf(|org_id, id| {
1473 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1474 })
1475 .returning(move |_org_id, _id| {
1476 Ok(sample_detail_response(
1477 &organization_key,
1478 Some(vec![sample_job(job_id())]),
1479 ))
1480 })
1481 .once();
1482 })
1483 };
1484
1485 let detail = client_with_org_key(api_client, organization_key)
1486 .get(organization_id(), config_id())
1487 .await
1488 .expect("the read succeeds");
1489
1490 assert_eq!(detail.config.id, config_id());
1491 assert_eq!(detail.jobs.len(), 1);
1492 }
1493
1494 #[tokio::test]
1495 async fn get_surfaces_an_api_error() {
1496 let api_client = ApiClient::new_mocked(move |mock| {
1497 mock.pam_access_connector_rotation_configs_api
1498 .expect_get()
1499 .returning(move |_org_id, _id| {
1500 Err(bitwarden_api_api::apis::Error::Response(
1501 bitwarden_api_api::apis::ResponseContent {
1502 status: reqwest::StatusCode::NOT_FOUND,
1503 message: String::new(),
1504 },
1505 ))
1506 })
1507 .once();
1508 });
1509
1510 let result = client(api_client).get(organization_id(), config_id()).await;
1511
1512 assert!(matches!(result, Err(RotationError::Api(_))));
1513 }
1514
1515 #[tokio::test]
1516 async fn create_validates_the_target_seals_and_sends_the_request() {
1517 let organization_key = organization_key();
1518 let api_client = {
1519 let organization_key = organization_key.clone();
1520 ApiClient::new_mocked(move |mock| {
1521 let target_key = organization_key.clone();
1522 mock.pam_access_connector_rotation_target_systems_api
1523 .expect_get_all()
1524 .returning(move |_org_id| {
1525 Ok(target_systems_response(vec![terminable_target_response(
1526 &target_key,
1527 )]))
1528 })
1529 .once();
1530
1531 let config_key = organization_key.clone();
1532 mock.pam_access_connector_rotation_configs_api
1533 .expect_post()
1534 .withf(move |org_id, request| {
1535 if *org_id != Uuid::from(organization_id())
1536 || request.cipher_id != Uuid::from(cipher_id())
1537 || request.target_system_id != Uuid::from(target_system_id())
1538 || request.schedule_cron.as_deref() != Some("0 0 0 * * ?")
1539 {
1540 return false;
1541 }
1542
1543 let store = create_test_crypto_with_user_and_org_key(
1544 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1545 organization_id(),
1546 config_key.clone(),
1547 );
1548 let mut ctx = store.context_mut();
1549 let unsealed = unseal_rotation_config::<KeySlotIds>(
1550 &request
1551 .sealed_data
1552 .parse::<DataEnvelope>()
1553 .expect("sealedData is well-formed"),
1554 &request
1555 .wrapped_key
1556 .parse::<EncString>()
1557 .expect("wrappedKey is well-formed"),
1558 &SymmetricKeySlotId::Organization(organization_id()),
1559 &mut ctx,
1560 )
1561 .expect("the blob unseals under the organization key");
1562
1563 unsealed.target_system_id == Uuid::from(target_system_id())
1564 && unsealed.cipher_id == Uuid::from(cipher_id())
1565 && unsealed.account_identity == "svc_rotation"
1566 && unsealed.terminate_sessions
1567 })
1568 .returning(move |_org_id, _request| {
1569 Ok(sample_detail_response(&organization_key, None))
1570 })
1571 .once();
1572 })
1573 };
1574
1575 let detail = client_with_org_key(api_client, organization_key)
1576 .create(organization_id(), create_request())
1577 .await
1578 .expect("creation succeeds");
1579
1580 assert_eq!(detail.config.id, config_id());
1581 }
1582
1583 #[tokio::test]
1585 async fn create_rejects_an_invalid_request_before_calling_the_server() {
1586 for (request, expected) in [
1587 (
1588 RotationConfigCreateRequest {
1589 account_identity: " ".to_string(),
1590 ..create_request()
1591 },
1592 RotationValidationError::InvalidAccountIdentity,
1593 ),
1594 (
1595 RotationConfigCreateRequest {
1596 schedule_cron: Some("0 0 * * *".to_string()),
1598 ..create_request()
1599 },
1600 RotationValidationError::InvalidCron,
1601 ),
1602 ] {
1603 let result = client(ApiClient::new_mocked(|_| {}))
1604 .create(organization_id(), request)
1605 .await;
1606
1607 assert!(
1608 matches!(result, Err(RotationError::Validation(ref actual)) if *actual == expected),
1609 "expected {expected:?}, got {result:?}"
1610 );
1611 }
1612 }
1613
1614 #[tokio::test]
1616 async fn create_rejects_terminate_sessions_on_an_unsupported_target() {
1617 let organization_key = organization_key();
1618 let api_client = {
1619 let target_key = organization_key.clone();
1620 ApiClient::new_mocked(move |mock| {
1621 mock.pam_access_connector_rotation_target_systems_api
1622 .expect_get_all()
1623 .returning(move |_org_id| {
1624 let sealed = seal_target_with(
1625 &target_key,
1626 TargetSystemSealedV1 {
1627 target_system_id: target_system_id().into(),
1628 method: SealedTargetSystemMethod::Automatic,
1629 kind: Some(SealedTargetSystemKind::Mssql),
1630 password_policy: SealedPasswordPolicy {
1631 min_length: 14,
1632 max_length: 20,
1633 include_uppercase: true,
1634 include_lowercase: true,
1635 include_digits: true,
1636 include_symbols: false,
1637 },
1638 supports_session_termination: Some(false),
1639 },
1640 );
1641 let response = PamTargetSystemResponseModel {
1642 sealed_data: Some(sealed.envelope.to_string()),
1643 wrapped_key: Some(sealed.wrapped_key.to_string()),
1644 ..terminable_target_response(&target_key)
1645 };
1646 Ok(target_systems_response(vec![response]))
1647 })
1648 .once();
1649 })
1650 };
1651
1652 let result = client_with_org_key(api_client, organization_key)
1653 .create(organization_id(), create_request())
1654 .await;
1655
1656 assert!(matches!(
1657 result,
1658 Err(RotationError::Validation(
1659 RotationValidationError::SessionTerminationUnsupported
1660 ))
1661 ));
1662 }
1663
1664 #[tokio::test]
1666 async fn create_rejects_terminate_sessions_on_a_pending_seal_target() {
1667 let api_client = ApiClient::new_mocked(move |mock| {
1668 mock.pam_access_connector_rotation_target_systems_api
1669 .expect_get_all()
1670 .returning(move |_org_id| {
1671 Ok(target_systems_response(vec![
1672 PamTargetSystemResponseModel {
1673 id: Some(target_system_id().into()),
1674 organization_id: Some(organization_id().into()),
1675 name: Some("Prod SQL".to_string()),
1676 method: Some(ApiTargetSystemMethod::Automatic),
1677 status: Some(ApiTargetSystemStatus::PendingSeal),
1678 sealed_data: None,
1679 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1680 revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1681 ..Default::default()
1682 },
1683 ]))
1684 })
1685 .once();
1686 });
1687
1688 let result = client(api_client)
1689 .create(organization_id(), create_request())
1690 .await;
1691
1692 assert!(matches!(
1693 result,
1694 Err(RotationError::Validation(
1695 RotationValidationError::SessionTerminationUnsupported
1696 ))
1697 ));
1698 }
1699
1700 #[test]
1702 fn seal_rejects_terminate_sessions_without_confirmed_support() {
1703 let target = TargetSystemView {
1704 id: target_system_id(),
1705 organization_id: organization_id(),
1706 name: "Prod SQL".to_string(),
1707 method: TargetSystemMethod::Automatic,
1708 status: TargetSystemStatus::PendingSeal,
1709 sealed: None,
1710 creation_date: "2026-01-01T00:00:00Z".parse().unwrap(),
1711 revision_date: "2026-01-01T00:00:00Z".parse().unwrap(),
1712 };
1713 let config = ValidatedRotationConfig::try_from(create_request()).expect("a valid request");
1714
1715 let client = client(ApiClient::new_mocked(|_| {}));
1716 let result = config.seal(
1717 &target,
1718 SymmetricKeySlotId::Organization(organization_id()),
1719 &mut client.key_store.context_mut(),
1720 );
1721
1722 assert!(matches!(
1723 result,
1724 Err(RotationError::Validation(
1725 RotationValidationError::SessionTerminationUnsupported
1726 ))
1727 ));
1728 }
1729
1730 #[tokio::test]
1731 async fn create_fails_when_the_target_system_does_not_exist() {
1732 let api_client = ApiClient::new_mocked(move |mock| {
1733 mock.pam_access_connector_rotation_target_systems_api
1734 .expect_get_all()
1735 .returning(move |_org_id| Ok(target_systems_response(vec![])))
1736 .once();
1737 });
1738
1739 let result = client(api_client)
1740 .create(organization_id(), create_request())
1741 .await;
1742
1743 assert!(matches!(result, Err(RotationError::TargetSystemNotFound)));
1744 }
1745
1746 #[tokio::test]
1747 async fn create_fails_when_the_target_systems_seal_is_invalid() {
1748 let api_client = ApiClient::new_mocked(move |mock| {
1749 mock.pam_access_connector_rotation_target_systems_api
1750 .expect_get_all()
1751 .returning(move |_org_id| {
1752 Ok(target_systems_response(vec![
1753 PamTargetSystemResponseModel {
1754 id: Some(target_system_id().into()),
1755 organization_id: Some(organization_id().into()),
1756 name: Some("Prod SQL".to_string()),
1757 method: Some(ApiTargetSystemMethod::Automatic),
1758 status: Some(ApiTargetSystemStatus::Active),
1759 sealed_data: Some("not a sealed blob".to_string()),
1760 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1761 revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1762 ..Default::default()
1763 },
1764 ]))
1765 })
1766 .once();
1767 });
1768
1769 let result = client(api_client)
1770 .create(organization_id(), create_request())
1771 .await;
1772
1773 assert!(matches!(
1774 result,
1775 Err(RotationError::Validation(
1776 RotationValidationError::TargetSystemSealInvalid
1777 ))
1778 ));
1779 }
1780
1781 #[tokio::test]
1782 async fn update_seals_and_sends_the_request_to_the_requested_config() {
1783 let organization_key = organization_key();
1784 let api_client = {
1785 let target_key = organization_key.clone();
1786 let config_key = organization_key.clone();
1787 let response_key = organization_key.clone();
1788 let existing_key = organization_key.clone();
1789 ApiClient::new_mocked(move |mock| {
1790 mock.pam_access_connector_rotation_configs_api
1791 .expect_get()
1792 .returning(move |_org_id, _id| Ok(sample_detail_response(&existing_key, None)))
1793 .once();
1794
1795 mock.pam_access_connector_rotation_target_systems_api
1796 .expect_get_all()
1797 .returning(move |_org_id| {
1798 Ok(target_systems_response(vec![terminable_target_response(
1799 &target_key,
1800 )]))
1801 })
1802 .once();
1803
1804 mock.pam_access_connector_rotation_configs_api
1805 .expect_put()
1806 .withf(move |org_id, id, request| {
1807 if *org_id != Uuid::from(organization_id())
1808 || *id != Uuid::from(config_id())
1809 || request.rotate_on_access_end != Some(true)
1810 {
1811 return false;
1812 }
1813
1814 let store = create_test_crypto_with_user_and_org_key(
1815 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1816 organization_id(),
1817 config_key.clone(),
1818 );
1819 let mut ctx = store.context_mut();
1820 let unsealed = unseal_rotation_config::<KeySlotIds>(
1821 &request
1822 .sealed_data
1823 .parse::<DataEnvelope>()
1824 .expect("sealedData is well-formed"),
1825 &request
1826 .wrapped_key
1827 .parse::<EncString>()
1828 .expect("wrappedKey is well-formed"),
1829 &SymmetricKeySlotId::Organization(organization_id()),
1830 &mut ctx,
1831 )
1832 .expect("the blob unseals under the organization key");
1833
1834 unsealed.account_identity == "svc_rotation"
1835 })
1836 .returning(move |_org_id, _id, _request| {
1837 Ok(sample_detail_response(&response_key, None))
1838 })
1839 .once();
1840 })
1841 };
1842
1843 let detail = client_with_org_key(api_client, organization_key)
1844 .update(organization_id(), config_id(), create_request())
1845 .await
1846 .expect("the update succeeds");
1847
1848 assert_eq!(detail.config.id, config_id());
1849 }
1850
1851 #[tokio::test]
1852 async fn update_rejects_an_invalid_request_before_calling_the_server() {
1853 let result = client(ApiClient::new_mocked(|_| {}))
1854 .update(
1855 organization_id(),
1856 config_id(),
1857 RotationConfigCreateRequest {
1858 account_identity: String::new(),
1859 ..create_request()
1860 },
1861 )
1862 .await;
1863
1864 assert!(matches!(
1865 result,
1866 Err(RotationError::Validation(
1867 RotationValidationError::InvalidAccountIdentity
1868 ))
1869 ));
1870 }
1871
1872 #[tokio::test]
1874 async fn update_rejects_a_target_system_id_different_from_the_existing_config() {
1875 let organization_key = organization_key();
1876 let api_client = {
1877 let organization_key = organization_key.clone();
1878 ApiClient::new_mocked(move |mock| {
1879 mock.pam_access_connector_rotation_configs_api
1880 .expect_get()
1881 .returning(move |_org_id, _id| {
1882 Ok(sample_detail_response(&organization_key, None))
1884 })
1885 .once();
1886 })
1887 };
1888
1889 let result = client_with_org_key(api_client, organization_key)
1890 .update(
1891 organization_id(),
1892 config_id(),
1893 RotationConfigCreateRequest {
1894 target_system_id: TargetSystemId::new(uuid!(
1896 "88888888-8888-8888-8888-888888888888"
1897 )),
1898 ..create_request()
1899 },
1900 )
1901 .await;
1902
1903 assert!(matches!(
1904 result,
1905 Err(RotationError::Validation(
1906 RotationValidationError::RotationConfigImmutableFieldMismatch
1907 ))
1908 ));
1909 }
1910
1911 #[tokio::test]
1913 async fn update_rejects_a_cipher_id_different_from_the_existing_config() {
1914 let organization_key = organization_key();
1915 let api_client = {
1916 let organization_key = organization_key.clone();
1917 ApiClient::new_mocked(move |mock| {
1918 mock.pam_access_connector_rotation_configs_api
1919 .expect_get()
1920 .returning(move |_org_id, _id| {
1921 Ok(sample_detail_response(&organization_key, None))
1923 })
1924 .once();
1925 })
1926 };
1927
1928 let result = client_with_org_key(api_client, organization_key)
1929 .update(
1930 organization_id(),
1931 config_id(),
1932 RotationConfigCreateRequest {
1933 cipher_id: CipherId::new(uuid!("99999999-9999-9999-9999-999999999999")),
1935 ..create_request()
1936 },
1937 )
1938 .await;
1939
1940 assert!(matches!(
1941 result,
1942 Err(RotationError::Validation(
1943 RotationValidationError::RotationConfigImmutableFieldMismatch
1944 ))
1945 ));
1946 }
1947
1948 #[tokio::test]
1949 async fn pause_targets_the_requested_config() {
1950 let api_client = ApiClient::new_mocked(move |mock| {
1951 mock.pam_access_connector_rotation_configs_api
1952 .expect_pause()
1953 .withf(|org_id, id| {
1954 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1955 })
1956 .returning(move |_org_id, _id| Ok(()))
1957 .once();
1958 });
1959
1960 client(api_client)
1961 .pause(organization_id(), config_id())
1962 .await
1963 .expect("pausing succeeds");
1964 }
1965
1966 #[tokio::test]
1967 async fn resume_targets_the_requested_config() {
1968 let api_client = ApiClient::new_mocked(move |mock| {
1969 mock.pam_access_connector_rotation_configs_api
1970 .expect_resume()
1971 .withf(|org_id, id| {
1972 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1973 })
1974 .returning(move |_org_id, _id| Ok(()))
1975 .once();
1976 });
1977
1978 client(api_client)
1979 .resume(organization_id(), config_id())
1980 .await
1981 .expect("resuming succeeds");
1982 }
1983
1984 #[tokio::test]
1986 async fn rotate_now_dispatches_an_on_demand_rotation() {
1987 let api_client = ApiClient::new_mocked(move |mock| {
1988 mock.pam_access_connector_rotation_configs_api
1989 .expect_rotate()
1990 .withf(|org_id, id| {
1991 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1992 })
1993 .returning(move |_org_id, _id| Ok(()))
1994 .once();
1995 });
1996
1997 client(api_client)
1998 .rotate_now(organization_id(), config_id())
1999 .await
2000 .expect("dispatching succeeds");
2001 }
2002
2003 #[tokio::test]
2004 async fn record_manual_rotation_records_against_the_requested_config() {
2005 let api_client = ApiClient::new_mocked(move |mock| {
2006 mock.pam_access_connector_rotation_configs_api
2007 .expect_record_manual()
2008 .withf(|org_id, id| {
2009 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
2010 })
2011 .returning(move |_org_id, _id| Ok(()))
2012 .once();
2013 });
2014
2015 client(api_client)
2016 .record_manual_rotation(organization_id(), config_id())
2017 .await
2018 .expect("recording succeeds");
2019 }
2020
2021 #[tokio::test]
2022 async fn delete_targets_the_requested_config() {
2023 let api_client = ApiClient::new_mocked(move |mock| {
2024 mock.pam_access_connector_rotation_configs_api
2025 .expect_delete()
2026 .withf(|org_id, id| {
2027 *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
2028 })
2029 .returning(move |_org_id, _id| Ok(()))
2030 .once();
2031 });
2032
2033 client(api_client)
2034 .delete(organization_id(), config_id())
2035 .await
2036 .expect("deleting succeeds");
2037 }
2038
2039 #[tokio::test]
2042 async fn rotate_now_surfaces_an_api_error() {
2043 let api_client = ApiClient::new_mocked(move |mock| {
2044 mock.pam_access_connector_rotation_configs_api
2045 .expect_rotate()
2046 .returning(move |_org_id, _id| {
2047 Err(bitwarden_api_api::apis::Error::Response(
2048 bitwarden_api_api::apis::ResponseContent {
2049 status: reqwest::StatusCode::TOO_MANY_REQUESTS,
2050 message: String::new(),
2051 },
2052 ))
2053 })
2054 .once();
2055 });
2056
2057 let result = client(api_client)
2058 .rotate_now(organization_id(), config_id())
2059 .await;
2060
2061 assert!(matches!(result, Err(RotationError::Api(_))));
2062 }
2063
2064 #[test]
2067 fn the_client_derives_the_same_actions_as_the_config() {
2068 let organization_key = organization_key();
2069 let store = create_test_crypto_with_user_and_org_key(
2070 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
2071 organization_id(),
2072 organization_key.clone(),
2073 );
2074 let mut ctx = store.context_mut();
2075 let config = config_view(
2076 organization_id(),
2077 sample_response(&organization_key),
2078 &mut ctx,
2079 )
2080 .expect("the payload is valid");
2081
2082 for status in [
2083 None,
2084 Some(TargetSystemStatus::Active),
2085 Some(TargetSystemStatus::Disabled),
2086 Some(TargetSystemStatus::Unknown),
2087 ] {
2088 assert_eq!(
2089 client(ApiClient::new_mocked(|_| {})).actions(config.clone(), status),
2090 config.actions(status),
2091 "for {status:?}"
2092 );
2093 }
2094 }
2095}