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