use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use cellos_core::ports::SecretBroker;
use cellos_core::{CellosError, RuntimeSecretLeaseRequest, SecretView};
use serde::Deserialize;
use std::fmt;
use tracing::instrument;
use zeroize::{Zeroize, ZeroizeOnDrop};
pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 15_000;
pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
pub const ENV_REQUEST_TIMEOUT_MS: &str = "CELLOS_VAULT_TIMEOUT_MS";
pub const ENV_CONNECT_TIMEOUT_MS: &str = "CELLOS_VAULT_CONNECT_TIMEOUT_MS";
pub fn resolve_timeout_ms(env_var: &str, default_ms: u64) -> u64 {
match std::env::var(env_var) {
Ok(raw) => raw
.trim()
.parse::<u64>()
.ok()
.filter(|v| *v > 0)
.unwrap_or(default_ms),
Err(_) => default_ms,
}
}
#[derive(ZeroizeOnDrop)]
pub struct VaultAppRoleBroker {
#[zeroize(skip)]
client: reqwest::Client,
#[zeroize(skip)]
addr: String,
#[zeroize(skip)]
role_id: String,
secret_id: String,
#[zeroize(skip)]
kv_mount: String,
#[zeroize(skip)]
kv_path_prefix: Option<String>,
#[zeroize(skip)]
namespace: Option<String>,
#[zeroize(skip)]
runtime_leases: Mutex<HashMap<String, RuntimeVaultLease>>,
}
impl fmt::Debug for VaultAppRoleBroker {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VaultAppRoleBroker")
.field("addr", &self.addr)
.field("role_id", &"<redacted>")
.field("secret_id", &"<redacted>")
.field("kv_mount", &self.kv_mount)
.field("kv_path_prefix", &self.kv_path_prefix)
.field("namespace", &self.namespace)
.field(
"runtime_leases",
&format_args!(
"<{} cell(s)>",
self.runtime_leases
.lock()
.map(|g| g.len())
.unwrap_or_else(|e| e.into_inner().len())
),
)
.finish()
}
}
#[derive(ZeroizeOnDrop)]
struct RuntimeVaultLease {
token: String,
}
impl fmt::Debug for RuntimeVaultLease {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RuntimeVaultLease")
.field("token", &"<REDACTED>")
.finish()
}
}
impl RuntimeVaultLease {
fn zeroize(&mut self) {
self.token.zeroize();
}
}
#[derive(Deserialize)]
struct VaultLoginResponse {
auth: VaultAuth,
}
#[derive(Deserialize)]
struct VaultAuth {
client_token: String,
}
impl std::fmt::Debug for VaultLoginResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VaultLoginResponse")
.field("auth", &self.auth)
.finish()
}
}
impl std::fmt::Debug for VaultAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VaultAuth")
.field("client_token", &"<redacted>")
.finish()
}
}
#[derive(Deserialize)]
struct VaultKvResponse {
data: VaultKvDataWrapper,
}
#[derive(Deserialize)]
struct VaultKvDataWrapper {
data: serde_json::Map<String, serde_json::Value>,
}
fn http_client_builder() -> Result<reqwest::ClientBuilder, String> {
let request_timeout = Duration::from_millis(resolve_timeout_ms(
ENV_REQUEST_TIMEOUT_MS,
DEFAULT_REQUEST_TIMEOUT_MS,
));
let connect_timeout = Duration::from_millis(resolve_timeout_ms(
ENV_CONNECT_TIMEOUT_MS,
DEFAULT_CONNECT_TIMEOUT_MS,
));
let mut builder = reqwest::Client::builder()
.timeout(request_timeout)
.connect_timeout(connect_timeout);
if let Ok(path) = std::env::var("CELLOS_CA_BUNDLE") {
let pem =
std::fs::read(&path).map_err(|e| format!("CELLOS_CA_BUNDLE: read {path}: {e}"))?;
let mut added = 0usize;
for block in pem_cert_blocks(&pem) {
let cert = reqwest::Certificate::from_pem(&block)
.map_err(|e| format!("CELLOS_CA_BUNDLE: parse cert in {path}: {e}"))?;
builder = builder.add_root_certificate(cert);
added += 1;
}
if added == 0 {
return Err(format!("CELLOS_CA_BUNDLE: no certificates found in {path}"));
}
tracing::debug!(path = %path, count = added, "CELLOS_CA_BUNDLE: loaded CA certificates");
}
Ok(builder)
}
fn pem_cert_blocks(pem: &[u8]) -> Vec<Vec<u8>> {
let text = String::from_utf8_lossy(pem);
let mut blocks = Vec::new();
let mut current = String::new();
let mut in_block = false;
for line in text.lines() {
if line.starts_with("-----BEGIN ") {
in_block = true;
current.clear();
}
if in_block {
current.push_str(line);
current.push('\n');
if line.starts_with("-----END ") {
blocks.push(current.as_bytes().to_vec());
in_block = false;
}
}
}
blocks
}
impl VaultAppRoleBroker {
pub fn from_env() -> Result<Self, CellosError> {
let addr = std::env::var("CELLOS_VAULT_ADDR")
.map_err(|_| CellosError::SecretBroker("CELLOS_VAULT_ADDR not set".into()))?;
let addr = addr.trim().trim_end_matches('/').to_string();
if addr.is_empty() {
return Err(CellosError::SecretBroker(
"CELLOS_VAULT_ADDR is empty after trim".into(),
));
}
let parsed = reqwest::Url::parse(&addr).map_err(|e| {
CellosError::SecretBroker(format!("CELLOS_VAULT_ADDR invalid URL: {e}"))
})?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return Err(CellosError::SecretBroker(format!(
"CELLOS_VAULT_ADDR scheme must be http or https, got {scheme}"
)));
}
let role_id = std::env::var("CELLOS_VAULT_ROLE_ID")
.map_err(|_| CellosError::SecretBroker("CELLOS_VAULT_ROLE_ID not set".into()))?;
if role_id.trim().is_empty() {
return Err(CellosError::SecretBroker(
"CELLOS_VAULT_ROLE_ID is empty".into(),
));
}
let secret_id = std::env::var("CELLOS_VAULT_SECRET_ID")
.map_err(|_| CellosError::SecretBroker("CELLOS_VAULT_SECRET_ID not set".into()))?;
if secret_id.trim().is_empty() {
return Err(CellosError::SecretBroker(
"CELLOS_VAULT_SECRET_ID is empty".into(),
));
}
let kv_mount =
std::env::var("CELLOS_VAULT_KV_MOUNT").unwrap_or_else(|_| "secret".to_string());
let kv_mount = kv_mount.trim().trim_matches('/').to_string();
let kv_path_prefix = std::env::var("CELLOS_VAULT_KV_PATH_PREFIX")
.ok()
.map(|p| p.trim().trim_matches('/').to_string())
.filter(|p| !p.is_empty());
let namespace = std::env::var("CELLOS_VAULT_NAMESPACE")
.ok()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty());
let client = http_client_builder()
.map_err(CellosError::SecretBroker)?
.build()
.map_err(|e| CellosError::SecretBroker(format!("vault http client init: {e}")))?;
Ok(Self {
client,
addr,
role_id,
secret_id,
kv_mount,
kv_path_prefix,
namespace,
runtime_leases: Mutex::new(HashMap::new()),
})
}
async fn login(&self) -> Result<String, CellosError> {
let url = format!("{}/v1/auth/approle/login", self.addr);
let body = serde_json::json!({
"role_id": self.role_id,
"secret_id": self.secret_id,
});
let mut req = self.client.post(&url).json(&body);
if let Some(ref ns) = self.namespace {
req = req.header("X-Vault-Namespace", ns);
}
let resp = req
.send()
.await
.map_err(|e| CellosError::SecretBroker(format!("vault approle login request: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(CellosError::SecretBroker(format!(
"vault approle login returned {status}: {body}"
)));
}
let mut login: VaultLoginResponse = resp
.json()
.await
.map_err(|e| CellosError::SecretBroker(format!("vault login response parse: {e}")))?;
let token = std::mem::take(&mut login.auth.client_token);
drop(login);
tracing::debug!("vault approle login succeeded");
Ok(token)
}
fn kv_path(&self, key: &str) -> String {
match &self.kv_path_prefix {
Some(prefix) => format!("{}/v1/{}/data/{}/{}", self.addr, self.kv_mount, prefix, key),
None => format!("{}/v1/{}/data/{}", self.addr, self.kv_mount, key),
}
}
async fn fetch_secret(&self, token: &str, key: &str) -> Result<String, CellosError> {
let url = self.kv_path(key);
let mut req = self.client.get(&url).header("X-Vault-Token", token);
if let Some(ref ns) = self.namespace {
req = req.header("X-Vault-Namespace", ns);
}
let resp = req
.send()
.await
.map_err(|e| CellosError::SecretBroker(format!("vault kv read request: {e}")))?;
if resp.status().as_u16() == 404 {
return Err(CellosError::SecretBroker(format!(
"vault kv secret not found: {key}"
)));
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(CellosError::SecretBroker(format!(
"vault kv read returned {status}: {body}"
)));
}
let kv: VaultKvResponse = resp
.json()
.await
.map_err(|e| CellosError::SecretBroker(format!("vault kv response parse: {e}")))?;
let value = kv
.data
.data
.get(key)
.or_else(|| kv.data.data.values().next())
.ok_or_else(|| {
CellosError::SecretBroker(format!(
"vault kv secret {key:?} has no fields in data.data"
))
})?;
match value {
serde_json::Value::String(s) => Ok(s.clone()),
other => Ok(other.to_string()),
}
}
async fn revoke_token(&self, token: &str) -> Result<(), CellosError> {
let url = format!("{}/v1/auth/token/revoke-self", self.addr);
let mut req = self.client.post(&url).header("X-Vault-Token", token);
if let Some(ref ns) = self.namespace {
req = req.header("X-Vault-Namespace", ns);
}
let resp = req.send().await.map_err(|e| {
CellosError::SecretBroker(format!("vault revoke-self request failed: {e}"))
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(CellosError::SecretBroker(format!(
"vault revoke-self returned {status}: {body}"
)));
}
Ok(())
}
fn take_runtime_lease(&self, cell_id: &str) -> Option<RuntimeVaultLease> {
self.runtime_leases
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(cell_id)
}
fn insert_runtime_lease(&self, cell_id: &str, lease: RuntimeVaultLease) {
if let Some(mut previous) = self
.runtime_leases
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(cell_id.to_string(), lease)
{
previous.zeroize();
}
}
pub fn has_runtime_lease(&self, cell_id: &str) -> bool {
self.runtime_leases
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(cell_id)
}
pub fn runtime_lease_count(&self) -> usize {
self.runtime_leases
.lock()
.unwrap_or_else(|e| e.into_inner())
.len()
}
}
#[async_trait]
impl SecretBroker for VaultAppRoleBroker {
#[instrument(skip(self), fields(key = %key, cell_id = %cell_id))]
async fn resolve(
&self,
key: &str,
cell_id: &str,
_ttl_seconds: u64,
) -> Result<SecretView, CellosError> {
tracing::debug!(key = %key, cell_id = %cell_id, "resolving vault secret");
let token = zeroize::Zeroizing::new(self.login().await?);
let value = self.fetch_secret(token.as_str(), key).await?;
tracing::info!(key = %key, cell_id = %cell_id, "vault secret resolved");
Ok(SecretView {
key: key.to_string(),
value: zeroize::Zeroizing::new(value),
})
}
async fn prepare_runtime_secret_lease(
&self,
cell_id: &str,
requests: &[RuntimeSecretLeaseRequest],
) -> Result<(), CellosError> {
if requests.is_empty() {
return Ok(());
}
if let Some(mut previous) = self.take_runtime_lease(cell_id) {
let revoke_result = self.revoke_token(&previous.token).await;
previous.zeroize();
revoke_result?;
}
let mut token = self.login().await?;
for req in requests {
if let Err(e) = self.fetch_secret(&token, &req.key).await {
if let Err(revoke_err) = self.revoke_token(&token).await {
tracing::warn!(
cell_id = %cell_id,
revoke_error = %revoke_err,
"failed to revoke partial Vault lease after prepare error; \
upstream may rely on TTL"
);
}
tracing::warn!(
cell_id = %cell_id,
key = %req.key,
error = %e,
"Vault prepare aborted; partial lease revoked (E2-03)"
);
token.zeroize();
return Err(e);
}
}
self.insert_runtime_lease(cell_id, RuntimeVaultLease { token });
tracing::info!(
cell_id = %cell_id,
secret_count = requests.len(),
"prepared Vault runtime secret lease"
);
Ok(())
}
async fn fetch_runtime_secret(
&self,
key: &str,
cell_id: &str,
_ttl_seconds: u64,
) -> Result<SecretView, CellosError> {
let mut token = self
.runtime_leases
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(cell_id)
.map(|lease| lease.token.clone())
.ok_or_else(|| {
CellosError::SecretBroker(format!(
"no prepared Vault runtime lease for cell {cell_id:?}"
))
})?;
let result = self
.fetch_secret(&token, key)
.await
.map(|value| SecretView {
key: key.to_string(),
value: zeroize::Zeroizing::new(value),
});
token.zeroize();
result
}
async fn revoke_for_cell(&self, cell_id: &str) -> Result<(), CellosError> {
let Some(mut lease) = self.take_runtime_lease(cell_id) else {
return Ok(());
};
let revoke_result = self.revoke_token(&lease.token).await;
lease.zeroize();
revoke_result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::time::{Duration, Instant};
#[derive(Debug)]
struct CapturedRequest {
method: String,
target: String,
token: Option<String>,
body: String,
}
fn read_request(stream: &mut TcpStream) -> CapturedRequest {
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.expect("read request line");
assert!(!request_line.trim().is_empty(), "expected request line");
let mut content_length = 0usize;
let mut token = None;
loop {
let mut line = String::new();
reader.read_line(&mut line).expect("read header");
if line == "\r\n" || line.is_empty() {
break;
}
if let Some((name, value)) = line.split_once(':') {
let name = name.trim().to_ascii_lowercase();
let value = value.trim().to_string();
if name == "content-length" {
content_length = value.parse::<usize>().expect("parse content-length");
} else if name == "x-vault-token" {
token = Some(value);
}
}
}
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body).expect("read request body");
let mut parts = request_line.split_whitespace();
let method = parts.next().expect("method").to_string();
let target = parts.next().expect("target").to_string();
CapturedRequest {
method,
target,
token,
body: String::from_utf8(body).expect("utf8 request body"),
}
}
fn write_response(stream: &mut TcpStream, status_line: &str, body: &str, content_type: &str) {
write!(
stream,
"HTTP/1.1 {status_line}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.expect("write response");
stream.flush().expect("flush response");
}
fn start_mock_vault(
expected_requests: usize,
) -> (String, thread::JoinHandle<Vec<CapturedRequest>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock vault");
listener
.set_nonblocking(true)
.expect("set mock vault nonblocking");
let addr = listener.local_addr().expect("mock vault addr");
let handle = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(10);
let mut requests = Vec::new();
while requests.len() < expected_requests && Instant::now() < deadline {
match listener.accept() {
Ok((mut stream, _)) => {
stream
.set_nonblocking(false)
.expect("set accepted stream blocking");
let request = read_request(&mut stream);
match (request.method.as_str(), request.target.as_str()) {
("POST", "/v1/auth/approle/login") => write_response(
&mut stream,
"200 OK",
r#"{"auth":{"client_token":"vault-token"}}"#,
"application/json",
),
("GET", "/v1/secret/data/API_TOKEN") => write_response(
&mut stream,
"200 OK",
r#"{"data":{"data":{"API_TOKEN":"leased-secret"}}}"#,
"application/json",
),
("POST", "/v1/auth/token/revoke-self") => {
write_response(&mut stream, "204 No Content", "", "text/plain")
}
_ => write_response(
&mut stream,
"404 Not Found",
"unexpected request",
"text/plain",
),
}
requests.push(request);
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(20));
}
Err(err) => panic!("mock vault accept failed: {err}"),
}
}
requests
});
(format!("http://{addr}"), handle)
}
fn set_required_env() {
std::env::set_var("CELLOS_VAULT_ADDR", "https://vault.example.com");
std::env::set_var("CELLOS_VAULT_ROLE_ID", "test-role-id");
std::env::set_var("CELLOS_VAULT_SECRET_ID", "test-secret-id");
std::env::remove_var("CELLOS_VAULT_KV_MOUNT");
std::env::remove_var("CELLOS_VAULT_KV_PATH_PREFIX");
std::env::remove_var("CELLOS_VAULT_NAMESPACE");
std::env::remove_var("CELLOS_CA_BUNDLE");
}
fn clear_required_env() {
std::env::remove_var("CELLOS_VAULT_ADDR");
std::env::remove_var("CELLOS_VAULT_ROLE_ID");
std::env::remove_var("CELLOS_VAULT_SECRET_ID");
}
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn constructs_with_required_env() {
let _g = env_lock();
set_required_env();
let broker = VaultAppRoleBroker::from_env();
clear_required_env();
assert!(broker.is_ok());
}
#[test]
fn fails_when_addr_missing() {
let _g = env_lock();
set_required_env();
std::env::remove_var("CELLOS_VAULT_ADDR");
let err = VaultAppRoleBroker::from_env().unwrap_err();
clear_required_env();
assert!(err.to_string().contains("CELLOS_VAULT_ADDR"), "got: {err}");
}
#[test]
fn fails_when_role_id_missing() {
let _g = env_lock();
set_required_env();
std::env::remove_var("CELLOS_VAULT_ROLE_ID");
let err = VaultAppRoleBroker::from_env().unwrap_err();
clear_required_env();
assert!(
err.to_string().contains("CELLOS_VAULT_ROLE_ID"),
"got: {err}"
);
}
#[test]
fn fails_when_secret_id_missing() {
let _g = env_lock();
set_required_env();
std::env::remove_var("CELLOS_VAULT_SECRET_ID");
let err = VaultAppRoleBroker::from_env().unwrap_err();
clear_required_env();
assert!(
err.to_string().contains("CELLOS_VAULT_SECRET_ID"),
"got: {err}"
);
}
#[test]
fn fails_when_addr_not_http() {
let _g = env_lock();
set_required_env();
std::env::set_var("CELLOS_VAULT_ADDR", "grpc://vault.example.com");
let err = VaultAppRoleBroker::from_env().unwrap_err();
clear_required_env();
assert!(err.to_string().contains("http or https"), "got: {err}");
}
#[test]
fn uses_default_kv_mount() {
let _g = env_lock();
set_required_env();
let broker = VaultAppRoleBroker::from_env().unwrap();
clear_required_env();
assert_eq!(broker.kv_mount, "secret");
}
#[test]
fn custom_kv_mount_and_prefix() {
let _g = env_lock();
set_required_env();
std::env::set_var("CELLOS_VAULT_KV_MOUNT", "kv");
std::env::set_var("CELLOS_VAULT_KV_PATH_PREFIX", "cellos/prod");
let broker = VaultAppRoleBroker::from_env().unwrap();
clear_required_env();
std::env::remove_var("CELLOS_VAULT_KV_MOUNT");
std::env::remove_var("CELLOS_VAULT_KV_PATH_PREFIX");
assert_eq!(broker.kv_mount, "kv");
assert_eq!(broker.kv_path_prefix.as_deref(), Some("cellos/prod"));
}
#[test]
fn kv_path_without_prefix() {
let _g = env_lock();
set_required_env();
let broker = VaultAppRoleBroker::from_env().unwrap();
clear_required_env();
assert_eq!(
broker.kv_path("DB_PASSWORD"),
"https://vault.example.com/v1/secret/data/DB_PASSWORD"
);
}
#[test]
fn kv_path_with_prefix() {
let _g = env_lock();
set_required_env();
std::env::set_var("CELLOS_VAULT_KV_MOUNT", "kv");
std::env::set_var("CELLOS_VAULT_KV_PATH_PREFIX", "cellos/prod");
let broker = VaultAppRoleBroker::from_env().unwrap();
std::env::remove_var("CELLOS_VAULT_KV_MOUNT");
std::env::remove_var("CELLOS_VAULT_KV_PATH_PREFIX");
clear_required_env();
assert_eq!(
broker.kv_path("DB_PASSWORD"),
"https://vault.example.com/v1/kv/data/cellos/prod/DB_PASSWORD"
);
}
#[tokio::test]
async fn resolve_fails_without_vault_running() {
let broker = {
let _g = env_lock();
set_required_env();
std::env::set_var("CELLOS_VAULT_ADDR", "http://127.0.0.1:19999");
let broker = VaultAppRoleBroker::from_env().unwrap();
clear_required_env();
broker
};
let err = broker.resolve("ANY_KEY", "cell-1", 60).await.unwrap_err();
assert!(
err.to_string().contains("vault approle login"),
"got: {err}"
);
}
#[tokio::test]
async fn runtime_leased_prepare_fetches_and_revokes_token() {
let (addr, server) = start_mock_vault(4);
let broker = {
let _g = env_lock();
set_required_env();
std::env::set_var("CELLOS_VAULT_ADDR", addr);
let broker = VaultAppRoleBroker::from_env().unwrap();
clear_required_env();
broker
};
broker
.prepare_runtime_secret_lease(
"cell-1",
&[RuntimeSecretLeaseRequest {
key: "API_TOKEN".into(),
ttl_seconds: 60,
}],
)
.await
.unwrap();
let view = broker
.fetch_runtime_secret("API_TOKEN", "cell-1", 60)
.await
.unwrap();
assert_eq!(view.key, "API_TOKEN");
assert_eq!(view.value.as_str(), "leased-secret");
broker.revoke_for_cell("cell-1").await.unwrap();
let requests = server.join().expect("join mock vault");
assert_eq!(requests.len(), 4);
assert_eq!(requests[0].method, "POST");
assert_eq!(requests[0].target, "/v1/auth/approle/login");
assert!(requests[0].body.contains("\"role_id\":\"test-role-id\""));
assert!(requests[0]
.body
.contains("\"secret_id\":\"test-secret-id\""));
assert_eq!(requests[1].method, "GET");
assert_eq!(requests[1].target, "/v1/secret/data/API_TOKEN");
assert_eq!(requests[1].token.as_deref(), Some("vault-token"));
assert_eq!(requests[2].method, "GET");
assert_eq!(requests[2].target, "/v1/secret/data/API_TOKEN");
assert_eq!(requests[2].token.as_deref(), Some("vault-token"));
assert_eq!(requests[3].method, "POST");
assert_eq!(requests[3].target, "/v1/auth/token/revoke-self");
assert_eq!(requests[3].token.as_deref(), Some("vault-token"));
}
#[tokio::test]
async fn runtime_leased_fetch_requires_prepared_lease() {
let broker = {
let _g = env_lock();
set_required_env();
let broker = VaultAppRoleBroker::from_env().unwrap();
clear_required_env();
broker
};
let err = broker
.fetch_runtime_secret("API_TOKEN", "missing-cell", 60)
.await
.unwrap_err();
assert!(
err.to_string().contains("no prepared Vault runtime lease"),
"got: {err}"
);
}
#[tokio::test]
async fn revoke_without_prepared_lease_is_ok() {
let broker = {
let _g = env_lock();
set_required_env();
let broker = VaultAppRoleBroker::from_env().unwrap();
clear_required_env();
broker
};
broker.revoke_for_cell("any-cell").await.unwrap();
}
#[test]
fn vault_login_response_debug_redacts_client_token() {
let response = VaultLoginResponse {
auth: VaultAuth {
client_token: "VAULT-CT-ZERO-INLINE-SENTINEL".to_string(),
},
};
let dbg = format!("{response:?}");
assert!(
!dbg.contains("VAULT-CT-ZERO-INLINE-SENTINEL"),
"VaultLoginResponse Debug leaked client_token: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"VaultLoginResponse Debug should mark client_token as redacted: {dbg}"
);
let auth_dbg = format!("{:?}", response.auth);
assert!(
!auth_dbg.contains("VAULT-CT-ZERO-INLINE-SENTINEL"),
"VaultAuth Debug leaked client_token: {auth_dbg}"
);
}
}