Skip to main content

bitwarden_pam/rotation/
connectors.rs

1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4    AssignAccessConnectorTargetRequestModel, PamAccessConnectorDetailResponseModel,
5    PamAccessConnectorResponseModel, RegisterAccessConnectorRequestModel,
6};
7use bitwarden_core::{
8    FromClient, OrganizationId, client::ApiConfigurations, key_management::KeySlotIds, require,
9};
10use bitwarden_crypto::KeyStore;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use super::{
15    error::RotationError,
16    models::{AccessConnectorStatus, RotationJob},
17    validate::validate_name,
18};
19use crate::{AccessConnectorId, TargetSystemId};
20
21/// An access connector: the unattended agent that performs credential rotations for an
22/// organization.
23#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
24#[bitwarden_ffi::wasm_record]
25#[serde(rename_all = "camelCase")]
26pub struct AccessConnector {
27    /// The connector's unique identifier.
28    pub id: AccessConnectorId,
29    /// The organization this connector belongs to.
30    pub organization_id: OrganizationId,
31    /// Display name, stored in plaintext on the server and copied into audit rows, so it must not
32    /// carry anything sensitive.
33    pub name: String,
34    /// Lifecycle state.
35    pub status: AccessConnectorStatus,
36    /// Whether the connector has heartbeated within the server's offline threshold, so it can lag
37    /// a disconnect by up to that threshold.
38    pub is_connected: bool,
39    /// The last heartbeat the server recorded (UTC), or `None` absent any connection.
40    pub last_heartbeat_at: Option<DateTime<Utc>>,
41    /// The target systems this connector may rotate.
42    pub assigned_target_system_ids: Vec<TargetSystemId>,
43    /// The connector's registration time (UTC).
44    pub creation_date: DateTime<Utc>,
45    /// The connector's last-modified time (UTC).
46    pub revision_date: DateTime<Utc>,
47}
48
49impl TryFrom<PamAccessConnectorResponseModel> for AccessConnector {
50    type Error = RotationError;
51
52    fn try_from(response: PamAccessConnectorResponseModel) -> Result<Self, Self::Error> {
53        Ok(Self {
54            id: AccessConnectorId::new(require!(response.id)),
55            organization_id: OrganizationId::new(require!(response.organization_id)),
56            name: require!(response.name),
57            status: require!(response.status).into(),
58            is_connected: response.is_connected.unwrap_or(false),
59            last_heartbeat_at: response
60                .last_heartbeat_at
61                .map(|date| date.parse())
62                .transpose()?,
63            assigned_target_system_ids: response
64                .assigned_target_system_ids
65                .unwrap_or_default()
66                .into_iter()
67                .map(TargetSystemId::new)
68                .collect(),
69            creation_date: require!(response.creation_date).parse()?,
70            revision_date: require!(response.revision_date).parse()?,
71        })
72    }
73}
74
75/// A connector together with its recent rotation activity.
76#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
77#[bitwarden_ffi::wasm_record]
78#[serde(rename_all = "camelCase")]
79pub struct AccessConnectorDetail {
80    /// The connector itself.
81    pub connector: AccessConnector,
82    /// The jobs this connector has worked, newest first, with the attempts it recorded. The server
83    /// caps the count, so this is recent activity only.
84    pub jobs: Vec<RotationJob>,
85}
86
87impl TryFrom<PamAccessConnectorDetailResponseModel> for AccessConnectorDetail {
88    type Error = RotationError;
89
90    fn try_from(response: PamAccessConnectorDetailResponseModel) -> Result<Self, Self::Error> {
91        let jobs = response
92            .jobs
93            .clone()
94            .unwrap_or_default()
95            .into_iter()
96            .map(RotationJob::try_from)
97            .collect::<Result<Vec<_>, _>>()?;
98
99        // The server flattens the connector's own fields onto the detail payload.
100        let connector = AccessConnector::try_from(PamAccessConnectorResponseModel {
101            object: response.object,
102            id: response.id,
103            organization_id: response.organization_id,
104            name: response.name,
105            status: response.status,
106            is_connected: response.is_connected,
107            last_heartbeat_at: response.last_heartbeat_at,
108            assigned_target_system_ids: response.assigned_target_system_ids,
109            creation_date: response.creation_date,
110            revision_date: response.revision_date,
111        })?;
112
113        Ok(Self { connector, jobs })
114    }
115}
116
117/// The result of registering a connector, including its one-time token.
118#[derive(Serialize, Deserialize, Debug, Clone)]
119#[bitwarden_ffi::wasm_record]
120#[serde(rename_all = "camelCase")]
121pub struct AccessConnectorRegistrationResponse {
122    /// The newly registered connector's unique identifier.
123    pub id: AccessConnectorId,
124    /// The organization the connector was registered in.
125    pub organization_id: OrganizationId,
126    /// The connector's display name.
127    pub name: String,
128    /// Lifecycle state. A freshly registered connector is
129    /// [`Enabled`](AccessConnectorStatus::Enabled).
130    pub status: AccessConnectorStatus,
131    /// The connector's registration time (UTC).
132    pub creation_date: DateTime<Utc>,
133    /// The credential the operator provisions into the connector's configuration.
134    ///
135    /// Returned once and unrecoverable, so never persist or log it. A lost token means deleting
136    /// the connector and registering again.
137    pub token: String,
138}
139
140/// Client for PAM access connector operations.
141#[bitwarden_ffi::wasm_object]
142#[derive(FromClient)]
143pub struct AccessConnectorsClient {
144    pub(crate) key_store: KeyStore<KeySlotIds>,
145    pub(crate) api_configurations: Arc<ApiConfigurations>,
146}
147
148#[bitwarden_ffi::wasm_export]
149impl AccessConnectorsClient {
150    /// Lists the organization's access connectors.
151    pub async fn list(
152        &self,
153        organization_id: OrganizationId,
154    ) -> Result<Vec<AccessConnector>, RotationError> {
155        let response = self
156            .api_configurations
157            .api_client
158            .pam_access_connectors_api()
159            .get_all(organization_id.into())
160            .await?;
161
162        response
163            .data
164            .unwrap_or_default()
165            .into_iter()
166            .map(AccessConnector::try_from)
167            .collect()
168    }
169
170    /// Reads one connector with its recent rotation activity.
171    pub async fn get(
172        &self,
173        organization_id: OrganizationId,
174        id: AccessConnectorId,
175    ) -> Result<AccessConnectorDetail, RotationError> {
176        let response = self
177            .api_configurations
178            .api_client
179            .pam_access_connectors_api()
180            .get(organization_id.into(), id.into())
181            .await?;
182
183        AccessConnectorDetail::try_from(response)
184    }
185
186    /// Registers a connector and returns its one-time token; see
187    /// [`AccessConnectorRegistrationResponse::token`].
188    pub async fn register(
189        &self,
190        organization_id: OrganizationId,
191        name: String,
192    ) -> Result<AccessConnectorRegistrationResponse, RotationError> {
193        validate_name(&name)?;
194
195        // Derived before the call so a key-store problem fails without leaving a registered
196        // connector the caller has no token for.
197        let secrets = self.registration_secrets(organization_id)?;
198
199        let response = self
200            .api_configurations
201            .api_client
202            .pam_access_connectors_api()
203            .post(
204                organization_id.into(),
205                RegisterAccessConnectorRequestModel {
206                    name: name.clone(),
207                    encrypted_payload: secrets.encrypted_payload.to_string(),
208                    key: secrets.key.to_string(),
209                },
210            )
211            .await?;
212
213        let api_key_id = require!(response.api_key_id);
214        let client_secret = require!(response.client_secret);
215
216        Ok(AccessConnectorRegistrationResponse {
217            id: AccessConnectorId::new(require!(response.id)),
218            organization_id: OrganizationId::new(require!(response.organization_id)),
219            name: response.name.unwrap_or(name),
220            status: require!(response.status).into(),
221            creation_date: require!(response.creation_date).parse()?,
222            token: secrets.into_token(api_key_id, &client_secret),
223        })
224    }
225
226    /// Re-enables a disabled connector so it can authenticate and claim jobs again.
227    pub async fn enable(
228        &self,
229        organization_id: OrganizationId,
230        id: AccessConnectorId,
231    ) -> Result<(), RotationError> {
232        self.api_configurations
233            .api_client
234            .pam_access_connectors_api()
235            .enable(organization_id.into(), id.into())
236            .await?;
237
238        Ok(())
239    }
240
241    /// Disables a connector, so it cannot authenticate or claim jobs. Reversible with
242    /// [`enable`](AccessConnectorsClient::enable); use [`delete`](AccessConnectorsClient::delete)
243    /// to retire it for good.
244    pub async fn disable(
245        &self,
246        organization_id: OrganizationId,
247        id: AccessConnectorId,
248    ) -> Result<(), RotationError> {
249        self.api_configurations
250            .api_client
251            .pam_access_connectors_api()
252            .disable(organization_id.into(), id.into())
253            .await?;
254
255        Ok(())
256    }
257
258    /// Permanently deletes a connector and invalidates its credential. The connector held the
259    /// plaintext organization key, so a suspected compromise still needs that key rotated.
260    pub async fn delete(
261        &self,
262        organization_id: OrganizationId,
263        id: AccessConnectorId,
264    ) -> Result<(), RotationError> {
265        self.api_configurations
266            .api_client
267            .pam_access_connectors_api()
268            .delete(organization_id.into(), id.into())
269            .await?;
270
271        Ok(())
272    }
273
274    /// Assigns a target system to a connector, so it may rotate credentials against that target.
275    pub async fn assign_target(
276        &self,
277        organization_id: OrganizationId,
278        id: AccessConnectorId,
279        target_system_id: TargetSystemId,
280    ) -> Result<(), RotationError> {
281        self.api_configurations
282            .api_client
283            .pam_access_connectors_api()
284            .assign_target(
285                organization_id.into(),
286                id.into(),
287                AssignAccessConnectorTargetRequestModel {
288                    target_system_id: target_system_id.into(),
289                },
290            )
291            .await?;
292
293        Ok(())
294    }
295
296    /// Removes a target-system assignment from a connector.
297    pub async fn unassign_target(
298        &self,
299        organization_id: OrganizationId,
300        id: AccessConnectorId,
301        target_system_id: TargetSystemId,
302    ) -> Result<(), RotationError> {
303        self.api_configurations
304            .api_client
305            .pam_access_connectors_api()
306            .unassign_target(organization_id.into(), id.into(), target_system_id.into())
307            .await?;
308
309        Ok(())
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use std::sync::Mutex;
316
317    use bitwarden_access_token::{AccessToken, AccessTokenKind};
318    use bitwarden_api_api::{
319        apis::ApiClient,
320        models::{
321            PamAccessConnectorResponseModelListResponseModel,
322            PamAccessConnectorStatus as ApiAccessConnectorStatus, PamRotationJobResponseModel,
323            PamRotationJobStatus as ApiRotationJobStatus, PamRotationSource as ApiRotationSource,
324            RegisterAccessConnectorResponseModel,
325        },
326    };
327    use bitwarden_core::key_management::{
328        SymmetricKeySlotId, create_test_crypto_with_user_and_org_key,
329        create_test_crypto_with_user_key,
330    };
331    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, SymmetricKeyAlgorithm};
332    use bitwarden_sensitive_value::ExposeSensitive as _;
333    use uuid::{Uuid, uuid};
334
335    use super::*;
336    use crate::rotation::{
337        models::{RotationJobStatus, RotationSource},
338        validate::RotationValidationError,
339    };
340
341    fn organization_id() -> OrganizationId {
342        OrganizationId::new(uuid!("11111111-1111-1111-1111-111111111111"))
343    }
344
345    fn connector_id() -> AccessConnectorId {
346        AccessConnectorId::new(uuid!("22222222-2222-2222-2222-222222222222"))
347    }
348
349    fn target_system_id() -> TargetSystemId {
350        TargetSystemId::new(uuid!("33333333-3333-3333-3333-333333333333"))
351    }
352
353    fn job_id() -> Uuid {
354        uuid!("44444444-4444-4444-4444-444444444444")
355    }
356
357    fn api_key_id() -> Uuid {
358        uuid!("55555555-5555-5555-5555-555555555555")
359    }
360
361    fn client(api_client: ApiClient) -> AccessConnectorsClient {
362        client_with_org_key(
363            api_client,
364            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
365        )
366    }
367
368    fn client_with_org_key(
369        api_client: ApiClient,
370        organization_key: SymmetricCryptoKey,
371    ) -> AccessConnectorsClient {
372        AccessConnectorsClient {
373            key_store: create_test_crypto_with_user_and_org_key(
374                SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
375                organization_id(),
376                organization_key,
377            ),
378            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
379        }
380    }
381
382    /// A payload with every optional field populated, so a test that clears one is unambiguous
383    /// about which field it is exercising.
384    fn sample_response() -> PamAccessConnectorResponseModel {
385        PamAccessConnectorResponseModel {
386            id: Some(connector_id().into()),
387            organization_id: Some(organization_id().into()),
388            name: Some("Prod connector".to_string()),
389            status: Some(ApiAccessConnectorStatus::Enabled),
390            is_connected: Some(true),
391            last_heartbeat_at: Some("2026-02-01T12:30:00Z".to_string()),
392            assigned_target_system_ids: Some(vec![target_system_id().into()]),
393            creation_date: Some("2026-01-01T00:00:00Z".to_string()),
394            revision_date: Some("2026-01-15T00:00:00Z".to_string()),
395            ..Default::default()
396        }
397    }
398
399    /// The same connector as [`sample_response`], as the detail route returns it: the connector's
400    /// own fields flattened onto the payload, plus its recent jobs.
401    fn sample_detail_response(
402        jobs: Option<Vec<PamRotationJobResponseModel>>,
403    ) -> PamAccessConnectorDetailResponseModel {
404        let connector = sample_response();
405        PamAccessConnectorDetailResponseModel {
406            object: connector.object,
407            id: connector.id,
408            organization_id: connector.organization_id,
409            name: connector.name,
410            status: connector.status,
411            is_connected: connector.is_connected,
412            last_heartbeat_at: connector.last_heartbeat_at,
413            assigned_target_system_ids: connector.assigned_target_system_ids,
414            creation_date: connector.creation_date,
415            revision_date: connector.revision_date,
416            jobs,
417        }
418    }
419
420    fn sample_job(id: Uuid) -> PamRotationJobResponseModel {
421        PamRotationJobResponseModel {
422            id: Some(id),
423            rotation_config_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
424            source: Some(ApiRotationSource::Scheduled),
425            status: Some(ApiRotationJobStatus::Succeeded),
426            creation_date: Some("2026-01-10T00:00:00Z".to_string()),
427            ..Default::default()
428        }
429    }
430
431    fn registration_response() -> RegisterAccessConnectorResponseModel {
432        RegisterAccessConnectorResponseModel {
433            id: Some(connector_id().into()),
434            organization_id: Some(organization_id().into()),
435            name: Some("Prod connector".to_string()),
436            status: Some(ApiAccessConnectorStatus::Enabled),
437            creation_date: Some("2026-01-01T00:00:00Z".to_string()),
438            api_key_id: Some(api_key_id()),
439            client_secret: Some("client-secret-value".to_string()),
440            ..Default::default()
441        }
442    }
443
444    #[test]
445    fn a_full_payload_maps_every_field() {
446        let connector = AccessConnector::try_from(sample_response()).expect("the payload is valid");
447
448        assert_eq!(connector.id, connector_id());
449        assert_eq!(connector.organization_id, organization_id());
450        assert_eq!(connector.name, "Prod connector");
451        assert_eq!(connector.status, AccessConnectorStatus::Enabled);
452        assert!(connector.is_connected);
453        assert_eq!(
454            connector.last_heartbeat_at,
455            Some("2026-02-01T12:30:00Z".parse().expect("a valid timestamp"))
456        );
457        assert_eq!(connector.assigned_target_system_ids, [target_system_id()]);
458        assert_eq!(
459            connector.creation_date,
460            "2026-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
461        );
462        assert_eq!(
463            connector.revision_date,
464            "2026-01-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
465        );
466    }
467
468    /// A connector that has never connected has no heartbeat or assignments, which is not an
469    /// error, and `is_connected` must read false.
470    #[test]
471    fn a_connector_that_has_never_connected_maps_to_disconnected_with_no_heartbeat() {
472        let response = PamAccessConnectorResponseModel {
473            is_connected: None,
474            last_heartbeat_at: None,
475            assigned_target_system_ids: None,
476            ..sample_response()
477        };
478
479        let connector = AccessConnector::try_from(response).expect("the payload is valid");
480
481        assert!(!connector.is_connected);
482        assert_eq!(connector.last_heartbeat_at, None);
483        assert!(connector.assigned_target_system_ids.is_empty());
484    }
485
486    #[test]
487    fn a_missing_required_field_is_reported_rather_than_defaulted() {
488        for response in [
489            PamAccessConnectorResponseModel {
490                id: None,
491                ..sample_response()
492            },
493            PamAccessConnectorResponseModel {
494                organization_id: None,
495                ..sample_response()
496            },
497            PamAccessConnectorResponseModel {
498                name: None,
499                ..sample_response()
500            },
501            PamAccessConnectorResponseModel {
502                status: None,
503                ..sample_response()
504            },
505            PamAccessConnectorResponseModel {
506                creation_date: None,
507                ..sample_response()
508            },
509            PamAccessConnectorResponseModel {
510                revision_date: None,
511                ..sample_response()
512            },
513        ] {
514            assert!(matches!(
515                AccessConnector::try_from(response.clone()),
516                Err(RotationError::MissingField(_))
517            ));
518        }
519    }
520
521    #[test]
522    fn an_unparseable_date_is_reported() {
523        for response in [
524            PamAccessConnectorResponseModel {
525                creation_date: Some("not a date".to_string()),
526                ..sample_response()
527            },
528            PamAccessConnectorResponseModel {
529                revision_date: Some("not a date".to_string()),
530                ..sample_response()
531            },
532            PamAccessConnectorResponseModel {
533                last_heartbeat_at: Some("not a date".to_string()),
534                ..sample_response()
535            },
536        ] {
537            assert!(matches!(
538                AccessConnector::try_from(response),
539                Err(RotationError::Chrono(_))
540            ));
541        }
542    }
543
544    /// A newer server naming a status this version does not model must not fail the whole list.
545    #[test]
546    fn an_unrecognized_status_degrades_to_unknown() {
547        let response = PamAccessConnectorResponseModel {
548            status: Some(ApiAccessConnectorStatus::__Unknown(99)),
549            ..sample_response()
550        };
551
552        let connector = AccessConnector::try_from(response).expect("an unknown status still maps");
553
554        assert_eq!(connector.status, AccessConnectorStatus::Unknown);
555    }
556
557    /// The detail conversion hand-copies each field of the flattened payload, so a dropped one
558    /// would silently read as absent.
559    #[test]
560    fn the_detail_payload_yields_the_same_connector_as_the_list_payload() {
561        let detail =
562            AccessConnectorDetail::try_from(sample_detail_response(Some(vec![sample_job(
563                job_id(),
564            )])))
565            .expect("the payload is valid");
566
567        assert_eq!(
568            detail.connector,
569            AccessConnector::try_from(sample_response()).expect("the payload is valid")
570        );
571    }
572
573    #[test]
574    fn detail_jobs_keep_the_order_the_server_sent() {
575        let second = uuid!("77777777-7777-7777-7777-777777777777");
576        let detail = AccessConnectorDetail::try_from(sample_detail_response(Some(vec![
577            sample_job(job_id()),
578            sample_job(second),
579        ])))
580        .expect("the payload is valid");
581
582        let ids: Vec<Uuid> = detail.jobs.iter().map(|job| job.id.into()).collect();
583        assert_eq!(ids, [job_id(), second]);
584        assert_eq!(detail.jobs[0].source, RotationSource::Scheduled);
585        assert_eq!(detail.jobs[0].status, RotationJobStatus::Succeeded);
586    }
587
588    /// A connector that has never been dispatched work has no jobs, which is not an error.
589    #[test]
590    fn a_detail_payload_without_jobs_has_no_jobs() {
591        let detail =
592            AccessConnectorDetail::try_from(sample_detail_response(None)).expect("no jobs is fine");
593
594        assert!(detail.jobs.is_empty());
595    }
596
597    #[test]
598    fn a_malformed_job_fails_the_whole_detail() {
599        let malformed = PamRotationJobResponseModel {
600            id: None,
601            ..sample_job(job_id())
602        };
603
604        let result = AccessConnectorDetail::try_from(sample_detail_response(Some(vec![malformed])));
605
606        assert!(matches!(result, Err(RotationError::MissingField(_))));
607    }
608
609    #[tokio::test]
610    async fn list_maps_the_organizations_connectors() {
611        let api_client = ApiClient::new_mocked(move |mock| {
612            mock.pam_access_connectors_api
613                .expect_get_all()
614                .withf(|org_id| *org_id == Uuid::from(organization_id()))
615                .returning(move |_org_id| {
616                    Ok(PamAccessConnectorResponseModelListResponseModel {
617                        data: Some(vec![sample_response()]),
618                        ..Default::default()
619                    })
620                })
621                .once();
622        });
623
624        let connectors = client(api_client)
625            .list(organization_id())
626            .await
627            .expect("the list succeeds");
628
629        assert_eq!(connectors.len(), 1);
630        assert_eq!(connectors[0].id, connector_id());
631    }
632
633    #[tokio::test]
634    async fn an_organization_with_no_connectors_lists_empty() {
635        let api_client = ApiClient::new_mocked(move |mock| {
636            mock.pam_access_connectors_api
637                .expect_get_all()
638                .returning(move |_org_id| {
639                    Ok(PamAccessConnectorResponseModelListResponseModel::default())
640                })
641                .once();
642        });
643
644        let connectors = client(api_client)
645            .list(organization_id())
646            .await
647            .expect("an absent list is not an error");
648
649        assert!(connectors.is_empty());
650    }
651
652    #[tokio::test]
653    async fn list_fails_if_any_connector_is_malformed() {
654        let api_client = ApiClient::new_mocked(move |mock| {
655            mock.pam_access_connectors_api
656                .expect_get_all()
657                .returning(move |_org_id| {
658                    Ok(PamAccessConnectorResponseModelListResponseModel {
659                        data: Some(vec![
660                            sample_response(),
661                            PamAccessConnectorResponseModel {
662                                id: None,
663                                ..sample_response()
664                            },
665                        ]),
666                        ..Default::default()
667                    })
668                })
669                .once();
670        });
671
672        let result = client(api_client).list(organization_id()).await;
673
674        assert!(matches!(result, Err(RotationError::MissingField(_))));
675    }
676
677    #[tokio::test]
678    async fn list_surfaces_an_api_error() {
679        let api_client = ApiClient::new_mocked(move |mock| {
680            mock.pam_access_connectors_api
681                .expect_get_all()
682                .returning(move |_org_id| {
683                    Err(bitwarden_api_api::apis::Error::Response(
684                        bitwarden_api_api::apis::ResponseContent {
685                            status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
686                            message: String::new(),
687                        },
688                    ))
689                })
690                .once();
691        });
692
693        let result = client(api_client).list(organization_id()).await;
694
695        assert!(matches!(result, Err(RotationError::Api(_))));
696    }
697
698    #[tokio::test]
699    async fn get_reads_the_requested_connector_with_its_jobs() {
700        let api_client = ApiClient::new_mocked(move |mock| {
701            mock.pam_access_connectors_api
702                .expect_get()
703                .withf(|org_id, id| {
704                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
705                })
706                .returning(move |_org_id, _id| {
707                    Ok(sample_detail_response(Some(vec![sample_job(job_id())])))
708                })
709                .once();
710        });
711
712        let detail = client(api_client)
713            .get(organization_id(), connector_id())
714            .await
715            .expect("the read succeeds");
716
717        assert_eq!(detail.connector.id, connector_id());
718        assert_eq!(detail.jobs.len(), 1);
719    }
720
721    #[tokio::test]
722    async fn get_surfaces_an_api_error() {
723        let api_client = ApiClient::new_mocked(move |mock| {
724            mock.pam_access_connectors_api
725                .expect_get()
726                .returning(move |_org_id, _id| {
727                    Err(bitwarden_api_api::apis::Error::Response(
728                        bitwarden_api_api::apis::ResponseContent {
729                            status: reqwest::StatusCode::NOT_FOUND,
730                            message: String::new(),
731                        },
732                    ))
733                })
734                .once();
735        });
736
737        let result = client(api_client)
738            .get(organization_id(), connector_id())
739            .await;
740
741        assert!(matches!(result, Err(RotationError::Api(_))));
742    }
743
744    /// The connector must recover the organization key from its token plus the server's
745    /// `encryptedPayload`. Walks that path through the public method, so a mint/parse mismatch
746    /// fails here.
747    #[tokio::test]
748    async fn register_returns_a_token_that_recovers_the_organization_key() {
749        let organization_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
750        let sent_payload = Arc::new(Mutex::new(None));
751
752        let captured = sent_payload.clone();
753        let api_client = ApiClient::new_mocked(move |mock| {
754            mock.pam_access_connectors_api
755                .expect_post()
756                .withf(|org_id, request| {
757                    *org_id == Uuid::from(organization_id()) && request.name == "Prod connector"
758                })
759                .returning(move |_org_id, request| {
760                    *captured.lock().expect("the lock is not poisoned") =
761                        Some(request.encrypted_payload);
762                    Ok(registration_response())
763                })
764                .once();
765        });
766
767        let registered = client_with_org_key(api_client, organization_key.clone())
768            .register(organization_id(), "Prod connector".to_string())
769            .await
770            .expect("registration succeeds");
771
772        assert_eq!(registered.id, connector_id());
773        assert_eq!(registered.organization_id, organization_id());
774        assert_eq!(registered.name, "Prod connector");
775        assert_eq!(registered.status, AccessConnectorStatus::Enabled);
776
777        // Everything below is what a connector does with the token it was provisioned.
778        let token = AccessToken::parse(&registered.token, AccessTokenKind::AccessConnector)
779            .expect("the token parses");
780        assert_eq!(token.api_key_id(), api_key_id());
781        assert_eq!(token.client_secret().expose(), "client-secret-value");
782
783        let payload = sent_payload
784            .lock()
785            .expect("the lock is not poisoned")
786            .clone()
787            .expect("the request carried an encrypted payload");
788
789        let connector_store: KeyStore<KeySlotIds> = KeyStore::default();
790        let organization_key_slot = SymmetricKeySlotId::Organization(organization_id());
791        token
792            .open_payload(
793                &mut connector_store.context_mut(),
794                &payload,
795                organization_key_slot,
796            )
797            .expect("the key derived from the token decrypts the payload");
798
799        let ctx = connector_store.context();
800        #[allow(deprecated)]
801        let recovered_b64 = ctx
802            .dangerous_get_symmetric_key(organization_key_slot)
803            .expect("the slot was just populated")
804            .to_base64();
805        assert_eq!(
806            recovered_b64.to_string(),
807            organization_key.to_base64().to_string(),
808            "the connector must recover the organization key verbatim"
809        );
810    }
811
812    /// The token is unrecoverable, so the caller must be able to name its connector even when the
813    /// server echoes no name back.
814    #[tokio::test]
815    async fn register_falls_back_to_the_requested_name_when_the_server_omits_it() {
816        let api_client = ApiClient::new_mocked(move |mock| {
817            mock.pam_access_connectors_api
818                .expect_post()
819                .returning(move |_org_id, _request| {
820                    Ok(RegisterAccessConnectorResponseModel {
821                        name: None,
822                        ..registration_response()
823                    })
824                })
825                .once();
826        });
827
828        let registered = client(api_client)
829            .register(organization_id(), "Prod connector".to_string())
830            .await
831            .expect("registration succeeds");
832
833        assert_eq!(registered.name, "Prod connector");
834    }
835
836    /// A registration that cannot produce a usable token must not leave a connector behind. The
837    /// mock has no expectations, so any call to the server fails the test.
838    #[tokio::test]
839    async fn register_rejects_an_invalid_name_before_calling_the_server() {
840        for name in ["", "   ", &"a".repeat(201)] {
841            let result = client(ApiClient::new_mocked(|_| {}))
842                .register(organization_id(), name.to_string())
843                .await;
844
845            assert!(
846                matches!(
847                    result,
848                    Err(RotationError::Validation(
849                        RotationValidationError::InvalidName
850                    ))
851                ),
852                "for {name:?}"
853            );
854        }
855    }
856
857    /// Same obligation as an invalid name, for the other failure that precedes the call: without
858    /// the organization key there is nothing to wrap into the token.
859    #[tokio::test]
860    async fn register_without_the_organization_key_never_reaches_the_server() {
861        let client = AccessConnectorsClient {
862            // A store with a user key but no organization key, as for a non-member.
863            key_store: create_test_crypto_with_user_key(SymmetricCryptoKey::make(
864                SymmetricKeyAlgorithm::Aes256CbcHmac,
865            )),
866            api_configurations: Arc::new(ApiConfigurations::from_api_client(
867                ApiClient::new_mocked(|_| {}),
868            )),
869        };
870
871        let result = client
872            .register(organization_id(), "Prod connector".to_string())
873            .await;
874
875        assert!(matches!(result, Err(RotationError::MissingOrganizationKey)));
876    }
877
878    /// A token missing either half cannot authenticate, so a response without them has to fail
879    /// rather than hand back a string that looks like a credential.
880    #[tokio::test]
881    async fn register_fails_when_the_response_cannot_complete_the_token() {
882        for response in [
883            RegisterAccessConnectorResponseModel {
884                api_key_id: None,
885                ..registration_response()
886            },
887            RegisterAccessConnectorResponseModel {
888                client_secret: None,
889                ..registration_response()
890            },
891        ] {
892            let api_client = ApiClient::new_mocked(move |mock| {
893                mock.pam_access_connectors_api
894                    .expect_post()
895                    .returning(move |_org_id, _request| Ok(response.clone()))
896                    .once();
897            });
898
899            let result = client(api_client)
900                .register(organization_id(), "Prod connector".to_string())
901                .await;
902
903            assert!(matches!(result, Err(RotationError::MissingField(_))));
904        }
905    }
906
907    #[tokio::test]
908    async fn enable_targets_the_requested_connector() {
909        let api_client = ApiClient::new_mocked(move |mock| {
910            mock.pam_access_connectors_api
911                .expect_enable()
912                .withf(|org_id, id| {
913                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
914                })
915                .returning(move |_org_id, _id| Ok(()))
916                .once();
917        });
918
919        client(api_client)
920            .enable(organization_id(), connector_id())
921            .await
922            .expect("enabling succeeds");
923    }
924
925    #[tokio::test]
926    async fn disable_targets_the_requested_connector() {
927        let api_client = ApiClient::new_mocked(move |mock| {
928            mock.pam_access_connectors_api
929                .expect_disable()
930                .withf(|org_id, id| {
931                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
932                })
933                .returning(move |_org_id, _id| Ok(()))
934                .once();
935        });
936
937        client(api_client)
938            .disable(organization_id(), connector_id())
939            .await
940            .expect("disabling succeeds");
941    }
942
943    #[tokio::test]
944    async fn delete_targets_the_requested_connector() {
945        let api_client = ApiClient::new_mocked(move |mock| {
946            mock.pam_access_connectors_api
947                .expect_delete()
948                .withf(|org_id, id| {
949                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(connector_id())
950                })
951                .returning(move |_org_id, _id| Ok(()))
952                .once();
953        });
954
955        client(api_client)
956            .delete(organization_id(), connector_id())
957            .await
958            .expect("deleting succeeds");
959    }
960
961    #[tokio::test]
962    async fn delete_surfaces_an_api_error() {
963        let api_client = ApiClient::new_mocked(move |mock| {
964            mock.pam_access_connectors_api
965                .expect_delete()
966                .returning(move |_org_id, _id| {
967                    Err(bitwarden_api_api::apis::Error::Response(
968                        bitwarden_api_api::apis::ResponseContent {
969                            status: reqwest::StatusCode::NOT_FOUND,
970                            message: String::new(),
971                        },
972                    ))
973                })
974                .once();
975        });
976
977        let result = client(api_client)
978            .delete(organization_id(), connector_id())
979            .await;
980
981        assert!(matches!(result, Err(RotationError::Api(_))));
982    }
983
984    #[tokio::test]
985    async fn assign_target_sends_the_target_system_in_the_body() {
986        let api_client = ApiClient::new_mocked(move |mock| {
987            mock.pam_access_connectors_api
988                .expect_assign_target()
989                .withf(|org_id, id, request| {
990                    *org_id == Uuid::from(organization_id())
991                        && *id == Uuid::from(connector_id())
992                        && request.target_system_id == Uuid::from(target_system_id())
993                })
994                .returning(move |_org_id, _id, _request| Ok(()))
995                .once();
996        });
997
998        client(api_client)
999            .assign_target(organization_id(), connector_id(), target_system_id())
1000            .await
1001            .expect("assigning succeeds");
1002    }
1003
1004    /// `unassign_target` puts the target system in the path rather than a body, so it takes three
1005    /// same-typed identifiers in a row, and transposing two would still compile.
1006    #[tokio::test]
1007    async fn unassign_target_sends_the_identifiers_in_the_right_order() {
1008        let api_client = ApiClient::new_mocked(move |mock| {
1009            mock.pam_access_connectors_api
1010                .expect_unassign_target()
1011                .withf(|org_id, id, target_system_id_arg| {
1012                    *org_id == Uuid::from(organization_id())
1013                        && *id == Uuid::from(connector_id())
1014                        && *target_system_id_arg == Uuid::from(target_system_id())
1015                })
1016                .returning(move |_org_id, _id, _target_system_id| Ok(()))
1017                .once();
1018        });
1019
1020        client(api_client)
1021            .unassign_target(organization_id(), connector_id(), target_system_id())
1022            .await
1023            .expect("unassigning succeeds");
1024    }
1025}