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#[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 pub id: AccessConnectorId,
33 pub organization_id: OrganizationId,
35 pub name: String,
38 pub status: AccessConnectorStatus,
40 pub is_connected: bool,
43 pub last_heartbeat_at: Option<DateTime<Utc>>,
45 pub assigned_target_system_ids: Vec<TargetSystemId>,
47 pub creation_date: DateTime<Utc>,
49 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#[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 pub connector: AccessConnector,
86 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 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#[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 pub id: AccessConnectorId,
128 pub organization_id: OrganizationId,
130 pub name: String,
132 pub status: AccessConnectorStatus,
135 pub creation_date: DateTime<Utc>,
137 pub token: String,
142}
143
144#[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 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 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 pub async fn register(
193 &self,
194 organization_id: OrganizationId,
195 name: String,
196 ) -> Result<AccessConnectorRegistrationResponse, RotationError> {
197 validate_name(&name)?;
198
199 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 let token = AccessToken::parse(®istered.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 #[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 #[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 #[tokio::test]
864 async fn register_without_the_organization_key_never_reaches_the_server() {
865 let client = AccessConnectorsClient {
866 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 #[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 #[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}