1use hasp_core::{
20 Backend, BackendFailureKind, Entry, Error, ExposeSecret, ProxyConfig, SecretString,
21};
22use serde::Deserialize;
23use std::time::Duration;
24use url::Url;
25
26#[derive(Debug)]
31pub struct AzureKvUrl {
32 pub vault_name: String,
33 pub secret_name: String,
34 pub version: Option<String>,
35 pub field: Option<String>,
36}
37
38impl TryFrom<&Url> for AzureKvUrl {
39 type Error = Error;
40
41 fn try_from(url: &Url) -> Result<Self, Self::Error> {
42 if url.scheme() != "azure-kv" {
43 return Err(Error::InvalidUrl("expected azure-kv:// scheme".into()));
44 }
45
46 let vault_name = url
47 .host_str()
48 .ok_or_else(|| Error::InvalidUrl("azure-kv:// requires a vault name (host)".into()))?
49 .to_owned();
50 if vault_name.is_empty() {
51 return Err(Error::InvalidUrl(
52 "azure-kv:// vault name must not be empty".into(),
53 ));
54 }
55
56 let secret_name = url.path().trim_start_matches('/').to_owned();
57
58 let mut version = None;
59 let mut field = None;
60 for (k, v) in url.query_pairs() {
61 match k.as_ref() {
62 "version" => version = Some(v.into_owned()),
63 "field" => field = Some(v.into_owned()),
64 _ => {
65 return Err(Error::InvalidUrl(format!(
66 "azure-kv:// unknown query parameter: {k}"
67 )));
68 }
69 }
70 }
71
72 Ok(AzureKvUrl {
73 vault_name,
74 secret_name,
75 version,
76 field,
77 })
78 }
79}
80
81pub struct AzureKvBackend {
88 init: Result<tokio::runtime::Runtime, Error>,
89 proxy: Option<ProxyConfig>,
90}
91
92impl std::fmt::Debug for AzureKvBackend {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 f.debug_struct("AzureKvBackend")
95 .field("init", &self.init.is_ok())
96 .field("proxy", &self.proxy.as_ref().map(|_| "[REDACTED]"))
97 .finish()
98 }
99}
100
101impl AzureKvBackend {
102 const SCHEME: &'static str = "azure-kv";
103 const API_VERSION: &'static str = "7.5";
104 const TOKEN_SCOPE: &'static str = "https://vault.azure.net/.default";
105
106 pub fn new() -> Self {
111 Self::with_proxy(None)
112 }
113
114 pub fn with_proxy(proxy: Option<ProxyConfig>) -> Self {
115 Self {
116 proxy,
117 init: tokio::runtime::Builder::new_current_thread()
118 .enable_io()
119 .enable_time()
120 .build()
121 .map_err(|e| Error::Backend {
122 scheme: Self::SCHEME,
123 kind: BackendFailureKind::Permanent,
124 message: format!("failed to create tokio runtime: {e}"),
125 }),
126 }
127 }
128
129 fn runtime(&self) -> Result<&tokio::runtime::Runtime, Error> {
130 self.init.as_ref().map_err(|e| e.clone())
131 }
132
133 fn block_on<F>(&self, future: F) -> Result<F::Output, Error>
134 where
135 F: std::future::Future,
136 {
137 let rt = self.runtime()?;
138 Ok(rt.block_on(future))
139 }
140
141 fn token(&self) -> Result<String, Error> {
143 self.block_on(async {
144 let credential = azure_identity::create_credential().map_err(|e| {
145 let msg = e.to_string();
146 if msg.to_lowercase().contains("credential") {
147 Error::AuthenticationFailed(format!(
148 "no ambient Azure credentials; set AZURE_CLIENT_ID/SECRET/TENANT_ID or log in with Azure CLI: {msg}"
149 ))
150 } else {
151 Error::Backend {
152 scheme: Self::SCHEME,
153 kind: BackendFailureKind::Permanent,
154 message: format!("failed to discover Azure credentials: {msg}"),
155 }
156 }
157 })?;
158
159 let access_token = credential
160 .get_token(&[Self::TOKEN_SCOPE])
161 .await
162 .map_err(|e| Error::AuthenticationFailed(format!(
163 "failed to acquire Azure access token: {e}"
164 )))?;
165
166 let bearer = access_token.token.secret().to_string();
167 Ok(bearer)
168 })?
169 }
170
171 fn client(&self) -> reqwest::blocking::Client {
173 let mut builder = reqwest::blocking::Client::builder().timeout(Duration::from_secs(10));
174
175 if let Some(p) = &self.proxy {
176 let proxy = reqwest::Proxy::all(p.url_without_credentials())
177 .expect("reqwest proxy construction is infallible with a valid URL");
178 builder = builder.proxy(proxy);
179 }
180
181 builder
182 .build()
183 .expect("reqwest client construction is infallible with default features")
184 }
185
186 fn build_url(&self, url: &AzureKvUrl) -> String {
188 let version_path = match &url.version {
189 Some(v) if !v.is_empty() => format!("/{v}"),
190 _ => String::new(),
191 };
192 format!(
193 "https://{}.vault.azure.net/secrets/{}{version_path}?api-version={}",
194 url.vault_name,
195 url.secret_name,
196 Self::API_VERSION,
197 )
198 }
199
200 fn ensure_secret_name(url: &AzureKvUrl) -> Result<(), Error> {
202 if url.secret_name.is_empty() {
203 return Err(Error::InvalidUrl(
204 "azure-kv:// secret name must not be empty".into(),
205 ));
206 }
207 Ok(())
208 }
209
210 fn build_list_url(&self, vault_name: &str) -> String {
212 format!(
213 "https://{vault_name}.vault.azure.net/secrets?api-version={}",
214 Self::API_VERSION,
215 )
216 }
217}
218
219impl Default for AzureKvBackend {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225impl Backend for AzureKvBackend {
226 fn scheme(&self) -> &'static str {
227 Self::SCHEME
228 }
229
230 fn validate(&self, url: &Url) -> Result<(), Error> {
231 AzureKvUrl::try_from(url).map(|_| ())
232 }
233
234 fn get(&self, url: &Url) -> Result<SecretString, Error> {
235 let kv_url = AzureKvUrl::try_from(url)?;
236 Self::ensure_secret_name(&kv_url)?;
237 let token = self.token()?;
238 let request_url = self.build_url(&kv_url);
239
240 let client = self.client();
241 let response = client
242 .get(&request_url)
243 .bearer_auth(&token)
244 .send()
245 .map_err(map_reqwest_error)?;
246
247 let status = response.status();
248 if !status.is_success() {
249 return Err(map_http_status(status, url));
250 }
251
252 let payload: SecretResponse = response.json().map_err(|e| Error::Backend {
253 scheme: Self::SCHEME,
254 kind: BackendFailureKind::Permanent,
255 message: format!("invalid JSON from Azure Key Vault: {e}"),
256 })?;
257
258 let value = payload.value.ok_or_else(|| Error::Backend {
259 scheme: Self::SCHEME,
260 kind: BackendFailureKind::Permanent,
261 message: "Azure Key Vault returned a secret without a value field".into(),
262 })?;
263
264 let value = match &kv_url.field {
268 Some(path) => hasp_core::extract_field_from_str(&value, path)?,
269 None => value,
270 };
271 Ok(SecretString::new(value.into()))
272 }
273
274 fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
275 let kv_url = AzureKvUrl::try_from(url)?;
276 Self::ensure_secret_name(&kv_url)?;
277 let token = self.token()?;
278 let request_url = self.build_url(&kv_url);
279
280 let body = serde_json::json!({ "value": value.expose_secret() });
281
282 let client = self.client();
283 let response = client
284 .put(&request_url)
285 .bearer_auth(&token)
286 .json(&body)
287 .send()
288 .map_err(map_reqwest_error)?;
289
290 let status = response.status();
291 if !status.is_success() {
292 return Err(map_http_status(status, url));
293 }
294
295 Ok(())
296 }
297
298 fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
299 let kv_url = AzureKvUrl::try_from(url)?;
300 let token = self.token()?;
301 let mut request_url = self.build_list_url(&kv_url.vault_name);
302
303 let client = self.client();
304 let mut entries = Vec::new();
305 const MAX_PAGES: usize = 500;
306
307 for _ in 0..MAX_PAGES {
308 let response = client
309 .get(&request_url)
310 .bearer_auth(&token)
311 .send()
312 .map_err(map_reqwest_error)?;
313
314 let status = response.status();
315 if !status.is_success() {
316 return Err(map_http_status(status, url));
317 }
318
319 let payload: SecretListResponse = response.json().map_err(|e| Error::Backend {
320 scheme: Self::SCHEME,
321 kind: BackendFailureKind::Permanent,
322 message: format!("invalid JSON from Azure Key Vault list: {e}"),
323 })?;
324
325 for item in payload.value.into_iter().flatten() {
329 let segments: Vec<_> = item.id.rsplit('/').collect();
330 let name = if segments.len() >= 2 {
331 segments[1].to_owned()
332 } else {
333 item.id.rsplit('/').next().unwrap_or(&item.id).to_owned()
334 };
335 let entry_url = Url::parse(&format!("azure-kv://{}/{name}", kv_url.vault_name))
336 .map_err(|e| Error::Backend {
337 scheme: Self::SCHEME,
338 kind: BackendFailureKind::Permanent,
339 message: format!("failed to build list entry URL: {e}"),
340 })?;
341 entries.push(Entry {
342 name: name.clone(),
343 url: entry_url,
344 });
345 }
346
347 match payload.next_link {
348 Some(link) if !link.is_empty() => {
349 request_url = link;
350 }
351 _ => break,
352 }
353 }
354
355 Ok(entries)
356 }
357
358 fn delete(&self, url: &Url) -> Result<(), Error> {
359 let kv_url = AzureKvUrl::try_from(url)?;
360 Self::ensure_secret_name(&kv_url)?;
361 let token = self.token()?;
362 let request_url = self.build_url(&kv_url);
363
364 let client = self.client();
365 let response = client
366 .delete(&request_url)
367 .bearer_auth(&token)
368 .send()
369 .map_err(map_reqwest_error)?;
370
371 match response.status() {
372 reqwest::StatusCode::ACCEPTED | reqwest::StatusCode::NO_CONTENT => Ok(()),
373 status => Err(map_http_status(status, url)),
374 }
375 }
376
377 fn exists(&self, url: &Url) -> Result<bool, Error> {
378 let kv_url = AzureKvUrl::try_from(url)?;
379 Self::ensure_secret_name(&kv_url)?;
380 let token = self.token()?;
381 let request_url = self.build_url(&kv_url);
382
383 let client = self.client();
384 let response = client
385 .get(&request_url)
386 .bearer_auth(&token)
387 .send()
388 .map_err(map_reqwest_error)?;
389
390 match response.status() {
391 reqwest::StatusCode::OK => Ok(true),
392 reqwest::StatusCode::NOT_FOUND => Ok(false),
393 status => Err(map_http_status(status, url)),
394 }
395 }
396}
397
398#[derive(Debug, Deserialize)]
402struct SecretResponse {
403 value: Option<String>,
404}
405
406#[derive(Debug, Deserialize)]
411struct SecretListResponse {
412 #[serde(default)]
413 value: Option<Vec<SecretListItem>>,
414 #[serde(rename = "nextLink")]
415 next_link: Option<String>,
416}
417
418#[derive(Debug, Deserialize)]
422struct SecretListItem {
423 id: String,
424}
425
426fn map_reqwest_error(err: reqwest::Error) -> Error {
428 let kind = if err.is_timeout() || err.is_connect() {
429 BackendFailureKind::Transient
430 } else {
431 BackendFailureKind::Permanent
432 };
433 Error::Backend {
434 scheme: "azure-kv",
435 kind,
436 message: format!("Azure Key Vault request failed: {err}"),
437 }
438}
439
440fn map_http_status(status: reqwest::StatusCode, url: &Url) -> Error {
447 match status {
448 reqwest::StatusCode::NOT_FOUND => Error::NotFound(url.to_string()),
449 reqwest::StatusCode::FORBIDDEN => {
450 Error::PermissionDenied(format!("azure-kv:// permission denied for {url}"))
451 }
452 reqwest::StatusCode::UNAUTHORIZED => {
453 Error::AuthenticationFailed(format!("azure-kv:// authentication failed for {url}"))
454 }
455 reqwest::StatusCode::TOO_MANY_REQUESTS => Error::Backend {
456 scheme: "azure-kv",
457 kind: BackendFailureKind::Throttled,
458 message: format!("Azure Key Vault throttled the request (HTTP {status})"),
459 },
460 status if status.is_server_error() => Error::Backend {
461 scheme: "azure-kv",
462 kind: BackendFailureKind::Transient,
463 message: format!("Azure Key Vault returned HTTP {status}"),
464 },
465 reqwest::StatusCode::CONFLICT => Error::PreconditionFailed(format!(
466 "azure-kv:// secret is in soft-delete recovery (HTTP {status})"
467 )),
468 status if status.as_u16() == 400 => {
469 Error::InvalidUrl(format!("azure-kv:// invalid request (HTTP {status})"))
470 }
471 _ => Error::Backend {
472 scheme: "azure-kv",
473 kind: BackendFailureKind::Permanent,
474 message: format!("Azure Key Vault returned HTTP {status}"),
475 },
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn parse_valid_url_simple() {
485 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
486 let kv = AzureKvUrl::try_from(&url).unwrap();
487 assert_eq!(kv.vault_name, "my-vault");
488 assert_eq!(kv.secret_name, "my-secret");
489 assert_eq!(kv.version, None);
490 }
491
492 #[test]
493 fn parse_valid_url_with_version() {
494 let url = Url::parse("azure-kv://my-vault/my-secret?version=abc123").unwrap();
495 let kv = AzureKvUrl::try_from(&url).unwrap();
496 assert_eq!(kv.vault_name, "my-vault");
497 assert_eq!(kv.secret_name, "my-secret");
498 assert_eq!(kv.version, Some("abc123".into()));
499 }
500
501 #[test]
502 fn parse_valid_url_with_path() {
503 let url = Url::parse("azure-kv://my-vault/secrets/app/db-password").unwrap();
504 let kv = AzureKvUrl::try_from(&url).unwrap();
505 assert_eq!(kv.vault_name, "my-vault");
506 assert_eq!(kv.secret_name, "secrets/app/db-password");
507 }
508
509 #[test]
510 fn parse_missing_host_fails() {
511 let url = Url::parse("azure-kv:///my-secret").unwrap();
512 assert!(AzureKvUrl::try_from(&url).is_err());
513 }
514
515 #[test]
516 fn empty_secret_name_fails_at_api_boundary() {
517 let backend = AzureKvBackend::new();
518 let url = Url::parse("azure-kv://my-vault/").unwrap();
519 let dummy = SecretString::new("x".into());
520 assert!(
521 matches!(
522 backend.get(&url),
523 Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
524 ),
525 "empty secret name should fail at API boundary"
526 );
527 assert!(
528 matches!(
529 backend.put(&url, &dummy),
530 Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
531 ),
532 "empty secret name should fail at API boundary for put"
533 );
534 assert!(
535 matches!(
536 backend.delete(&url),
537 Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
538 ),
539 "empty secret name should fail at API boundary for delete"
540 );
541 }
542
543 #[test]
544 fn parse_unknown_query_fails() {
545 let url = Url::parse("azure-kv://my-vault/my-secret?raw=true").unwrap();
546 assert!(AzureKvUrl::try_from(&url).is_err());
547 }
548
549 #[test]
550 fn build_url_without_version() {
551 let backend = AzureKvBackend::new();
552 let kv = AzureKvUrl {
553 vault_name: "my-vault".into(),
554 secret_name: "my-secret".into(),
555 version: None,
556 field: None,
557 };
558 let url = backend.build_url(&kv);
559 assert_eq!(
560 url,
561 "https://my-vault.vault.azure.net/secrets/my-secret?api-version=7.5"
562 );
563 }
564
565 #[test]
566 fn build_url_with_version() {
567 let backend = AzureKvBackend::new();
568 let kv = AzureKvUrl {
569 vault_name: "my-vault".into(),
570 secret_name: "my-secret".into(),
571 version: Some("v1".into()),
572 field: None,
573 };
574 let url = backend.build_url(&kv);
575 assert_eq!(
576 url,
577 "https://my-vault.vault.azure.net/secrets/my-secret/v1?api-version=7.5"
578 );
579 }
580
581 #[test]
582 fn parse_valid_url_with_field() {
583 let url = Url::parse("azure-kv://my-vault/my-secret?field=.creds.password").unwrap();
584 let kv = AzureKvUrl::try_from(&url).unwrap();
585 assert_eq!(kv.field, Some(".creds.password".into()));
586 assert_eq!(kv.version, None);
587 }
588
589 #[test]
590 fn error_map_404_to_not_found() {
591 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
592 let err = map_http_status(reqwest::StatusCode::NOT_FOUND, &url);
593 assert!(matches!(err, Error::NotFound(ref s) if s == "azure-kv://my-vault/my-secret"));
594 }
595
596 #[test]
597 fn error_map_403_to_permission_denied() {
598 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
599 let err = map_http_status(reqwest::StatusCode::FORBIDDEN, &url);
600 assert!(matches!(err, Error::PermissionDenied(ref s) if s.contains("permission denied")));
601 }
602
603 #[test]
604 fn error_map_401_to_authentication_failed() {
605 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
606 let err = map_http_status(reqwest::StatusCode::UNAUTHORIZED, &url);
607 assert!(
608 matches!(err, Error::AuthenticationFailed(ref s) if s.contains("authentication failed"))
609 );
610 }
611
612 #[test]
613 fn error_map_429_to_throttled() {
614 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
615 let err = map_http_status(reqwest::StatusCode::TOO_MANY_REQUESTS, &url);
616 assert!(matches!(
617 err,
618 Error::Backend {
619 kind: BackendFailureKind::Throttled,
620 ..
621 }
622 ));
623 }
624
625 #[test]
626 fn error_map_500_to_transient() {
627 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
628 let err = map_http_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &url);
629 assert!(matches!(
630 err,
631 Error::Backend {
632 kind: BackendFailureKind::Transient,
633 ..
634 }
635 ));
636 }
637
638 #[test]
639 fn error_map_418_to_permanent() {
640 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
641 let err = map_http_status(reqwest::StatusCode::IM_A_TEAPOT, &url);
642 assert!(matches!(
643 err,
644 Error::Backend {
645 kind: BackendFailureKind::Permanent,
646 ..
647 }
648 ));
649 }
650
651 #[test]
652 fn supported_operations() {
653 let backend = AzureKvBackend::new();
654 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
655 let dummy = SecretString::new("x".into());
656
657 assert!(
658 matches!(
659 backend.put(&url, &dummy),
660 Err(Error::Backend { .. })
661 | Err(Error::AuthenticationFailed(_))
662 | Err(Error::NotFound(_))
663 ),
664 "put now supported (fails at network layer): {err:?}",
665 err = backend.put(&url, &dummy).unwrap_err()
666 );
667 assert!(
668 matches!(
669 backend.list(&url),
670 Err(Error::Backend { .. })
671 | Err(Error::AuthenticationFailed(_))
672 | Err(Error::NotFound(_))
673 ),
674 "list now supported (fails at network layer): {err:?}",
675 err = backend.list(&url).unwrap_err()
676 );
677 assert!(
678 matches!(
679 backend.delete(&url),
680 Err(Error::Backend { .. })
681 | Err(Error::AuthenticationFailed(_))
682 | Err(Error::NotFound(_))
683 ),
684 "delete now supported (fails at network layer): {err:?}",
685 err = backend.delete(&url).unwrap_err()
686 );
687 }
688
689 #[test]
690 fn error_map_409_to_precondition_failed() {
691 let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
692 let err = map_http_status(reqwest::StatusCode::CONFLICT, &url);
693 assert!(
694 matches!(err, Error::PreconditionFailed(ref s) if s.contains("soft-delete")),
695 "got: {err:?}"
696 );
697 }
698
699 #[test]
700 fn list_parsing_from_json() {
701 let payload: SecretListResponse = serde_json::from_str(
702 r#"{"value":[{"id":"https://my-vault.vault.azure.net/secrets/secret-a/abc123"},{"id":"https://my-vault.vault.azure.net/secrets/secret-b/def456"}]}"#
703 ).unwrap();
704
705 let items = payload.value.unwrap();
706 assert_eq!(items.len(), 2);
707 let segments: Vec<_> = items[0].id.rsplit('/').collect();
708 assert_eq!(segments[1], "secret-a");
709 let segments: Vec<_> = items[1].id.rsplit('/').collect();
710 assert_eq!(segments[1], "secret-b");
711 }
712
713 #[test]
714 fn list_parsing_with_next_link() {
715 let payload: SecretListResponse = serde_json::from_str(
716 r#"{"value":[{"id":"https://my-vault.vault.azure.net/secrets/secret-a/abc123"}],"nextLink":"https://my-vault.vault.azure.net/secrets?api-version=7.5&$skiptoken=abc"}"#
717 ).unwrap();
718
719 let items = payload.value.unwrap();
720 assert_eq!(items.len(), 1);
721 assert_eq!(
722 payload.next_link.unwrap(),
723 "https://my-vault.vault.azure.net/secrets?api-version=7.5&$skiptoken=abc"
724 );
725 }
726
727 #[test]
728 fn list_parsing_empty() {
729 let payload: SecretListResponse = serde_json::from_str(r#"{"value":[]}"#).unwrap();
730 assert_eq!(payload.value.unwrap().len(), 0);
731 }
732
733 #[test]
734 fn backend_scheme() {
735 let backend = AzureKvBackend::new();
736 assert_eq!(backend.scheme(), "azure-kv");
737 }
738
739 #[test]
744 fn field_extraction_happy() {
745 let payload = r#"{"username":"app","password":"hunter2"}"#;
746 let v = hasp_core::extract_field_from_str(payload, "password").unwrap();
747 assert_eq!(v, "hunter2");
748 }
749
750 #[test]
751 fn field_extraction_missing_field_is_not_found() {
752 let payload = r#"{"username":"app"}"#;
753 let err = hasp_core::extract_field_from_str(payload, "password").unwrap_err();
754 assert!(matches!(err, Error::NotFound(_)));
755 }
756
757 #[test]
758 fn field_extraction_non_json_is_invalid_url() {
759 let payload = "plain-string-not-json";
760 let err = hasp_core::extract_field_from_str(payload, "password").unwrap_err();
761 assert!(matches!(err, Error::InvalidUrl(_)));
762 }
763}