1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4 PamTargetSystemMethod as ApiTargetSystemMethod, PamTargetSystemResponseModel,
5 RegisterTargetSystemRequestModel, UpdateTargetSystemRequestModel,
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_error::bitwarden_error;
17use bitwarden_pam_sealed::{
18 Sealed, SealedPasswordPolicy, SealedTargetSystemKind, SealedTargetSystemMethod,
19 TargetSystemSealedV1, seal_target_system, unseal_target_system,
20};
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24#[cfg(feature = "wasm")]
25use tsify::Tsify;
26use uuid::Uuid;
27#[cfg(feature = "wasm")]
28use wasm_bindgen::prelude::wasm_bindgen;
29
30use super::{
31 error::RotationError,
32 models::{PasswordPolicy, TargetSystemKind, TargetSystemMethod, TargetSystemStatus},
33 validate::{RotationValidationError, validate_name},
34};
35use crate::TargetSystemId;
36
37#[derive(Debug, Clone, PartialEq)]
39pub(crate) struct TargetSystem {
40 id: TargetSystemId,
41 organization_id: OrganizationId,
42 name: String,
43 method: TargetSystemMethod,
44 status: TargetSystemStatus,
45 sealed_data: Option<String>,
46 wrapped_key: Option<String>,
47 creation_date: DateTime<Utc>,
48 revision_date: DateTime<Utc>,
49}
50
51impl TargetSystem {
52 pub(super) fn from_response(
55 organization_id: OrganizationId,
56 response: PamTargetSystemResponseModel,
57 ) -> Result<Self, RotationError> {
58 let (sealed_data, wrapped_key) = if response.organization_id == Some(organization_id.into())
59 {
60 (response.sealed_data, response.wrapped_key)
61 } else {
62 tracing::debug!("PAM target system organization_id mismatch");
63 (None, None)
64 };
65
66 Ok(Self {
67 id: TargetSystemId::new(require!(response.id)),
68 organization_id,
69 name: require!(response.name),
70 method: require!(response.method).into(),
71 status: require!(response.status).into(),
72 sealed_data,
73 wrapped_key,
74 creation_date: require!(response.creation_date).parse()?,
75 revision_date: require!(response.revision_date).parse()?,
76 })
77 }
78
79 fn unseal(
82 &self,
83 key: SymmetricKeySlotId,
84 ctx: &mut KeyStoreContext<KeySlotIds>,
85 ) -> Option<TargetSystemSealedFields> {
86 if self.status == TargetSystemStatus::PendingSeal {
87 return None;
89 }
90 self.try_unseal(key, ctx)
91 .inspect_err(|reason| tracing::debug!(reason, "PAM target system sealed blob rejected"))
92 .ok()
93 }
94
95 fn try_unseal(
96 &self,
97 key: SymmetricKeySlotId,
98 ctx: &mut KeyStoreContext<KeySlotIds>,
99 ) -> Result<TargetSystemSealedFields, &'static str> {
100 let sealed_data = self.sealed_data.as_deref().ok_or("missing blob")?;
101 let wrapped_key = self.wrapped_key.as_deref().ok_or("missing wrapped key")?;
102 let envelope: DataEnvelope = sealed_data.parse().map_err(|_| "malformed sealed data")?;
103 let wrapped_key = EncString::from(wrapped_key);
105
106 let unsealed = unseal_target_system::<KeySlotIds>(&envelope, &wrapped_key, &key, ctx)
107 .map_err(|_| "unseal failed")?;
108
109 if unsealed.target_system_id != Uuid::from(self.id) {
110 return Err("target_system_id mismatch");
111 }
112 if TargetSystemMethod::from(unsealed.method) != self.method {
113 return Err("method mismatch");
114 }
115
116 Ok(TargetSystemSealedFields {
117 kind: unsealed.kind.map(Into::into),
118 password_policy: unsealed.password_policy.into(),
119 supports_session_termination: unsealed.supports_session_termination,
120 })
121 }
122}
123
124impl IdentifyKey<SymmetricKeySlotId> for TargetSystem {
125 fn key_identifier(&self) -> SymmetricKeySlotId {
126 SymmetricKeySlotId::Organization(self.organization_id)
127 }
128}
129
130impl Decryptable<KeySlotIds, SymmetricKeySlotId, TargetSystemView> for TargetSystem {
131 fn decrypt(
133 &self,
134 ctx: &mut KeyStoreContext<KeySlotIds>,
135 key: SymmetricKeySlotId,
136 ) -> Result<TargetSystemView, CryptoError> {
137 Ok(TargetSystemView {
138 id: self.id,
139 organization_id: self.organization_id,
140 name: self.name.clone(),
141 method: self.method,
142 status: self.status,
143 sealed: self.unseal(key, ctx),
144 creation_date: self.creation_date,
145 revision_date: self.revision_date,
146 })
147 }
148}
149
150#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
152#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
153#[serde(rename_all = "camelCase")]
154pub struct TargetSystemView {
155 pub id: TargetSystemId,
157 pub organization_id: OrganizationId,
159 pub name: String,
161 pub method: TargetSystemMethod,
163 pub status: TargetSystemStatus,
165 pub sealed: Option<TargetSystemSealedFields>,
168 pub creation_date: DateTime<Utc>,
170 pub revision_date: DateTime<Utc>,
172}
173
174#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
176#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
177#[serde(rename_all = "camelCase")]
178pub struct TargetSystemSealedFields {
179 pub kind: Option<TargetSystemKind>,
182 pub password_policy: PasswordPolicy,
184 pub supports_session_termination: Option<bool>,
187}
188
189#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
194#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
195#[serde(tag = "method", rename_all = "snake_case")]
196pub enum TargetSystemCreateRequest {
197 #[serde(rename_all = "camelCase")]
199 Automatic {
200 name: String,
202 kind: TargetSystemKind,
204 password_policy: PasswordPolicy,
206 supports_session_termination: bool,
208 },
209 #[serde(rename_all = "camelCase")]
211 Manual {
212 name: String,
214 password_policy: PasswordPolicy,
217 },
218}
219
220impl TargetSystemCreateRequest {
221 pub fn name(&self) -> &str {
223 match self {
224 Self::Automatic { name, .. } | Self::Manual { name, .. } => name,
225 }
226 }
227
228 pub fn password_policy(&self) -> &PasswordPolicy {
230 match self {
231 Self::Automatic {
232 password_policy, ..
233 }
234 | Self::Manual {
235 password_policy, ..
236 } => password_policy,
237 }
238 }
239
240 pub fn method(&self) -> TargetSystemMethod {
242 match self {
243 Self::Automatic { .. } => TargetSystemMethod::Automatic,
244 Self::Manual { .. } => TargetSystemMethod::Manual,
245 }
246 }
247
248 pub fn kind(&self) -> Option<TargetSystemKind> {
250 match self {
251 Self::Automatic { kind, .. } => Some(*kind),
252 Self::Manual { .. } => None,
253 }
254 }
255
256 pub fn supports_session_termination(&self) -> Option<bool> {
259 match self {
260 Self::Automatic {
261 supports_session_termination,
262 ..
263 } => Some(*supports_session_termination),
264 Self::Manual { .. } => None,
265 }
266 }
267}
268
269struct PreparedTargetSystem {
273 name: String,
274 method: SealedTargetSystemMethod,
275 kind: Option<SealedTargetSystemKind>,
276 password_policy: SealedPasswordPolicy,
277 supports_session_termination: Option<bool>,
278}
279
280impl TryFrom<&TargetSystemCreateRequest> for PreparedTargetSystem {
281 type Error = RotationError;
282
283 fn try_from(request: &TargetSystemCreateRequest) -> Result<Self, Self::Error> {
284 validate_name(request.name())?;
285 request
286 .password_policy()
287 .validate()
288 .map_err(RotationValidationError::from)?;
289 let kind = request
290 .kind()
291 .map(SealedTargetSystemKind::try_from)
292 .transpose()?;
293 let method = match request {
294 TargetSystemCreateRequest::Automatic { .. } => SealedTargetSystemMethod::Automatic,
295 TargetSystemCreateRequest::Manual { .. } => SealedTargetSystemMethod::Manual,
296 };
297
298 Ok(Self {
299 name: request.name().to_string(),
300 method,
301 kind,
302 password_policy: request.password_policy().clone().into(),
303 supports_session_termination: request.supports_session_termination(),
304 })
305 }
306}
307
308impl PreparedTargetSystem {
309 fn seal(
312 &self,
313 id: TargetSystemId,
314 key: SymmetricKeySlotId,
315 ctx: &mut KeyStoreContext<KeySlotIds>,
316 ) -> Result<Sealed, RotationError> {
317 if !ctx.has_symmetric_key(key) {
318 return Err(RotationError::MissingOrganizationKey);
319 }
320
321 let data = TargetSystemSealedV1 {
322 target_system_id: id.into(),
323 method: self.method,
324 kind: self.kind,
325 password_policy: self.password_policy.clone(),
326 supports_session_termination: self.supports_session_termination,
327 };
328 Ok(seal_target_system::<KeySlotIds>(data, &key, ctx)?)
329 }
330}
331
332impl From<&PreparedTargetSystem> for RegisterTargetSystemRequestModel {
333 fn from(prepared: &PreparedTargetSystem) -> Self {
334 Self {
335 name: prepared.name.clone(),
336 method: match prepared.method {
337 SealedTargetSystemMethod::Automatic => ApiTargetSystemMethod::Automatic,
338 SealedTargetSystemMethod::Manual => ApiTargetSystemMethod::Manual,
339 },
340 }
341 }
342}
343
344#[bitwarden_error(full)]
349#[derive(Debug, Error)]
350#[error("{message}")]
351pub struct TargetSystemCreateError {
352 pub id: Option<TargetSystemId>,
354 pub message: String,
356}
357
358#[cfg_attr(feature = "wasm", wasm_bindgen)]
360#[derive(FromClient)]
361pub struct TargetSystemsClient {
362 pub(crate) key_store: KeyStore<KeySlotIds>,
363 pub(crate) api_configurations: Arc<ApiConfigurations>,
364}
365
366#[cfg_attr(feature = "wasm", wasm_bindgen)]
367impl TargetSystemsClient {
368 pub async fn list(
370 &self,
371 organization_id: OrganizationId,
372 ) -> Result<Vec<TargetSystemView>, RotationError> {
373 let response = self
374 .api_configurations
375 .api_client
376 .pam_access_connector_rotation_target_systems_api()
377 .get_all(organization_id.into())
378 .await?;
379
380 let targets = response
381 .data
382 .unwrap_or_default()
383 .into_iter()
384 .map(|item| TargetSystem::from_response(organization_id, item))
385 .collect::<Result<Vec<_>, _>>()?;
386 Ok(self.key_store.decrypt_list(&targets)?)
387 }
388
389 pub async fn create(
394 &self,
395 organization_id: OrganizationId,
396 request: TargetSystemCreateRequest,
397 ) -> Result<TargetSystemView, TargetSystemCreateError> {
398 let nothing_created = |error: RotationError| TargetSystemCreateError {
399 id: None,
400 message: error.to_string(),
401 };
402
403 let prepared = PreparedTargetSystem::try_from(&request).map_err(nothing_created)?;
404 let (id, phase_one) = self
405 .register(organization_id, &prepared)
406 .await
407 .map_err(nothing_created)?;
408
409 self.seal_and_finish(organization_id, id, &phase_one, &prepared, &request)
410 .await
411 .map_err(|error| TargetSystemCreateError {
412 id: Some(id),
413 message: error.to_string(),
414 })
415 }
416
417 async fn register(
420 &self,
421 organization_id: OrganizationId,
422 prepared: &PreparedTargetSystem,
423 ) -> Result<(TargetSystemId, PamTargetSystemResponseModel), RotationError> {
424 self.require_organization_key(organization_id)?;
425
426 let response = self
427 .api_configurations
428 .api_client
429 .pam_access_connector_rotation_target_systems_api()
430 .post(organization_id.into(), prepared.into())
431 .await?;
432
433 let id = TargetSystemId::new(require!(response.id));
434 Ok((id, response))
435 }
436
437 fn require_organization_key(
440 &self,
441 organization_id: OrganizationId,
442 ) -> Result<(), RotationError> {
443 let organization_key = SymmetricKeySlotId::Organization(organization_id);
444 if !self.key_store.context().has_symmetric_key(organization_key) {
445 return Err(RotationError::MissingOrganizationKey);
446 }
447
448 Ok(())
449 }
450
451 async fn seal_and_finish(
454 &self,
455 organization_id: OrganizationId,
456 id: TargetSystemId,
457 phase_one: &PamTargetSystemResponseModel,
458 prepared: &PreparedTargetSystem,
459 request: &TargetSystemCreateRequest,
460 ) -> Result<TargetSystemView, RotationError> {
461 self.put_sealed(organization_id, id, prepared).await?;
462
463 let creation_date: DateTime<Utc> = require!(phase_one.creation_date.clone()).parse()?;
464
465 Ok(TargetSystemView {
466 id,
467 organization_id,
468 name: request.name().to_string(),
469 method: request.method(),
470 status: TargetSystemStatus::Active,
471 sealed: Some(TargetSystemSealedFields {
472 kind: request.kind(),
473 password_policy: request.password_policy().clone(),
474 supports_session_termination: request.supports_session_termination(),
475 }),
476 creation_date,
477 revision_date: creation_date,
478 })
479 }
480
481 pub async fn update(
487 &self,
488 organization_id: OrganizationId,
489 id: TargetSystemId,
490 request: TargetSystemCreateRequest,
491 ) -> Result<(), RotationError> {
492 let prepared = PreparedTargetSystem::try_from(&request)?;
493
494 let existing = self.existing(organization_id, id).await?;
495
496 if request.method() != existing.method {
497 return Err(RotationValidationError::TargetSystemMethodMismatch.into());
498 }
499
500 if let Some(existing_sealed) = &existing.sealed
501 && request.kind() != existing_sealed.kind
502 {
503 return Err(RotationValidationError::TargetSystemKindMismatch.into());
504 }
505
506 self.put_sealed(organization_id, id, &prepared).await
507 }
508
509 async fn existing(
511 &self,
512 organization_id: OrganizationId,
513 id: TargetSystemId,
514 ) -> Result<TargetSystemView, RotationError> {
515 let response = self
516 .api_configurations
517 .api_client
518 .pam_access_connector_rotation_target_systems_api()
519 .get_all(organization_id.into())
520 .await?;
521
522 let item = response
523 .data
524 .unwrap_or_default()
525 .into_iter()
526 .find(|item| item.id == Some(id.into()))
527 .ok_or(RotationError::TargetSystemNotFound)?;
528
529 let target = TargetSystem::from_response(organization_id, item)?;
530 Ok(self.key_store.decrypt(&target)?)
531 }
532
533 async fn put_sealed(
535 &self,
536 organization_id: OrganizationId,
537 id: TargetSystemId,
538 prepared: &PreparedTargetSystem,
539 ) -> Result<(), RotationError> {
540 let sealed = {
541 let mut ctx = self.key_store.context_mut();
542 prepared.seal(
543 id,
544 SymmetricKeySlotId::Organization(organization_id),
545 &mut ctx,
546 )?
547 };
548
549 self.api_configurations
550 .api_client
551 .pam_access_connector_rotation_target_systems_api()
552 .put(
553 organization_id.into(),
554 id.into(),
555 UpdateTargetSystemRequestModel {
556 name: prepared.name.clone(),
557 sealed_data: sealed.envelope.to_string(),
558 wrapped_key: sealed.wrapped_key.to_string(),
559 },
560 )
561 .await?;
562
563 Ok(())
564 }
565
566 pub async fn enable(
568 &self,
569 organization_id: OrganizationId,
570 id: TargetSystemId,
571 ) -> Result<(), RotationError> {
572 self.api_configurations
573 .api_client
574 .pam_access_connector_rotation_target_systems_api()
575 .enable(organization_id.into(), id.into())
576 .await?;
577
578 Ok(())
579 }
580
581 pub async fn disable(
584 &self,
585 organization_id: OrganizationId,
586 id: TargetSystemId,
587 ) -> Result<(), RotationError> {
588 self.api_configurations
589 .api_client
590 .pam_access_connector_rotation_target_systems_api()
591 .disable(organization_id.into(), id.into())
592 .await?;
593
594 Ok(())
595 }
596
597 pub async fn delete(
601 &self,
602 organization_id: OrganizationId,
603 id: TargetSystemId,
604 ) -> Result<(), RotationError> {
605 self.api_configurations
606 .api_client
607 .pam_access_connector_rotation_target_systems_api()
608 .delete(organization_id.into(), id.into())
609 .await?;
610
611 Ok(())
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use bitwarden_api_api::{
618 apis::ApiClient,
619 models::{
620 PamTargetSystemResponseModelListResponseModel,
621 PamTargetSystemStatus as ApiTargetSystemStatus,
622 },
623 };
624 use bitwarden_core::key_management::create_test_crypto_with_user_and_org_key;
625 use bitwarden_crypto::{
626 EncString, KeyStoreContext, SymmetricCryptoKey, SymmetricKeyAlgorithm, safe::DataEnvelope,
627 };
628 use bitwarden_pam_sealed::{
629 Sealed, SealedPasswordPolicy, TargetSystemSealedV1, unseal_target_system,
630 };
631 use uuid::{Uuid, uuid};
632
633 use super::*;
634 use crate::rotation::validate::RotationValidationError;
635
636 fn organization_id() -> OrganizationId {
637 OrganizationId::new(uuid!("11111111-1111-1111-1111-111111111111"))
638 }
639
640 fn target_system_id() -> TargetSystemId {
641 TargetSystemId::new(uuid!("22222222-2222-2222-2222-222222222222"))
642 }
643
644 fn organization_key() -> SymmetricCryptoKey {
645 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac)
646 }
647
648 fn client(api_client: ApiClient) -> TargetSystemsClient {
649 client_with_org_key(api_client, organization_key())
650 }
651
652 fn client_with_org_key(
653 api_client: ApiClient,
654 organization_key: SymmetricCryptoKey,
655 ) -> TargetSystemsClient {
656 TargetSystemsClient {
657 key_store: create_test_crypto_with_user_and_org_key(
658 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
659 organization_id(),
660 organization_key,
661 ),
662 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
663 }
664 }
665
666 fn policy() -> PasswordPolicy {
667 PasswordPolicy {
668 min_length: 14,
669 max_length: 20,
670 include_uppercase: true,
671 include_lowercase: true,
672 include_digits: true,
673 include_symbols: false,
674 }
675 }
676
677 fn automatic_create_request() -> TargetSystemCreateRequest {
678 TargetSystemCreateRequest::Automatic {
679 name: "Prod SQL".to_string(),
680 kind: TargetSystemKind::Mssql,
681 password_policy: policy(),
682 supports_session_termination: true,
683 }
684 }
685
686 fn manual_create_request() -> TargetSystemCreateRequest {
687 TargetSystemCreateRequest::Manual {
688 name: "Legacy mainframe".to_string(),
689 password_policy: policy(),
690 }
691 }
692
693 fn seal_with(organization_key: &SymmetricCryptoKey, data: TargetSystemSealedV1) -> Sealed {
695 let store = create_test_crypto_with_user_and_org_key(
696 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
697 organization_id(),
698 organization_key.clone(),
699 );
700 let mut ctx = store.context_mut();
701 bitwarden_pam_sealed::seal_target_system::<KeySlotIds>(
702 data,
703 &SymmetricKeySlotId::Organization(organization_id()),
704 &mut ctx,
705 )
706 .expect("sealing succeeds")
707 }
708
709 fn sealed_policy() -> SealedPasswordPolicy {
710 SealedPasswordPolicy {
711 min_length: 14,
712 max_length: 20,
713 include_uppercase: true,
714 include_lowercase: true,
715 include_digits: true,
716 include_symbols: false,
717 }
718 }
719
720 fn sample_sealed_data() -> TargetSystemSealedV1 {
721 TargetSystemSealedV1 {
722 target_system_id: target_system_id().into(),
723 method: SealedTargetSystemMethod::Automatic,
724 kind: Some(bitwarden_pam_sealed::SealedTargetSystemKind::Mssql),
725 password_policy: sealed_policy(),
726 supports_session_termination: Some(true),
727 }
728 }
729
730 fn target_view(
731 organization_id: OrganizationId,
732 response: PamTargetSystemResponseModel,
733 ctx: &mut KeyStoreContext<KeySlotIds>,
734 ) -> Result<TargetSystemView, RotationError> {
735 let target = TargetSystem::from_response(organization_id, response)?;
736 let key = target.key_identifier();
737 Ok(target.decrypt(ctx, key)?)
738 }
739
740 fn sample_response(organization_key: &SymmetricCryptoKey) -> PamTargetSystemResponseModel {
743 let payload = seal_with(organization_key, sample_sealed_data());
744 PamTargetSystemResponseModel {
745 id: Some(target_system_id().into()),
746 organization_id: Some(organization_id().into()),
747 name: Some("Prod SQL".to_string()),
748 method: Some(ApiTargetSystemMethod::Automatic),
749 status: Some(ApiTargetSystemStatus::Active),
750 sealed_data: Some(payload.envelope.to_string()),
751 wrapped_key: Some(payload.wrapped_key.to_string()),
752 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
753 revision_date: Some("2026-01-15T00:00:00Z".to_string()),
754 ..Default::default()
755 }
756 }
757
758 #[test]
759 fn a_full_payload_maps_every_field() {
760 let organization_key = organization_key();
761 let store = create_test_crypto_with_user_and_org_key(
762 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
763 organization_id(),
764 organization_key.clone(),
765 );
766 let mut ctx = store.context_mut();
767
768 let target = target_view(
769 organization_id(),
770 sample_response(&organization_key),
771 &mut ctx,
772 )
773 .expect("the payload is valid");
774
775 assert_eq!(target.id, target_system_id());
776 assert_eq!(target.organization_id, organization_id());
777 assert_eq!(target.name, "Prod SQL");
778 assert_eq!(target.method, TargetSystemMethod::Automatic);
779 assert_eq!(target.status, TargetSystemStatus::Active);
780 let sealed = target.sealed.expect("the blob is sealed");
781 assert_eq!(sealed.kind, Some(TargetSystemKind::Mssql));
782 assert_eq!(sealed.password_policy, policy());
783 assert_eq!(sealed.supports_session_termination, Some(true));
784 assert_eq!(
785 target.creation_date,
786 "2026-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
787 );
788 assert_eq!(
789 target.revision_date,
790 "2026-01-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
791 );
792 }
793
794 #[test]
795 fn a_pending_seal_target_has_no_sealed_fields() {
796 let organization_key = organization_key();
797 let store = create_test_crypto_with_user_and_org_key(
798 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
799 organization_id(),
800 organization_key.clone(),
801 );
802 let mut ctx = store.context_mut();
803
804 let response = PamTargetSystemResponseModel {
805 status: Some(ApiTargetSystemStatus::PendingSeal),
806 sealed_data: None,
807 ..sample_response(&organization_key)
808 };
809
810 let target =
811 target_view(organization_id(), response, &mut ctx).expect("the payload is valid");
812
813 assert_eq!(target.status, TargetSystemStatus::PendingSeal);
814 assert_eq!(target.sealed, None);
815 }
816
817 #[test]
819 fn invalid_blobs_degrade_to_no_sealed_fields() {
820 let organization_key = organization_key();
821 let other_target_blob = seal_with(
822 &organization_key,
823 TargetSystemSealedV1 {
824 target_system_id: uuid!("99999999-9999-9999-9999-999999999999"),
825 ..sample_sealed_data()
826 },
827 );
828 let wrong_method_blob = seal_with(
829 &organization_key,
830 TargetSystemSealedV1 {
831 method: SealedTargetSystemMethod::Manual,
832 ..sample_sealed_data()
833 },
834 );
835
836 let sample = sample_response(&organization_key);
837 for (sealed_data, wrapped_key) in [
838 (
839 Some("not a sealed blob".to_string()),
840 sample.wrapped_key.clone(),
841 ),
842 (
843 Some(other_target_blob.envelope.to_string()),
844 Some(other_target_blob.wrapped_key.to_string()),
845 ),
846 (
847 Some(wrong_method_blob.envelope.to_string()),
848 Some(wrong_method_blob.wrapped_key.to_string()),
849 ),
850 (None, sample.wrapped_key.clone()),
851 (sample.sealed_data.clone(), None),
852 ] {
853 let store = create_test_crypto_with_user_and_org_key(
854 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
855 organization_id(),
856 organization_key.clone(),
857 );
858 let mut ctx: KeyStoreContext<'_, KeySlotIds> = store.context_mut();
859
860 let response = PamTargetSystemResponseModel {
861 sealed_data,
862 wrapped_key,
863 ..sample_response(&organization_key)
864 };
865
866 let target =
867 target_view(organization_id(), response, &mut ctx).expect("the payload is valid");
868
869 assert_eq!(target.sealed, None, "for status {:?}", target.status);
870 }
871 }
872
873 #[test]
875 fn a_wrapped_key_from_a_different_seal_degrades_to_no_sealed_fields() {
876 let organization_key = organization_key();
877 let store = create_test_crypto_with_user_and_org_key(
878 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
879 organization_id(),
880 organization_key.clone(),
881 );
882 let mut ctx = store.context_mut();
883
884 let other = seal_with(&organization_key, sample_sealed_data());
885 let response = PamTargetSystemResponseModel {
886 wrapped_key: Some(other.wrapped_key.to_string()),
887 ..sample_response(&organization_key)
888 };
889
890 let target =
891 target_view(organization_id(), response, &mut ctx).expect("the payload is valid");
892
893 assert_eq!(target.sealed, None);
894 }
895
896 #[test]
898 fn a_blob_from_a_different_organization_is_not_trusted() {
899 let org_a = organization_id();
900 let org_b = OrganizationId::new(uuid!("55555555-5555-5555-5555-555555555555"));
901 let org_a_key = organization_key();
902 let org_b_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
903
904 let org_b_blob = seal_with(&org_b_key, sample_sealed_data());
906
907 let store = KeyStore::<KeySlotIds>::default();
908 {
909 let mut ctx = store.context_mut();
910 #[allow(deprecated)]
911 ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_a), org_a_key)
912 .expect("mutable context");
913 #[allow(deprecated)]
914 ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_b), org_b_key)
915 .expect("mutable context");
916 }
917
918 let response = PamTargetSystemResponseModel {
919 organization_id: Some(org_b.into()),
920 sealed_data: Some(org_b_blob.envelope.to_string()),
921 wrapped_key: Some(org_b_blob.wrapped_key.to_string()),
922 ..sample_response(&organization_key())
923 };
924
925 let mut ctx = store.context_mut();
926 let target = target_view(org_a, response, &mut ctx)
927 .expect("the payload is still structurally valid");
928
929 assert_eq!(target.organization_id, org_a);
930 assert_eq!(target.sealed, None);
931 }
932
933 #[test]
934 fn a_missing_required_field_is_reported_rather_than_defaulted() {
935 let organization_key = organization_key();
936 let store = create_test_crypto_with_user_and_org_key(
937 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
938 organization_id(),
939 organization_key.clone(),
940 );
941 let mut ctx = store.context_mut();
942
943 for response in [
944 PamTargetSystemResponseModel {
945 id: None,
946 ..sample_response(&organization_key)
947 },
948 PamTargetSystemResponseModel {
951 name: None,
952 ..sample_response(&organization_key)
953 },
954 PamTargetSystemResponseModel {
955 method: None,
956 ..sample_response(&organization_key)
957 },
958 PamTargetSystemResponseModel {
959 status: None,
960 ..sample_response(&organization_key)
961 },
962 PamTargetSystemResponseModel {
963 creation_date: None,
964 ..sample_response(&organization_key)
965 },
966 PamTargetSystemResponseModel {
967 revision_date: None,
968 ..sample_response(&organization_key)
969 },
970 ] {
971 assert!(matches!(
972 target_view(organization_id(), response, &mut ctx),
973 Err(RotationError::MissingField(_))
974 ));
975 }
976 }
977
978 #[test]
979 fn an_unparseable_date_is_reported() {
980 let organization_key = organization_key();
981 let store = create_test_crypto_with_user_and_org_key(
982 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
983 organization_id(),
984 organization_key.clone(),
985 );
986 let mut ctx = store.context_mut();
987
988 for response in [
989 PamTargetSystemResponseModel {
990 creation_date: Some("not a date".to_string()),
991 ..sample_response(&organization_key)
992 },
993 PamTargetSystemResponseModel {
994 revision_date: Some("not a date".to_string()),
995 ..sample_response(&organization_key)
996 },
997 ] {
998 assert!(matches!(
999 target_view(organization_id(), response, &mut ctx),
1000 Err(RotationError::Chrono(_))
1001 ));
1002 }
1003 }
1004
1005 #[test]
1006 fn unrecognized_enum_values_degrade_to_unknown() {
1007 let organization_key = organization_key();
1008 let store = create_test_crypto_with_user_and_org_key(
1009 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1010 organization_id(),
1011 organization_key.clone(),
1012 );
1013 let mut ctx = store.context_mut();
1014
1015 let response = PamTargetSystemResponseModel {
1016 method: Some(ApiTargetSystemMethod::__Unknown(9)),
1017 status: Some(ApiTargetSystemStatus::__Unknown(9)),
1018 ..sample_response(&organization_key)
1019 };
1020
1021 let target =
1022 target_view(organization_id(), response, &mut ctx).expect("unknown values still map");
1023
1024 assert_eq!(target.method, TargetSystemMethod::Unknown);
1025 assert_eq!(target.status, TargetSystemStatus::Unknown);
1026 assert_eq!(target.sealed, None);
1028 }
1029
1030 #[test]
1031 fn a_create_request_serializes_with_the_servers_method_discriminant() {
1032 let automatic = serde_json::to_value(automatic_create_request()).expect("it serializes");
1033 assert_eq!(automatic["method"], "automatic");
1034 assert_eq!(automatic["kind"], "mssql");
1035 assert_eq!(automatic["supportsSessionTermination"], true);
1036 assert_eq!(automatic["passwordPolicy"]["minLength"], 14);
1037
1038 let manual = serde_json::to_value(manual_create_request()).expect("it serializes");
1039 assert_eq!(manual["method"], "manual");
1040 assert_eq!(manual["name"], "Legacy mainframe");
1041 assert!(manual.get("kind").is_none());
1042 }
1043
1044 #[tokio::test]
1045 async fn list_maps_the_organizations_target_systems() {
1046 let organization_key = organization_key();
1047 let api_client = {
1048 let organization_key = organization_key.clone();
1049 ApiClient::new_mocked(move |mock| {
1050 mock.pam_access_connector_rotation_target_systems_api
1051 .expect_get_all()
1052 .withf(|org_id| *org_id == Uuid::from(organization_id()))
1053 .returning(move |_org_id| {
1054 Ok(PamTargetSystemResponseModelListResponseModel {
1055 data: Some(vec![sample_response(&organization_key)]),
1056 ..Default::default()
1057 })
1058 })
1059 .once();
1060 })
1061 };
1062
1063 let targets = client_with_org_key(api_client, organization_key)
1064 .list(organization_id())
1065 .await
1066 .expect("the list succeeds");
1067
1068 assert_eq!(targets.len(), 1);
1069 assert_eq!(targets[0].id, target_system_id());
1070 assert!(targets[0].sealed.is_some());
1071 }
1072
1073 #[tokio::test]
1074 async fn an_organization_with_no_target_systems_lists_empty() {
1075 let api_client = ApiClient::new_mocked(move |mock| {
1076 mock.pam_access_connector_rotation_target_systems_api
1077 .expect_get_all()
1078 .returning(move |_org_id| {
1079 Ok(PamTargetSystemResponseModelListResponseModel::default())
1080 })
1081 .once();
1082 });
1083
1084 let targets = client(api_client)
1085 .list(organization_id())
1086 .await
1087 .expect("an absent list is not an error");
1088
1089 assert!(targets.is_empty());
1090 }
1091
1092 #[tokio::test]
1093 async fn list_returns_the_rest_when_one_item_has_an_invalid_blob() {
1094 let organization_key = organization_key();
1095 let api_client = {
1096 let organization_key = organization_key.clone();
1097 ApiClient::new_mocked(move |mock| {
1098 mock.pam_access_connector_rotation_target_systems_api
1099 .expect_get_all()
1100 .returning(move |_org_id| {
1101 let bad = PamTargetSystemResponseModel {
1102 id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
1103 sealed_data: Some("not a sealed blob".to_string()),
1104 ..sample_response(&organization_key)
1105 };
1106 Ok(PamTargetSystemResponseModelListResponseModel {
1107 data: Some(vec![sample_response(&organization_key), bad]),
1108 ..Default::default()
1109 })
1110 })
1111 .once();
1112 })
1113 };
1114
1115 let targets = client_with_org_key(api_client, organization_key)
1116 .list(organization_id())
1117 .await
1118 .expect("one invalid item does not fail the list");
1119
1120 assert_eq!(targets.len(), 2);
1121 assert!(targets[0].sealed.is_some());
1122 assert_eq!(targets[1].sealed, None);
1123 }
1124
1125 #[tokio::test]
1126 async fn list_fails_if_a_required_field_is_missing() {
1127 let organization_key = organization_key();
1128 let api_client = {
1129 let organization_key = organization_key.clone();
1130 ApiClient::new_mocked(move |mock| {
1131 mock.pam_access_connector_rotation_target_systems_api
1132 .expect_get_all()
1133 .returning(move |_org_id| {
1134 Ok(PamTargetSystemResponseModelListResponseModel {
1135 data: Some(vec![
1136 sample_response(&organization_key),
1137 PamTargetSystemResponseModel {
1138 id: None,
1139 ..sample_response(&organization_key)
1140 },
1141 ]),
1142 ..Default::default()
1143 })
1144 })
1145 .once();
1146 })
1147 };
1148
1149 let result = client_with_org_key(api_client, organization_key)
1150 .list(organization_id())
1151 .await;
1152
1153 assert!(matches!(result, Err(RotationError::MissingField(_))));
1154 }
1155
1156 #[tokio::test]
1157 async fn list_surfaces_an_api_error() {
1158 let api_client = ApiClient::new_mocked(move |mock| {
1159 mock.pam_access_connector_rotation_target_systems_api
1160 .expect_get_all()
1161 .returning(move |_org_id| {
1162 Err(bitwarden_api_api::apis::Error::Response(
1163 bitwarden_api_api::apis::ResponseContent {
1164 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1165 message: String::new(),
1166 },
1167 ))
1168 })
1169 .once();
1170 });
1171
1172 let result = client(api_client).list(organization_id()).await;
1173
1174 assert!(matches!(result, Err(RotationError::Api(_))));
1175 }
1176
1177 #[tokio::test]
1178 async fn create_seals_the_put_body_with_the_phase_one_id() {
1179 let organization_key = organization_key();
1180 let api_client = {
1181 let organization_key = organization_key.clone();
1182 ApiClient::new_mocked(move |mock| {
1183 mock.pam_access_connector_rotation_target_systems_api
1184 .expect_post()
1185 .withf(|org_id, request| {
1186 *org_id == Uuid::from(organization_id())
1187 && request.name == "Prod SQL"
1188 && request.method == ApiTargetSystemMethod::Automatic
1189 })
1190 .returning(move |_org_id, _request| {
1191 Ok(PamTargetSystemResponseModel {
1192 id: Some(target_system_id().into()),
1193 organization_id: Some(organization_id().into()),
1194 name: Some("Prod SQL".to_string()),
1195 method: Some(ApiTargetSystemMethod::Automatic),
1196 status: Some(ApiTargetSystemStatus::PendingSeal),
1197 sealed_data: None,
1198 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1199 revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1200 ..Default::default()
1201 })
1202 })
1203 .once();
1204
1205 let organization_key = organization_key.clone();
1206 mock.pam_access_connector_rotation_target_systems_api
1207 .expect_put()
1208 .withf(move |org_id, id, request| {
1209 if *org_id != Uuid::from(organization_id())
1210 || *id != Uuid::from(target_system_id())
1211 || request.name != "Prod SQL"
1212 {
1213 return false;
1214 }
1215
1216 let store = create_test_crypto_with_user_and_org_key(
1217 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1218 organization_id(),
1219 organization_key.clone(),
1220 );
1221 let mut ctx = store.context_mut();
1222 let unsealed = unseal_target_system::<KeySlotIds>(
1223 &request
1224 .sealed_data
1225 .parse::<DataEnvelope>()
1226 .expect("sealedData is well-formed"),
1227 &request
1228 .wrapped_key
1229 .parse::<EncString>()
1230 .expect("wrappedKey is well-formed"),
1231 &SymmetricKeySlotId::Organization(organization_id()),
1232 &mut ctx,
1233 )
1234 .expect("the blob unseals under the organization key");
1235
1236 unsealed.target_system_id == Uuid::from(target_system_id())
1237 && unsealed.method == SealedTargetSystemMethod::Automatic
1238 && unsealed.kind
1239 == Some(bitwarden_pam_sealed::SealedTargetSystemKind::Mssql)
1240 && unsealed.supports_session_termination == Some(true)
1241 && unsealed.password_policy == sealed_policy()
1242 })
1243 .returning(move |_org_id, _id, _request| Ok(()))
1244 .once();
1245 })
1246 };
1247
1248 let target = client_with_org_key(api_client, organization_key)
1249 .create(organization_id(), automatic_create_request())
1250 .await
1251 .expect("creation succeeds");
1252
1253 assert_eq!(target.id, target_system_id());
1254 assert_eq!(target.status, TargetSystemStatus::Active);
1255 assert_eq!(
1256 target.sealed.and_then(|sealed| sealed.kind),
1257 Some(TargetSystemKind::Mssql)
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn create_reports_the_id_when_phase_two_fails() {
1263 let api_client = ApiClient::new_mocked(move |mock| {
1264 mock.pam_access_connector_rotation_target_systems_api
1265 .expect_post()
1266 .returning(move |_org_id, _request| {
1267 Ok(PamTargetSystemResponseModel {
1268 id: Some(target_system_id().into()),
1269 organization_id: Some(organization_id().into()),
1270 name: Some("Prod SQL".to_string()),
1271 method: Some(ApiTargetSystemMethod::Automatic),
1272 status: Some(ApiTargetSystemStatus::PendingSeal),
1273 sealed_data: None,
1274 creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1275 revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1276 ..Default::default()
1277 })
1278 })
1279 .once();
1280
1281 mock.pam_access_connector_rotation_target_systems_api
1282 .expect_put()
1283 .returning(move |_org_id, _id, _request| {
1284 Err(bitwarden_api_api::apis::Error::Response(
1285 bitwarden_api_api::apis::ResponseContent {
1286 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1287 message: String::new(),
1288 },
1289 ))
1290 })
1291 .once();
1292 });
1293
1294 let result = client(api_client)
1295 .create(organization_id(), automatic_create_request())
1296 .await;
1297
1298 let error = result.expect_err("phase 2 failed");
1299 assert_eq!(error.id, Some(target_system_id()));
1300 }
1301
1302 #[tokio::test]
1303 async fn create_refuses_without_the_organization_key_before_calling_the_server() {
1304 let key_store = KeyStore::<KeySlotIds>::default();
1305 let client = TargetSystemsClient {
1306 key_store,
1307 api_configurations: Arc::new(ApiConfigurations::from_api_client(
1308 ApiClient::new_mocked(|_| {}),
1309 )),
1310 };
1311
1312 let error = client
1313 .create(organization_id(), automatic_create_request())
1314 .await
1315 .expect_err("the organization key is missing");
1316
1317 assert_eq!(error.id, None);
1318 assert_eq!(
1319 error.message,
1320 RotationError::MissingOrganizationKey.to_string()
1321 );
1322 }
1323
1324 #[tokio::test]
1327 async fn create_rejects_an_invalid_request_before_calling_the_server() {
1328 let unsatisfiable = PasswordPolicy {
1329 include_uppercase: false,
1330 include_lowercase: false,
1331 include_digits: false,
1332 include_symbols: false,
1333 ..policy()
1334 };
1335
1336 for (request, expected) in [
1337 (
1338 TargetSystemCreateRequest::Automatic {
1339 name: " ".to_string(),
1340 kind: TargetSystemKind::Mssql,
1341 password_policy: policy(),
1342 supports_session_termination: true,
1343 },
1344 RotationValidationError::InvalidName,
1345 ),
1346 (
1347 TargetSystemCreateRequest::Manual {
1348 name: String::new(),
1349 password_policy: policy(),
1350 },
1351 RotationValidationError::InvalidName,
1352 ),
1353 (
1354 TargetSystemCreateRequest::Automatic {
1355 name: "Prod SQL".to_string(),
1356 kind: TargetSystemKind::Mssql,
1357 password_policy: unsatisfiable.clone(),
1358 supports_session_termination: true,
1359 },
1360 RotationValidationError::NoCharacterClasses,
1361 ),
1362 ] {
1363 let result = client(ApiClient::new_mocked(|_| {}))
1364 .create(organization_id(), request)
1365 .await;
1366
1367 let error = result.expect_err("the request is invalid");
1368 assert_eq!(error.id, None);
1369 assert_eq!(error.message, expected.to_string());
1370 }
1371 }
1372
1373 #[tokio::test]
1374 async fn create_refuses_an_unrecognized_kind_before_calling_the_server() {
1375 let result = client(ApiClient::new_mocked(|_| {}))
1376 .create(
1377 organization_id(),
1378 TargetSystemCreateRequest::Automatic {
1379 name: "Prod SQL".to_string(),
1380 kind: TargetSystemKind::Unknown,
1381 password_policy: policy(),
1382 supports_session_termination: true,
1383 },
1384 )
1385 .await;
1386
1387 let error = result.expect_err("an unrecognized kind is refused");
1388 assert_eq!(error.id, None);
1389 assert_eq!(
1390 error.message,
1391 RotationError::UnrecognizedVariant.to_string()
1392 );
1393 }
1394
1395 #[tokio::test]
1396 async fn update_seals_and_sends_the_request_to_the_requested_target_system() {
1397 let organization_key = organization_key();
1398 let api_client = {
1399 let organization_key = organization_key.clone();
1400 ApiClient::new_mocked(move |mock| {
1401 let existing_key = organization_key.clone();
1402 mock.pam_access_connector_rotation_target_systems_api
1403 .expect_get_all()
1404 .returning(move |_org_id| {
1405 Ok(PamTargetSystemResponseModelListResponseModel {
1406 data: Some(vec![sample_response(&existing_key)]),
1407 ..Default::default()
1408 })
1409 })
1410 .once();
1411
1412 mock.pam_access_connector_rotation_target_systems_api
1413 .expect_put()
1414 .withf(move |org_id, id, request| {
1415 if *org_id != Uuid::from(organization_id())
1416 || *id != Uuid::from(target_system_id())
1417 || request.name != "Prod SQL"
1418 {
1419 return false;
1420 }
1421
1422 let store = create_test_crypto_with_user_and_org_key(
1423 SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1424 organization_id(),
1425 organization_key.clone(),
1426 );
1427 let mut ctx = store.context_mut();
1428 let unsealed = unseal_target_system::<KeySlotIds>(
1429 &request
1430 .sealed_data
1431 .parse::<DataEnvelope>()
1432 .expect("sealedData is well-formed"),
1433 &request
1434 .wrapped_key
1435 .parse::<EncString>()
1436 .expect("wrappedKey is well-formed"),
1437 &SymmetricKeySlotId::Organization(organization_id()),
1438 &mut ctx,
1439 )
1440 .expect("the blob unseals under the organization key");
1441
1442 unsealed.target_system_id == Uuid::from(target_system_id())
1443 })
1444 .returning(move |_org_id, _id, _request| Ok(()))
1445 .once();
1446 })
1447 };
1448
1449 client_with_org_key(api_client, organization_key)
1450 .update(
1451 organization_id(),
1452 target_system_id(),
1453 automatic_create_request(),
1454 )
1455 .await
1456 .expect("the update succeeds");
1457 }
1458
1459 #[tokio::test]
1460 async fn update_rejects_an_invalid_request_before_calling_the_server() {
1461 let result = client(ApiClient::new_mocked(|_| {}))
1462 .update(
1463 organization_id(),
1464 target_system_id(),
1465 TargetSystemCreateRequest::Automatic {
1466 name: " ".to_string(),
1467 kind: TargetSystemKind::Mssql,
1468 password_policy: policy(),
1469 supports_session_termination: true,
1470 },
1471 )
1472 .await;
1473
1474 assert!(matches!(
1475 result,
1476 Err(RotationError::Validation(
1477 RotationValidationError::InvalidName
1478 ))
1479 ));
1480 }
1481
1482 #[tokio::test]
1484 async fn update_rejects_a_method_different_from_the_existing_target() {
1485 let organization_key = organization_key();
1486 let api_client = {
1487 let organization_key = organization_key.clone();
1488 ApiClient::new_mocked(move |mock| {
1489 mock.pam_access_connector_rotation_target_systems_api
1490 .expect_get_all()
1491 .returning(move |_org_id| {
1492 Ok(PamTargetSystemResponseModelListResponseModel {
1493 data: Some(vec![sample_response(&organization_key)]),
1495 ..Default::default()
1496 })
1497 })
1498 .once();
1499 })
1500 };
1501
1502 let result = client_with_org_key(api_client, organization_key)
1503 .update(
1504 organization_id(),
1505 target_system_id(),
1506 manual_create_request(),
1508 )
1509 .await;
1510
1511 assert!(matches!(
1512 result,
1513 Err(RotationError::Validation(
1514 RotationValidationError::TargetSystemMethodMismatch
1515 ))
1516 ));
1517 }
1518
1519 #[tokio::test]
1521 async fn update_rejects_a_kind_different_from_the_existing_sealed_target() {
1522 let organization_key = organization_key();
1523 let api_client = {
1524 let organization_key = organization_key.clone();
1525 ApiClient::new_mocked(move |mock| {
1526 mock.pam_access_connector_rotation_target_systems_api
1527 .expect_get_all()
1528 .returning(move |_org_id| {
1529 Ok(PamTargetSystemResponseModelListResponseModel {
1530 data: Some(vec![sample_response(&organization_key)]),
1532 ..Default::default()
1533 })
1534 })
1535 .once();
1536 })
1537 };
1538
1539 let result = client_with_org_key(api_client, organization_key)
1540 .update(
1541 organization_id(),
1542 target_system_id(),
1543 TargetSystemCreateRequest::Automatic {
1544 name: "Prod SQL".to_string(),
1545 kind: TargetSystemKind::Entra,
1547 password_policy: policy(),
1548 supports_session_termination: true,
1549 },
1550 )
1551 .await;
1552
1553 assert!(matches!(
1554 result,
1555 Err(RotationError::Validation(
1556 RotationValidationError::TargetSystemKindMismatch
1557 ))
1558 ));
1559 }
1560
1561 #[tokio::test]
1562 async fn update_fails_when_the_target_system_does_not_exist() {
1563 let api_client = ApiClient::new_mocked(move |mock| {
1564 mock.pam_access_connector_rotation_target_systems_api
1565 .expect_get_all()
1566 .returning(move |_org_id| {
1567 Ok(PamTargetSystemResponseModelListResponseModel::default())
1568 })
1569 .once();
1570 });
1571
1572 let result = client(api_client)
1573 .update(
1574 organization_id(),
1575 target_system_id(),
1576 automatic_create_request(),
1577 )
1578 .await;
1579
1580 assert!(matches!(result, Err(RotationError::TargetSystemNotFound)));
1581 }
1582
1583 #[tokio::test]
1584 async fn enable_targets_the_requested_target_system() {
1585 let api_client = ApiClient::new_mocked(move |mock| {
1586 mock.pam_access_connector_rotation_target_systems_api
1587 .expect_enable()
1588 .withf(|org_id, id| {
1589 *org_id == Uuid::from(organization_id())
1590 && *id == Uuid::from(target_system_id())
1591 })
1592 .returning(move |_org_id, _id| Ok(()))
1593 .once();
1594 });
1595
1596 client(api_client)
1597 .enable(organization_id(), target_system_id())
1598 .await
1599 .expect("enabling succeeds");
1600 }
1601
1602 #[tokio::test]
1603 async fn disable_targets_the_requested_target_system() {
1604 let api_client = ApiClient::new_mocked(move |mock| {
1605 mock.pam_access_connector_rotation_target_systems_api
1606 .expect_disable()
1607 .withf(|org_id, id| {
1608 *org_id == Uuid::from(organization_id())
1609 && *id == Uuid::from(target_system_id())
1610 })
1611 .returning(move |_org_id, _id| Ok(()))
1612 .once();
1613 });
1614
1615 client(api_client)
1616 .disable(organization_id(), target_system_id())
1617 .await
1618 .expect("disabling succeeds");
1619 }
1620
1621 #[tokio::test]
1622 async fn disable_surfaces_an_api_error() {
1623 let api_client = ApiClient::new_mocked(move |mock| {
1624 mock.pam_access_connector_rotation_target_systems_api
1625 .expect_disable()
1626 .returning(move |_org_id, _id| {
1627 Err(bitwarden_api_api::apis::Error::Response(
1628 bitwarden_api_api::apis::ResponseContent {
1629 status: reqwest::StatusCode::CONFLICT,
1630 message: String::new(),
1631 },
1632 ))
1633 })
1634 .once();
1635 });
1636
1637 let result = client(api_client)
1638 .disable(organization_id(), target_system_id())
1639 .await;
1640
1641 assert!(matches!(result, Err(RotationError::Api(_))));
1642 }
1643
1644 #[tokio::test]
1645 async fn delete_targets_the_requested_target_system() {
1646 let api_client = ApiClient::new_mocked(move |mock| {
1647 mock.pam_access_connector_rotation_target_systems_api
1648 .expect_delete()
1649 .withf(|org_id, id| {
1650 *org_id == Uuid::from(organization_id())
1651 && *id == Uuid::from(target_system_id())
1652 })
1653 .returning(move |_org_id, _id| Ok(()))
1654 .once();
1655 });
1656
1657 client(api_client)
1658 .delete(organization_id(), target_system_id())
1659 .await
1660 .expect("deleting succeeds");
1661 }
1662
1663 #[tokio::test]
1666 async fn delete_surfaces_the_refusal_when_a_config_still_names_the_target() {
1667 let api_client = ApiClient::new_mocked(move |mock| {
1668 mock.pam_access_connector_rotation_target_systems_api
1669 .expect_delete()
1670 .returning(move |_org_id, _id| {
1671 Err(bitwarden_api_api::apis::Error::Response(
1672 bitwarden_api_api::apis::ResponseContent {
1673 status: reqwest::StatusCode::CONFLICT,
1674 message: String::new(),
1675 },
1676 ))
1677 })
1678 .once();
1679 });
1680
1681 let result = client(api_client)
1682 .delete(organization_id(), target_system_id())
1683 .await;
1684
1685 assert!(matches!(result, Err(RotationError::Api(_))));
1686 }
1687}