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;
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/// A target system as the server returns it, with its security-relevant fields still sealed.
38#[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    /// `organization_id` must be the queried id, not `response.organization_id`, or a hostile
53    /// server could pass off another organization's validly-sealed blob.
54    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    /// Unseals the blob and checks it against this row's `id` and `method`. Any problem degrades
80    /// to `None` rather than failing, so one bad blob can't fail a `list`.
81    fn unseal(
82        &self,
83        key: SymmetricKeySlotId,
84        ctx: &mut KeyStoreContext<KeySlotIds>,
85    ) -> Option<TargetSystemSealedFields> {
86        if self.status == TargetSystemStatus::PendingSeal {
87            // Not sealed yet; expected, so not logged as a rejection.
88            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        // Unknown formats parse as `Unparseable` and fail to unseal.
104        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    /// Never fails: a blob that can't be verified leaves [`TargetSystemView::sealed`] `None`.
132    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/// A target system a credential can be rotated against, with its sealed fields unsealed.
151#[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    /// The target system's unique identifier.
156    pub id: TargetSystemId,
157    /// The organization this target system belongs to.
158    pub organization_id: OrganizationId,
159    /// Display name. Plaintext organization configuration, not vault data.
160    pub name: String,
161    /// How credentials are rotated for this target.
162    pub method: TargetSystemMethod,
163    /// Lifecycle state.
164    pub status: TargetSystemStatus,
165    /// `None` if the blob is not sealed yet, fails to unseal, or disagrees with this row's ID or
166    /// method.
167    pub sealed: Option<TargetSystemSealedFields>,
168    /// The target system's creation time (UTC).
169    pub creation_date: DateTime<Utc>,
170    /// The target system's last-modified time (UTC).
171    pub revision_date: DateTime<Utc>,
172}
173
174/// Security-relevant fields of a target system; the server only stores them sealed.
175#[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    /// The integration behind the target. `None` only for a
180    /// [`Manual`](TargetSystemMethod::Manual) target.
181    pub kind: Option<TargetSystemKind>,
182    /// Constraints the rotated credential must satisfy.
183    pub password_policy: PasswordPolicy,
184    /// Whether the integration can terminate sessions after rotating. `None` for
185    /// [`Manual`](TargetSystemMethod::Manual) targets.
186    pub supports_session_termination: Option<bool>,
187}
188
189/// Request to create or update a target system. Method and kind can't change after creation, but
190/// [`TargetSystemsClient::update`] still takes the full shape.
191///
192/// Serializes with a `method` discriminant matching the server's own.
193#[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    /// A connector rotates the credential by writing it into the target system.
198    #[serde(rename_all = "camelCase")]
199    Automatic {
200        /// Display name.
201        name: String,
202        /// The integration that performs the rotation.
203        kind: TargetSystemKind,
204        /// Constraints the connector generates the new credential under.
205        password_policy: PasswordPolicy,
206        /// Whether the integration can terminate the account's sessions after rotating.
207        supports_session_termination: bool,
208    },
209    /// An operator rotates the credential out of band and records that they did.
210    #[serde(rename_all = "camelCase")]
211    Manual {
212        /// Display name.
213        name: String,
214        /// The rules the operator is expected to follow by hand; nothing enforces them, since
215        /// no connector runs a manual rotation.
216        password_policy: PasswordPolicy,
217    },
218}
219
220impl TargetSystemCreateRequest {
221    /// The display name, whichever method this request carries.
222    pub fn name(&self) -> &str {
223        match self {
224            Self::Automatic { name, .. } | Self::Manual { name, .. } => name,
225        }
226    }
227
228    /// The password policy, whichever method this request carries.
229    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    /// The rotation method this request carries: never [`TargetSystemMethod::Unknown`].
241    pub fn method(&self) -> TargetSystemMethod {
242        match self {
243            Self::Automatic { .. } => TargetSystemMethod::Automatic,
244            Self::Manual { .. } => TargetSystemMethod::Manual,
245        }
246    }
247
248    /// The integration kind. `None` for a [`Manual`](TargetSystemMethod::Manual) request.
249    pub fn kind(&self) -> Option<TargetSystemKind> {
250        match self {
251            Self::Automatic { kind, .. } => Some(*kind),
252            Self::Manual { .. } => None,
253        }
254    }
255
256    /// Whether the integration can terminate sessions after rotating. `None` for a
257    /// [`Manual`](TargetSystemMethod::Manual) request.
258    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
269/// A [`TargetSystemCreateRequest`] validated and converted to its sealed form, minus the ID that
270/// phase 2 binds in. Exists so the phase-1 `POST` only runs for a request phase 2 can seal,
271/// leaving no orphaned [`PendingSeal`](TargetSystemStatus::PendingSeal) row.
272struct 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    /// Seals under `key`, binding the payload to `id` so a blob swapped from another target is
310    /// detected.
311    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/// Failure from [`TargetSystemsClient::create`].
345///
346/// `full` mode (unlike [`RotationError`]) so [`Self::id`] crosses the FFI boundary: sealing can
347/// fail after the `POST` assigned an ID, leaving a row to retry or delete.
348#[bitwarden_error(full)]
349#[derive(Debug, Error)]
350#[error("{message}")]
351pub struct TargetSystemCreateError {
352    /// `None` means nothing was created.
353    pub id: Option<TargetSystemId>,
354    /// Never contains sealed field values.
355    pub message: String,
356}
357
358/// Client for PAM rotation target-system operations.
359#[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    /// Lists the organization's target systems.
369    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    /// Registers a target system with a `POST`, leaving it
390    /// [`PendingSeal`](TargetSystemStatus::PendingSeal), then seals its blob under the new ID and
391    /// `PUT`s it. If the second phase fails the row stays `PendingSeal`; retry via
392    /// [`Self::update`] with the same request, or [`Self::delete`] it.
393    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    /// Phase 1 of [`Self::create`]. Takes only a prepared target, so the `POST` can't run for a
418    /// request phase 2 can't seal.
419    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    /// Runs before the phase-1 `POST`; `put_sealed` keeps its own check because it needs the key
438    /// in its own context.
439    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    /// Phase 2 of [`Self::create`]. The `PUT` returns no content, so `revision_date` is the
452    /// phase-1 value.
453    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    /// Re-seals the whole blob, so every field must be resupplied. Also how a
482    /// [`PendingSeal`](TargetSystemStatus::PendingSeal) row left by a failed [`Self::create`]
483    /// becomes [`Active`](TargetSystemStatus::Active).
484    ///
485    /// Rejects a `method` change, and a `kind` change when the existing blob is verified.
486    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    /// There is no single-target `GET`, so this lists and converts only `id`'s row.
510    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    /// Seals `prepared` under `id` and `PUT`s it.
534    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    /// Puts a disabled target system back into service; new rotation jobs dispatch for it again.
567    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    /// Stops new rotation jobs being dispatched for a target system. Jobs already in flight run to
582    /// completion.
583    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    /// Permanently deletes a target system. The server refuses while any rotation config still
598    /// names the target; delete those configs first, or [`disable`](TargetSystemsClient::disable)
599    /// a target that is only unavailable.
600    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    /// `organization_key` is the key `client()` installs for `organization_id()`.
694    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    /// An automatic, sealed, active target with every field populated, so a test that clears one
741    /// is unambiguous about which field it is exercising.
742    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    /// Garbage bytes, a blob sealed for another target, and a sealed `method` mismatch.
818    #[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    /// A wrapped key from a different seal of the same data can't open this envelope.
874    #[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    /// A hostile server serves org B's item, with org B's validly-sealed blob, in org A's list.
897    #[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        // The caller holds both orgs' keys.
905        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            // `organization_id: None` degrades instead; see
949            // `a_blob_from_a_different_organization_is_not_trusted`.
950            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        // An unrecognized method never matches the sealed method, so this degrades too.
1027        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    /// Nothing is created when local validation fails, so the error carries no ID. The client
1325    /// holds the organization key, so the failure is the validation, not the key check.
1326    #[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    /// Refused before sealing or sending anything.
1483    #[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                            // Existing target is Automatic.
1494                            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                // Request is Manual, disagreeing with the existing Automatic target.
1507                manual_create_request(),
1508            )
1509            .await;
1510
1511        assert!(matches!(
1512            result,
1513            Err(RotationError::Validation(
1514                RotationValidationError::TargetSystemMethodMismatch
1515            ))
1516        ));
1517    }
1518
1519    /// Refused before sealing or sending anything.
1520    #[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                            // Existing target has sealed kind Mssql (see `sample_sealed_data`).
1531                            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                    // Request names Entra, disagreeing with the sealed Mssql.
1546                    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    /// The server holds the "no config may still name it" precondition, and its refusal must reach
1664    /// the caller as an error rather than read as a deleted target.
1665    #[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}