1use std::collections::HashMap;
91use std::sync::Mutex;
92use std::time::Duration;
93
94use async_trait::async_trait;
95use cellos_core::ports::SecretBroker;
96use cellos_core::{CellosError, RuntimeSecretLeaseRequest, SecretView};
97use serde::Deserialize;
98use std::fmt;
99use tracing::instrument;
100use zeroize::{Zeroize, ZeroizeOnDrop};
101
102pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 15_000;
109
110pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
112
113pub const ENV_REQUEST_TIMEOUT_MS: &str = "CELLOS_VAULT_TIMEOUT_MS";
115
116pub const ENV_CONNECT_TIMEOUT_MS: &str = "CELLOS_VAULT_CONNECT_TIMEOUT_MS";
118
119pub fn resolve_timeout_ms(env_var: &str, default_ms: u64) -> u64 {
125 match std::env::var(env_var) {
126 Ok(raw) => raw
127 .trim()
128 .parse::<u64>()
129 .ok()
130 .filter(|v| *v > 0)
131 .unwrap_or(default_ms),
132 Err(_) => default_ms,
133 }
134}
135
136#[derive(ZeroizeOnDrop)]
149pub struct VaultAppRoleBroker {
150 #[zeroize(skip)]
151 client: reqwest::Client,
152 #[zeroize(skip)]
154 addr: String,
155 #[zeroize(skip)]
157 role_id: String,
158 secret_id: String,
160 #[zeroize(skip)]
161 kv_mount: String,
162 #[zeroize(skip)]
163 kv_path_prefix: Option<String>,
164 #[zeroize(skip)]
165 namespace: Option<String>,
166 #[zeroize(skip)]
169 runtime_leases: Mutex<HashMap<String, RuntimeVaultLease>>,
170}
171
172impl fmt::Debug for VaultAppRoleBroker {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.debug_struct("VaultAppRoleBroker")
175 .field("addr", &self.addr)
176 .field("role_id", &"<redacted>")
177 .field("secret_id", &"<redacted>")
178 .field("kv_mount", &self.kv_mount)
179 .field("kv_path_prefix", &self.kv_path_prefix)
180 .field("namespace", &self.namespace)
181 .field(
184 "runtime_leases",
185 &format_args!(
186 "<{} cell(s)>",
187 self.runtime_leases
188 .lock()
189 .map(|g| g.len())
190 .unwrap_or_else(|e| e.into_inner().len())
191 ),
192 )
193 .finish()
194 }
195}
196
197#[derive(ZeroizeOnDrop)]
198struct RuntimeVaultLease {
199 token: String,
201}
202
203impl fmt::Debug for RuntimeVaultLease {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 f.debug_struct("RuntimeVaultLease")
206 .field("token", &"<REDACTED>")
207 .finish()
208 }
209}
210
211impl RuntimeVaultLease {
212 fn zeroize(&mut self) {
215 self.token.zeroize();
216 }
217}
218
219#[derive(Deserialize)]
246struct VaultLoginResponse {
247 auth: VaultAuth,
248}
249
250#[derive(Deserialize)]
251struct VaultAuth {
252 client_token: String,
253}
254
255impl std::fmt::Debug for VaultLoginResponse {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 f.debug_struct("VaultLoginResponse")
258 .field("auth", &self.auth)
259 .finish()
260 }
261}
262
263impl std::fmt::Debug for VaultAuth {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 f.debug_struct("VaultAuth")
266 .field("client_token", &"<redacted>")
267 .finish()
268 }
269}
270
271#[derive(Deserialize)]
272struct VaultKvResponse {
273 data: VaultKvDataWrapper,
274}
275
276#[derive(Deserialize)]
277struct VaultKvDataWrapper {
278 data: serde_json::Map<String, serde_json::Value>,
279}
280
281fn http_client_builder() -> Result<reqwest::ClientBuilder, String> {
287 let request_timeout = Duration::from_millis(resolve_timeout_ms(
288 ENV_REQUEST_TIMEOUT_MS,
289 DEFAULT_REQUEST_TIMEOUT_MS,
290 ));
291 let connect_timeout = Duration::from_millis(resolve_timeout_ms(
292 ENV_CONNECT_TIMEOUT_MS,
293 DEFAULT_CONNECT_TIMEOUT_MS,
294 ));
295 let mut builder = reqwest::Client::builder()
296 .timeout(request_timeout)
297 .connect_timeout(connect_timeout);
298 if let Ok(path) = std::env::var("CELLOS_CA_BUNDLE") {
299 let pem =
300 std::fs::read(&path).map_err(|e| format!("CELLOS_CA_BUNDLE: read {path}: {e}"))?;
301 let mut added = 0usize;
302 for block in pem_cert_blocks(&pem) {
303 let cert = reqwest::Certificate::from_pem(&block)
304 .map_err(|e| format!("CELLOS_CA_BUNDLE: parse cert in {path}: {e}"))?;
305 builder = builder.add_root_certificate(cert);
306 added += 1;
307 }
308 if added == 0 {
309 return Err(format!("CELLOS_CA_BUNDLE: no certificates found in {path}"));
310 }
311 tracing::debug!(path = %path, count = added, "CELLOS_CA_BUNDLE: loaded CA certificates");
312 }
313 Ok(builder)
314}
315
316fn pem_cert_blocks(pem: &[u8]) -> Vec<Vec<u8>> {
317 let text = String::from_utf8_lossy(pem);
318 let mut blocks = Vec::new();
319 let mut current = String::new();
320 let mut in_block = false;
321 for line in text.lines() {
322 if line.starts_with("-----BEGIN ") {
323 in_block = true;
324 current.clear();
325 }
326 if in_block {
327 current.push_str(line);
328 current.push('\n');
329 if line.starts_with("-----END ") {
330 blocks.push(current.as_bytes().to_vec());
331 in_block = false;
332 }
333 }
334 }
335 blocks
336}
337
338impl VaultAppRoleBroker {
341 pub fn from_env() -> Result<Self, CellosError> {
344 let addr = std::env::var("CELLOS_VAULT_ADDR")
345 .map_err(|_| CellosError::SecretBroker("CELLOS_VAULT_ADDR not set".into()))?;
346 let addr = addr.trim().trim_end_matches('/').to_string();
347 if addr.is_empty() {
348 return Err(CellosError::SecretBroker(
349 "CELLOS_VAULT_ADDR is empty after trim".into(),
350 ));
351 }
352 let parsed = reqwest::Url::parse(&addr).map_err(|e| {
353 CellosError::SecretBroker(format!("CELLOS_VAULT_ADDR invalid URL: {e}"))
354 })?;
355 let scheme = parsed.scheme();
356 if scheme != "http" && scheme != "https" {
357 return Err(CellosError::SecretBroker(format!(
358 "CELLOS_VAULT_ADDR scheme must be http or https, got {scheme}"
359 )));
360 }
361
362 let role_id = std::env::var("CELLOS_VAULT_ROLE_ID")
363 .map_err(|_| CellosError::SecretBroker("CELLOS_VAULT_ROLE_ID not set".into()))?;
364 if role_id.trim().is_empty() {
365 return Err(CellosError::SecretBroker(
366 "CELLOS_VAULT_ROLE_ID is empty".into(),
367 ));
368 }
369
370 let secret_id = std::env::var("CELLOS_VAULT_SECRET_ID")
371 .map_err(|_| CellosError::SecretBroker("CELLOS_VAULT_SECRET_ID not set".into()))?;
372 if secret_id.trim().is_empty() {
373 return Err(CellosError::SecretBroker(
374 "CELLOS_VAULT_SECRET_ID is empty".into(),
375 ));
376 }
377
378 let kv_mount =
379 std::env::var("CELLOS_VAULT_KV_MOUNT").unwrap_or_else(|_| "secret".to_string());
380 let kv_mount = kv_mount.trim().trim_matches('/').to_string();
381
382 let kv_path_prefix = std::env::var("CELLOS_VAULT_KV_PATH_PREFIX")
383 .ok()
384 .map(|p| p.trim().trim_matches('/').to_string())
385 .filter(|p| !p.is_empty());
386
387 let namespace = std::env::var("CELLOS_VAULT_NAMESPACE")
388 .ok()
389 .map(|n| n.trim().to_string())
390 .filter(|n| !n.is_empty());
391
392 let client = http_client_builder()
393 .map_err(CellosError::SecretBroker)?
394 .build()
395 .map_err(|e| CellosError::SecretBroker(format!("vault http client init: {e}")))?;
396
397 Ok(Self {
398 client,
399 addr,
400 role_id,
401 secret_id,
402 kv_mount,
403 kv_path_prefix,
404 namespace,
405 runtime_leases: Mutex::new(HashMap::new()),
406 })
407 }
408
409 async fn login(&self) -> Result<String, CellosError> {
411 let url = format!("{}/v1/auth/approle/login", self.addr);
412 let body = serde_json::json!({
413 "role_id": self.role_id,
414 "secret_id": self.secret_id,
415 });
416 let mut req = self.client.post(&url).json(&body);
417 if let Some(ref ns) = self.namespace {
418 req = req.header("X-Vault-Namespace", ns);
419 }
420 let resp = req
421 .send()
422 .await
423 .map_err(|e| CellosError::SecretBroker(format!("vault approle login request: {e}")))?;
424
425 if !resp.status().is_success() {
426 let status = resp.status();
427 let body = resp.text().await.unwrap_or_default();
428 return Err(CellosError::SecretBroker(format!(
429 "vault approle login returned {status}: {body}"
430 )));
431 }
432
433 let mut login: VaultLoginResponse = resp
434 .json()
435 .await
436 .map_err(|e| CellosError::SecretBroker(format!("vault login response parse: {e}")))?;
437
438 let token = std::mem::take(&mut login.auth.client_token);
444 drop(login);
445 tracing::debug!("vault approle login succeeded");
446 Ok(token)
447 }
448
449 fn kv_path(&self, key: &str) -> String {
451 match &self.kv_path_prefix {
452 Some(prefix) => format!("{}/v1/{}/data/{}/{}", self.addr, self.kv_mount, prefix, key),
453 None => format!("{}/v1/{}/data/{}", self.addr, self.kv_mount, key),
454 }
455 }
456
457 async fn fetch_secret(&self, token: &str, key: &str) -> Result<String, CellosError> {
459 let url = self.kv_path(key);
460 let mut req = self.client.get(&url).header("X-Vault-Token", token);
461 if let Some(ref ns) = self.namespace {
462 req = req.header("X-Vault-Namespace", ns);
463 }
464 let resp = req
465 .send()
466 .await
467 .map_err(|e| CellosError::SecretBroker(format!("vault kv read request: {e}")))?;
468
469 if resp.status().as_u16() == 404 {
470 return Err(CellosError::SecretBroker(format!(
471 "vault kv secret not found: {key}"
472 )));
473 }
474 if !resp.status().is_success() {
475 let status = resp.status();
476 let body = resp.text().await.unwrap_or_default();
477 return Err(CellosError::SecretBroker(format!(
478 "vault kv read returned {status}: {body}"
479 )));
480 }
481
482 let kv: VaultKvResponse = resp
483 .json()
484 .await
485 .map_err(|e| CellosError::SecretBroker(format!("vault kv response parse: {e}")))?;
486
487 let value = kv
489 .data
490 .data
491 .get(key)
492 .or_else(|| kv.data.data.values().next())
493 .ok_or_else(|| {
494 CellosError::SecretBroker(format!(
495 "vault kv secret {key:?} has no fields in data.data"
496 ))
497 })?;
498
499 match value {
500 serde_json::Value::String(s) => Ok(s.clone()),
501 other => Ok(other.to_string()),
502 }
503 }
504
505 async fn revoke_token(&self, token: &str) -> Result<(), CellosError> {
506 let url = format!("{}/v1/auth/token/revoke-self", self.addr);
507 let mut req = self.client.post(&url).header("X-Vault-Token", token);
508 if let Some(ref ns) = self.namespace {
509 req = req.header("X-Vault-Namespace", ns);
510 }
511 let resp = req.send().await.map_err(|e| {
512 CellosError::SecretBroker(format!("vault revoke-self request failed: {e}"))
513 })?;
514
515 if !resp.status().is_success() {
516 let status = resp.status();
517 let body = resp.text().await.unwrap_or_default();
518 return Err(CellosError::SecretBroker(format!(
519 "vault revoke-self returned {status}: {body}"
520 )));
521 }
522
523 Ok(())
524 }
525
526 fn take_runtime_lease(&self, cell_id: &str) -> Option<RuntimeVaultLease> {
527 self.runtime_leases
528 .lock()
529 .unwrap_or_else(|e| e.into_inner())
530 .remove(cell_id)
531 }
532
533 fn insert_runtime_lease(&self, cell_id: &str, lease: RuntimeVaultLease) {
534 if let Some(mut previous) = self
535 .runtime_leases
536 .lock()
537 .unwrap_or_else(|e| e.into_inner())
538 .insert(cell_id.to_string(), lease)
539 {
540 previous.zeroize();
541 }
542 }
543
544 pub fn has_runtime_lease(&self, cell_id: &str) -> bool {
548 self.runtime_leases
549 .lock()
550 .unwrap_or_else(|e| e.into_inner())
551 .contains_key(cell_id)
552 }
553
554 pub fn runtime_lease_count(&self) -> usize {
558 self.runtime_leases
559 .lock()
560 .unwrap_or_else(|e| e.into_inner())
561 .len()
562 }
563}
564
565#[async_trait]
566impl SecretBroker for VaultAppRoleBroker {
567 #[instrument(skip(self), fields(key = %key, cell_id = %cell_id))]
571 async fn resolve(
572 &self,
573 key: &str,
574 cell_id: &str,
575 _ttl_seconds: u64,
576 ) -> Result<SecretView, CellosError> {
577 tracing::debug!(key = %key, cell_id = %cell_id, "resolving vault secret");
578 let token = zeroize::Zeroizing::new(self.login().await?);
582 let value = self.fetch_secret(token.as_str(), key).await?;
583 tracing::info!(key = %key, cell_id = %cell_id, "vault secret resolved");
584 Ok(SecretView {
585 key: key.to_string(),
586 value: zeroize::Zeroizing::new(value),
587 })
588 }
589
590 async fn prepare_runtime_secret_lease(
591 &self,
592 cell_id: &str,
593 requests: &[RuntimeSecretLeaseRequest],
594 ) -> Result<(), CellosError> {
595 if requests.is_empty() {
596 return Ok(());
597 }
598
599 if let Some(mut previous) = self.take_runtime_lease(cell_id) {
600 let revoke_result = self.revoke_token(&previous.token).await;
601 previous.zeroize();
602 revoke_result?;
603 }
604
605 let mut token = self.login().await?;
613
614 for req in requests {
620 if let Err(e) = self.fetch_secret(&token, &req.key).await {
621 if let Err(revoke_err) = self.revoke_token(&token).await {
625 tracing::warn!(
626 cell_id = %cell_id,
627 revoke_error = %revoke_err,
628 "failed to revoke partial Vault lease after prepare error; \
629 upstream may rely on TTL"
630 );
631 }
632 tracing::warn!(
633 cell_id = %cell_id,
634 key = %req.key,
635 error = %e,
636 "Vault prepare aborted; partial lease revoked (E2-03)"
637 );
638 token.zeroize();
639 return Err(e);
640 }
641 }
642
643 self.insert_runtime_lease(cell_id, RuntimeVaultLease { token });
644 tracing::info!(
645 cell_id = %cell_id,
646 secret_count = requests.len(),
647 "prepared Vault runtime secret lease"
648 );
649 Ok(())
650 }
651
652 async fn fetch_runtime_secret(
653 &self,
654 key: &str,
655 cell_id: &str,
656 _ttl_seconds: u64,
657 ) -> Result<SecretView, CellosError> {
658 let mut token = self
659 .runtime_leases
660 .lock()
661 .unwrap_or_else(|e| e.into_inner())
662 .get(cell_id)
663 .map(|lease| lease.token.clone())
664 .ok_or_else(|| {
665 CellosError::SecretBroker(format!(
666 "no prepared Vault runtime lease for cell {cell_id:?}"
667 ))
668 })?;
669
670 let result = self
671 .fetch_secret(&token, key)
672 .await
673 .map(|value| SecretView {
674 key: key.to_string(),
675 value: zeroize::Zeroizing::new(value),
676 });
677 token.zeroize();
678 result
679 }
680
681 async fn revoke_for_cell(&self, cell_id: &str) -> Result<(), CellosError> {
682 let Some(mut lease) = self.take_runtime_lease(cell_id) else {
683 return Ok(());
684 };
685
686 let revoke_result = self.revoke_token(&lease.token).await;
687 lease.zeroize();
688 revoke_result
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use std::io::{BufRead, BufReader, Read, Write};
696 use std::net::{TcpListener, TcpStream};
697 use std::thread;
698 use std::time::{Duration, Instant};
699
700 #[derive(Debug)]
701 struct CapturedRequest {
702 method: String,
703 target: String,
704 token: Option<String>,
705 body: String,
706 }
707
708 fn read_request(stream: &mut TcpStream) -> CapturedRequest {
709 let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
710 let mut request_line = String::new();
711 reader
712 .read_line(&mut request_line)
713 .expect("read request line");
714 assert!(!request_line.trim().is_empty(), "expected request line");
715
716 let mut content_length = 0usize;
717 let mut token = None;
718 loop {
719 let mut line = String::new();
720 reader.read_line(&mut line).expect("read header");
721 if line == "\r\n" || line.is_empty() {
722 break;
723 }
724 if let Some((name, value)) = line.split_once(':') {
725 let name = name.trim().to_ascii_lowercase();
726 let value = value.trim().to_string();
727 if name == "content-length" {
728 content_length = value.parse::<usize>().expect("parse content-length");
729 } else if name == "x-vault-token" {
730 token = Some(value);
731 }
732 }
733 }
734
735 let mut body = vec![0u8; content_length];
736 reader.read_exact(&mut body).expect("read request body");
737
738 let mut parts = request_line.split_whitespace();
739 let method = parts.next().expect("method").to_string();
740 let target = parts.next().expect("target").to_string();
741 CapturedRequest {
742 method,
743 target,
744 token,
745 body: String::from_utf8(body).expect("utf8 request body"),
746 }
747 }
748
749 fn write_response(stream: &mut TcpStream, status_line: &str, body: &str, content_type: &str) {
750 write!(
751 stream,
752 "HTTP/1.1 {status_line}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
753 body.len()
754 )
755 .expect("write response");
756 stream.flush().expect("flush response");
757 }
758
759 fn start_mock_vault(
760 expected_requests: usize,
761 ) -> (String, thread::JoinHandle<Vec<CapturedRequest>>) {
762 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock vault");
763 listener
764 .set_nonblocking(true)
765 .expect("set mock vault nonblocking");
766 let addr = listener.local_addr().expect("mock vault addr");
767 let handle = thread::spawn(move || {
768 let deadline = Instant::now() + Duration::from_secs(10);
769 let mut requests = Vec::new();
770 while requests.len() < expected_requests && Instant::now() < deadline {
771 match listener.accept() {
772 Ok((mut stream, _)) => {
773 stream
774 .set_nonblocking(false)
775 .expect("set accepted stream blocking");
776 let request = read_request(&mut stream);
777 match (request.method.as_str(), request.target.as_str()) {
778 ("POST", "/v1/auth/approle/login") => write_response(
779 &mut stream,
780 "200 OK",
781 r#"{"auth":{"client_token":"vault-token"}}"#,
782 "application/json",
783 ),
784 ("GET", "/v1/secret/data/API_TOKEN") => write_response(
785 &mut stream,
786 "200 OK",
787 r#"{"data":{"data":{"API_TOKEN":"leased-secret"}}}"#,
788 "application/json",
789 ),
790 ("POST", "/v1/auth/token/revoke-self") => {
791 write_response(&mut stream, "204 No Content", "", "text/plain")
792 }
793 _ => write_response(
794 &mut stream,
795 "404 Not Found",
796 "unexpected request",
797 "text/plain",
798 ),
799 }
800 requests.push(request);
801 }
802 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
803 thread::sleep(Duration::from_millis(20));
804 }
805 Err(err) => panic!("mock vault accept failed: {err}"),
806 }
807 }
808 requests
809 });
810
811 (format!("http://{addr}"), handle)
812 }
813
814 fn set_required_env() {
815 std::env::set_var("CELLOS_VAULT_ADDR", "https://vault.example.com");
816 std::env::set_var("CELLOS_VAULT_ROLE_ID", "test-role-id");
817 std::env::set_var("CELLOS_VAULT_SECRET_ID", "test-secret-id");
818 std::env::remove_var("CELLOS_VAULT_KV_MOUNT");
819 std::env::remove_var("CELLOS_VAULT_KV_PATH_PREFIX");
820 std::env::remove_var("CELLOS_VAULT_NAMESPACE");
821 std::env::remove_var("CELLOS_CA_BUNDLE");
822 }
823
824 fn clear_required_env() {
825 std::env::remove_var("CELLOS_VAULT_ADDR");
826 std::env::remove_var("CELLOS_VAULT_ROLE_ID");
827 std::env::remove_var("CELLOS_VAULT_SECRET_ID");
828 }
829
830 use std::sync::Mutex;
831 static ENV_LOCK: Mutex<()> = Mutex::new(());
832
833 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
835 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
836 }
837
838 #[test]
839 fn constructs_with_required_env() {
840 let _g = env_lock();
841 set_required_env();
842 let broker = VaultAppRoleBroker::from_env();
843 clear_required_env();
844 assert!(broker.is_ok());
845 }
846
847 #[test]
848 fn fails_when_addr_missing() {
849 let _g = env_lock();
850 set_required_env();
851 std::env::remove_var("CELLOS_VAULT_ADDR");
852 let err = VaultAppRoleBroker::from_env().unwrap_err();
853 clear_required_env();
854 assert!(err.to_string().contains("CELLOS_VAULT_ADDR"), "got: {err}");
855 }
856
857 #[test]
858 fn fails_when_role_id_missing() {
859 let _g = env_lock();
860 set_required_env();
861 std::env::remove_var("CELLOS_VAULT_ROLE_ID");
862 let err = VaultAppRoleBroker::from_env().unwrap_err();
863 clear_required_env();
864 assert!(
865 err.to_string().contains("CELLOS_VAULT_ROLE_ID"),
866 "got: {err}"
867 );
868 }
869
870 #[test]
871 fn fails_when_secret_id_missing() {
872 let _g = env_lock();
873 set_required_env();
874 std::env::remove_var("CELLOS_VAULT_SECRET_ID");
875 let err = VaultAppRoleBroker::from_env().unwrap_err();
876 clear_required_env();
877 assert!(
878 err.to_string().contains("CELLOS_VAULT_SECRET_ID"),
879 "got: {err}"
880 );
881 }
882
883 #[test]
884 fn fails_when_addr_not_http() {
885 let _g = env_lock();
886 set_required_env();
887 std::env::set_var("CELLOS_VAULT_ADDR", "grpc://vault.example.com");
888 let err = VaultAppRoleBroker::from_env().unwrap_err();
889 clear_required_env();
890 assert!(err.to_string().contains("http or https"), "got: {err}");
891 }
892
893 #[test]
894 fn uses_default_kv_mount() {
895 let _g = env_lock();
896 set_required_env();
897 let broker = VaultAppRoleBroker::from_env().unwrap();
898 clear_required_env();
899 assert_eq!(broker.kv_mount, "secret");
900 }
901
902 #[test]
903 fn custom_kv_mount_and_prefix() {
904 let _g = env_lock();
905 set_required_env();
906 std::env::set_var("CELLOS_VAULT_KV_MOUNT", "kv");
907 std::env::set_var("CELLOS_VAULT_KV_PATH_PREFIX", "cellos/prod");
908 let broker = VaultAppRoleBroker::from_env().unwrap();
909 clear_required_env();
910 std::env::remove_var("CELLOS_VAULT_KV_MOUNT");
911 std::env::remove_var("CELLOS_VAULT_KV_PATH_PREFIX");
912 assert_eq!(broker.kv_mount, "kv");
913 assert_eq!(broker.kv_path_prefix.as_deref(), Some("cellos/prod"));
914 }
915
916 #[test]
917 fn kv_path_without_prefix() {
918 let _g = env_lock();
919 set_required_env();
920 let broker = VaultAppRoleBroker::from_env().unwrap();
921 clear_required_env();
922 assert_eq!(
923 broker.kv_path("DB_PASSWORD"),
924 "https://vault.example.com/v1/secret/data/DB_PASSWORD"
925 );
926 }
927
928 #[test]
929 fn kv_path_with_prefix() {
930 let _g = env_lock();
931 set_required_env();
932 std::env::set_var("CELLOS_VAULT_KV_MOUNT", "kv");
933 std::env::set_var("CELLOS_VAULT_KV_PATH_PREFIX", "cellos/prod");
934 let broker = VaultAppRoleBroker::from_env().unwrap();
935 std::env::remove_var("CELLOS_VAULT_KV_MOUNT");
936 std::env::remove_var("CELLOS_VAULT_KV_PATH_PREFIX");
937 clear_required_env();
938 assert_eq!(
939 broker.kv_path("DB_PASSWORD"),
940 "https://vault.example.com/v1/kv/data/cellos/prod/DB_PASSWORD"
941 );
942 }
943
944 #[tokio::test]
945 async fn resolve_fails_without_vault_running() {
946 let broker = {
947 let _g = env_lock();
948 set_required_env();
949 std::env::set_var("CELLOS_VAULT_ADDR", "http://127.0.0.1:19999");
951 let broker = VaultAppRoleBroker::from_env().unwrap();
952 clear_required_env();
953 broker
954 };
955 let err = broker.resolve("ANY_KEY", "cell-1", 60).await.unwrap_err();
956 assert!(
957 err.to_string().contains("vault approle login"),
958 "got: {err}"
959 );
960 }
961
962 #[tokio::test]
963 async fn runtime_leased_prepare_fetches_and_revokes_token() {
964 let (addr, server) = start_mock_vault(4);
968 let broker = {
969 let _g = env_lock();
970 set_required_env();
971 std::env::set_var("CELLOS_VAULT_ADDR", addr);
972 let broker = VaultAppRoleBroker::from_env().unwrap();
973 clear_required_env();
974 broker
975 };
976
977 broker
978 .prepare_runtime_secret_lease(
979 "cell-1",
980 &[RuntimeSecretLeaseRequest {
981 key: "API_TOKEN".into(),
982 ttl_seconds: 60,
983 }],
984 )
985 .await
986 .unwrap();
987
988 let view = broker
989 .fetch_runtime_secret("API_TOKEN", "cell-1", 60)
990 .await
991 .unwrap();
992 assert_eq!(view.key, "API_TOKEN");
993 assert_eq!(view.value.as_str(), "leased-secret");
994
995 broker.revoke_for_cell("cell-1").await.unwrap();
996 let requests = server.join().expect("join mock vault");
997 assert_eq!(requests.len(), 4);
998 assert_eq!(requests[0].method, "POST");
999 assert_eq!(requests[0].target, "/v1/auth/approle/login");
1000 assert!(requests[0].body.contains("\"role_id\":\"test-role-id\""));
1001 assert!(requests[0]
1002 .body
1003 .contains("\"secret_id\":\"test-secret-id\""));
1004 assert_eq!(requests[1].method, "GET");
1006 assert_eq!(requests[1].target, "/v1/secret/data/API_TOKEN");
1007 assert_eq!(requests[1].token.as_deref(), Some("vault-token"));
1008 assert_eq!(requests[2].method, "GET");
1010 assert_eq!(requests[2].target, "/v1/secret/data/API_TOKEN");
1011 assert_eq!(requests[2].token.as_deref(), Some("vault-token"));
1012 assert_eq!(requests[3].method, "POST");
1013 assert_eq!(requests[3].target, "/v1/auth/token/revoke-self");
1014 assert_eq!(requests[3].token.as_deref(), Some("vault-token"));
1015 }
1016
1017 #[tokio::test]
1018 async fn runtime_leased_fetch_requires_prepared_lease() {
1019 let broker = {
1020 let _g = env_lock();
1021 set_required_env();
1022 let broker = VaultAppRoleBroker::from_env().unwrap();
1023 clear_required_env();
1024 broker
1025 };
1026 let err = broker
1027 .fetch_runtime_secret("API_TOKEN", "missing-cell", 60)
1028 .await
1029 .unwrap_err();
1030 assert!(
1031 err.to_string().contains("no prepared Vault runtime lease"),
1032 "got: {err}"
1033 );
1034 }
1035
1036 #[tokio::test]
1037 async fn revoke_without_prepared_lease_is_ok() {
1038 let broker = {
1039 let _g = env_lock();
1040 set_required_env();
1041 let broker = VaultAppRoleBroker::from_env().unwrap();
1042 clear_required_env();
1043 broker
1044 };
1045 broker.revoke_for_cell("any-cell").await.unwrap();
1046 }
1047
1048 #[test]
1052 fn vault_login_response_debug_redacts_client_token() {
1053 let response = VaultLoginResponse {
1054 auth: VaultAuth {
1055 client_token: "VAULT-CT-ZERO-INLINE-SENTINEL".to_string(),
1056 },
1057 };
1058 let dbg = format!("{response:?}");
1059 assert!(
1060 !dbg.contains("VAULT-CT-ZERO-INLINE-SENTINEL"),
1061 "VaultLoginResponse Debug leaked client_token: {dbg}"
1062 );
1063 assert!(
1064 dbg.contains("<redacted>"),
1065 "VaultLoginResponse Debug should mark client_token as redacted: {dbg}"
1066 );
1067
1068 let auth_dbg = format!("{:?}", response.auth);
1069 assert!(
1070 !auth_dbg.contains("VAULT-CT-ZERO-INLINE-SENTINEL"),
1071 "VaultAuth Debug leaked client_token: {auth_dbg}"
1072 );
1073 }
1074}