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