Skip to main content

bitwarden_pam/rotation/
target_systems.rs

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