Files
rustfs/crates/kms/src/backends/vault_credentials.rs
T

1871 lines
73 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Credential plumbing shared by the Vault KV2 and Transit backends.
//!
//! [`VaultCredentialProvider`] owns the authenticated [`VaultClient`] and hands
//! out per-request snapshots. Backends take a fresh snapshot via
//! [`VaultCredentialProvider::current`] for every Vault call instead of holding
//! a client for their own lifetime: a credential rotation applies to the next
//! call, while calls already in flight finish on the generation they captured
//! (their `Arc` keeps it alive).
//!
//! Lease-bound tokens (AppRole) are kept fresh by a background renewal task
//! (see [`VaultCredentialProvider::spawn_renewal_task`]): it renews at half the
//! lease TTL, falls back to a fresh login when renewal is not possible, and
//! keeps retrying after failures. If the token still reaches the configured
//! safety window before expiry, [`VaultCredentialProvider::current`] fails
//! closed rather than handing out a token that may lapse mid-request.
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use async_trait::async_trait;
use sha2::Digest;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use vaultrs::client::{VaultClient, VaultClientSettingsBuilder};
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::config::{KmsConfig, VaultAuthMethod, redacted_secret};
use crate::error::{KmsError, Result};
use crate::policy::{self, AttemptError, ErrorClass, OpClass, RetryPolicy};
/// Result of a single authentication attempt, classified for the Auth retry
/// policy.
type AttemptResult<T> = std::result::Result<T, AttemptError>;
/// Cadence for refresh retries after a failed cycle (on top of the bounded
/// retries inside one [`policy::execute`] call).
const DEFAULT_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(5);
/// Default seconds between token file re-reads for [`TokenFileSource`].
const DEFAULT_TOKEN_FILE_POLL_INTERVAL_SECS: u64 = 30;
// ---------------------------------------------------------------------------
// Metrics
//
// Both gauges describe the one credential generation currently installed, so
// they carry no labels: the Vault address, mount, auth path and token are all
// off limits as label values, and there is exactly one generation to describe.
// The renewal loop republishes them on a bounded cadence while it waits, so a
// scrape landing between refresh cycles never reads a TTL frozen at the last
// refresh, or a fail-closed state that flipped after it.
// ---------------------------------------------------------------------------
/// Gauge: seconds left before the active Vault token expires; `0` once it has.
const METRIC_TOKEN_TTL_SECONDS: &str = "rustfs_kms_vault_token_ttl_seconds";
/// Gauge: `1` while [`VaultCredentialProvider::current`] refuses to hand out
/// the token because it is inside the fail-closed safety window, `0` otherwise.
const METRIC_CREDENTIALS_FAIL_CLOSED: &str = "rustfs_kms_vault_credentials_fail_closed";
/// How often the renewal loop republishes the credential gauges while waiting.
/// Bounds how stale a scrape can be, without any additional Vault traffic.
const CREDENTIAL_GAUGE_INTERVAL: Duration = Duration::from_secs(10);
/// Register metric descriptions once per process.
fn describe_credential_metrics() {
static DESCRIBE: std::sync::Once = std::sync::Once::new();
DESCRIBE.call_once(|| {
metrics::describe_gauge!(
METRIC_TOKEN_TTL_SECONDS,
"Seconds remaining before the Vault token backing the KMS backend expires"
);
metrics::describe_gauge!(
METRIC_CREDENTIALS_FAIL_CLOSED,
"1 while the Vault credential provider refuses to serve its token because the token is inside the fail-closed safety window"
);
});
}
/// A crate-owned secret value, zeroized on drop and redacted in Debug output.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub(crate) struct SecretString(String);
impl SecretString {
pub(crate) fn new(value: String) -> Self {
Self(value)
}
pub(crate) fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(redacted_secret(&self.0))
}
}
/// Expiry attributes of a lease-bound token.
#[derive(Debug, Clone, Copy)]
pub(crate) struct LeaseInfo {
/// Time-to-live granted at issue or renewal.
pub(crate) ttl: Duration,
/// Whether `renew-self` can extend this token.
pub(crate) renewable: bool,
}
/// A Vault token handed out by a [`TokenSource`].
///
/// The crate's copy of the token is zeroized on drop. This cannot cover the
/// copy `vaultrs` keeps inside its client settings (or the HTTP headers built
/// from it); it bounds how long the token lingers in memory owned by this
/// module.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub(crate) struct TokenLease {
token: String,
/// `None` for tokens without an expiry (static configuration tokens and
/// non-expiring root-like tokens).
#[zeroize(skip)]
lease: Option<LeaseInfo>,
}
impl TokenLease {
pub(crate) fn new(token: String, lease: Option<LeaseInfo>) -> Self {
Self { token, lease }
}
/// Map a Vault auth response onto a lease. A `lease_duration` of zero
/// means the token never expires, so no lease is tracked and no renewal is
/// scheduled.
fn from_auth(auth: vaultrs::api::AuthInfo) -> Self {
let lease = (auth.lease_duration > 0).then_some(LeaseInfo {
ttl: Duration::from_secs(auth.lease_duration),
renewable: auth.renewable,
});
Self {
token: auth.client_token,
lease,
}
}
/// Expose the raw token for handing to the Vault client builder.
pub(crate) fn expose(&self) -> &str {
&self.token
}
pub(crate) fn lease_info(&self) -> Option<LeaseInfo> {
self.lease
}
}
impl fmt::Debug for TokenLease {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenLease")
.field("token", &redacted_secret(&self.token))
.field("lease", &self.lease)
.finish()
}
}
/// Wrap a `vaultrs` failure as a classified attempt failure.
fn attempt_error(operation: &str, error: vaultrs::error::ClientError) -> AttemptError {
AttemptError {
class: policy::classify_vaultrs(&error),
error: KmsError::backend_error(format!("Vault {operation} failed: {error}")),
}
}
/// Source of Vault authentication tokens.
///
/// Implementations perform one attempt per call; bounded retries and backoff
/// are owned by the caller through [`policy::execute`] with [`OpClass::Auth`].
/// Current sources are [`StaticToken`], [`AppRoleLogin`], and the agent-managed
/// [`TokenFileSource`].
#[async_trait]
pub(crate) trait TokenSource: fmt::Debug + Send + Sync {
/// One login attempt yielding a token for a new client generation.
async fn acquire(&self) -> AttemptResult<TokenLease>;
/// One `renew-self` attempt for the token held by `client`.
///
/// Sources whose tokens cannot be renewed fail fatally; callers fall back
/// to [`TokenSource::acquire`].
async fn renew(&self, _client: &VaultClient) -> AttemptResult<TokenLease> {
Err(AttemptError {
class: ErrorClass::Fatal,
error: KmsError::invalid_operation("this token source does not support renewal"),
})
}
}
/// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed
/// at configuration time. The token carries no lease, so it is never renewed
/// and never expires from the provider's point of view.
pub(crate) struct StaticToken {
token: TokenLease,
}
impl StaticToken {
pub(crate) fn new(token: String) -> Self {
Self {
token: TokenLease::new(token, None),
}
}
}
#[async_trait]
impl TokenSource for StaticToken {
async fn acquire(&self) -> AttemptResult<TokenLease> {
Ok(self.token.clone())
}
}
impl fmt::Debug for StaticToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// TokenLease::fmt already redacts the token value.
f.debug_struct("StaticToken").field("token", &self.token).finish()
}
}
/// Token source for [`VaultAuthMethod::AppRole`]: exchanges `role_id` +
/// `secret_id` for a lease-bound token via the AppRole auth engine.
pub(crate) struct AppRoleLogin {
/// Unauthenticated client used only for the login exchange.
login_client: VaultClient,
mount: String,
role_id: String,
/// Inline secret_id fallback, used when no file is configured.
secret_id: SecretString,
/// Secret-id file, re-read on every login so external rotation of the
/// secret_id is picked up without a restart. Takes precedence over the
/// inline value.
secret_id_file: Option<PathBuf>,
}
impl AppRoleLogin {
pub(crate) fn new(
settings: &VaultConnectionSettings,
mount: String,
role_id: String,
secret_id: String,
secret_id_file: Option<PathBuf>,
) -> Result<Self> {
Ok(Self {
login_client: settings.build_login_client()?,
mount,
role_id,
secret_id: SecretString::new(secret_id),
secret_id_file,
})
}
/// Resolve the secret_id for one login attempt.
///
/// File problems are fatal for the attempt (replaying the same read within
/// one retry cycle cannot help), but the renewal loop keeps retrying on
/// its cadence, so repairing the file heals the source without a restart.
async fn resolve_secret_id(&self) -> AttemptResult<SecretString> {
let Some(path) = &self.secret_id_file else {
return Ok(self.secret_id.clone());
};
let mut raw = tokio::fs::read_to_string(path).await.map_err(|error| AttemptError {
class: ErrorClass::Fatal,
error: KmsError::configuration_error(format!("Failed to read AppRole secret_id file {}: {error}", path.display())),
})?;
let trimmed = raw.trim();
if trimmed.is_empty() {
raw.zeroize();
return Err(AttemptError {
class: ErrorClass::Fatal,
error: KmsError::configuration_error(format!("AppRole secret_id file {} is empty", path.display())),
});
}
let secret_id = SecretString::new(trimmed.to_string());
raw.zeroize();
Ok(secret_id)
}
}
#[async_trait]
impl TokenSource for AppRoleLogin {
async fn acquire(&self) -> AttemptResult<TokenLease> {
let secret_id = self.resolve_secret_id().await?;
let auth = vaultrs::auth::approle::login(&self.login_client, &self.mount, &self.role_id, secret_id.expose())
.await
.map_err(|error| attempt_error("AppRole login", error))?;
Ok(TokenLease::from_auth(auth))
}
async fn renew(&self, client: &VaultClient) -> AttemptResult<TokenLease> {
let auth = vaultrs::token::renew_self(client, None)
.await
.map_err(|error| attempt_error("token renewal", error))?;
Ok(TokenLease::from_auth(auth))
}
}
impl fmt::Debug for AppRoleLogin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// The login client embeds Vault client settings and must stay out of
// Debug output; role_id is not a secret in Vault's AppRole model.
f.debug_struct("AppRoleLogin")
.field("mount", &self.mount)
.field("role_id", &self.role_id)
.field("secret_id", &self.secret_id)
.field("secret_id_file", &self.secret_id_file)
.finish_non_exhaustive()
}
}
/// Token source for [`VaultAuthMethod::Kubernetes`]: exchanges the pod's
/// projected ServiceAccount token for a lease-bound Vault token.
///
/// The JWT is re-read on every login because the kubelet rotates a projected
/// token well inside the pod's lifetime; caching it would strand the source on
/// an expired assertion once the current Vault token can no longer be renewed.
///
/// Unlike [`TokenFileSource`], the file mode is not checked: the kubelet owns
/// the projected token and mounts it world-readable by default, so rejecting
/// group/other bits would refuse every standard pod rather than catch a
/// deployment error.
pub(crate) struct KubernetesLogin {
/// Unauthenticated client used only for the login exchange.
login_client: VaultClient,
mount: String,
role: String,
jwt_path: PathBuf,
}
impl KubernetesLogin {
pub(crate) fn new(settings: &VaultConnectionSettings, mount: String, role: String, jwt_path: PathBuf) -> Result<Self> {
Ok(Self {
login_client: settings.build_login_client()?,
mount,
role,
jwt_path,
})
}
/// Read the ServiceAccount token for one login attempt.
///
/// Mirrors [`AppRoleLogin::resolve_secret_id`]: a read failure is fatal for
/// the attempt but the refresh loop keeps retrying, so a token the kubelet
/// has not projected yet heals the source without a restart.
async fn resolve_jwt(&self) -> AttemptResult<SecretString> {
let mut raw = tokio::fs::read_to_string(&self.jwt_path)
.await
.map_err(|error| AttemptError {
class: ErrorClass::Fatal,
error: KmsError::configuration_error(format!(
"Failed to read Kubernetes ServiceAccount token {}: {error}",
self.jwt_path.display()
)),
})?;
let trimmed = raw.trim();
if trimmed.is_empty() {
raw.zeroize();
return Err(AttemptError {
class: ErrorClass::Fatal,
error: KmsError::configuration_error(format!(
"Kubernetes ServiceAccount token {} is empty",
self.jwt_path.display()
)),
});
}
let jwt = SecretString::new(trimmed.to_string());
raw.zeroize();
Ok(jwt)
}
}
#[async_trait]
impl TokenSource for KubernetesLogin {
async fn acquire(&self) -> AttemptResult<TokenLease> {
let jwt = self.resolve_jwt().await?;
let auth = vaultrs::auth::kubernetes::login(&self.login_client, &self.mount, &self.role, jwt.expose())
.await
.map_err(|error| attempt_error("Kubernetes login", error))?;
Ok(TokenLease::from_auth(auth))
}
async fn renew(&self, client: &VaultClient) -> AttemptResult<TokenLease> {
let auth = vaultrs::token::renew_self(client, None)
.await
.map_err(|error| attempt_error("token renewal", error))?;
Ok(TokenLease::from_auth(auth))
}
}
impl fmt::Debug for KubernetesLogin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// The login client embeds Vault client settings and must stay out of
// Debug output; the role name is not a secret, and the JWT is never held.
f.debug_struct("KubernetesLogin")
.field("mount", &self.mount)
.field("role", &self.role)
.field("jwt_path", &self.jwt_path)
.finish_non_exhaustive()
}
}
/// Token source for [`VaultAuthMethod::TokenFile`]: reads an agent-managed
/// token file (for example a Vault Agent auto-auth sink).
///
/// The agent owns the token's lifecycle; this source only tracks the file.
/// Every read grants the token an observed validity of `validity`, so the
/// renewal loop re-reads the file at half that interval and installs a new
/// client generation from whatever the file holds. A file that disappears or
/// turns empty keeps failing the refresh until the fail-closed window trips,
/// and heals the provider as soon as it is restored.
pub(crate) struct TokenFileSource {
path: PathBuf,
/// Observed validity granted per successful read; the renewal loop
/// re-reads at half this value.
validity: Duration,
/// Digest of the last token read, to tell agent-driven rotations apart
/// from plain refreshes in the logs. Never holds the token itself.
last_digest: std::sync::Mutex<Option<[u8; 32]>>,
}
impl TokenFileSource {
pub(crate) fn new(path: PathBuf, validity: Duration) -> Self {
Self {
path,
validity,
last_digest: std::sync::Mutex::new(None),
}
}
fn fatal(error: KmsError) -> AttemptError {
AttemptError {
class: ErrorClass::Fatal,
error,
}
}
/// Reject a token file readable by group or other, mirroring the SFTP
/// host-key rule: a Vault token grants use of the KMS keys, so a mode
/// wider than owner-only is a deployment error, not a warning.
#[cfg(unix)]
fn check_permissions(&self, metadata: &std::fs::Metadata) -> AttemptResult<()> {
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(Self::fatal(KmsError::configuration_error(format!(
"Vault token file {} has insecure permissions {mode:#o} (group and other permission bits must be unset)",
self.path.display()
))));
}
Ok(())
}
}
#[async_trait]
impl TokenSource for TokenFileSource {
async fn acquire(&self) -> AttemptResult<TokenLease> {
// Synchronous reads on purpose: the token file is tiny, this runs at
// the renewal cadence, and staying off the blocking pool keeps the
// paused-clock tests of the renewal timing deterministic.
let metadata = std::fs::metadata(&self.path).map_err(|error| {
Self::fatal(KmsError::configuration_error(format!(
"Failed to read Vault token file {}: {error}",
self.path.display()
)))
})?;
#[cfg(unix)]
self.check_permissions(&metadata)?;
let mut raw = std::fs::read_to_string(&self.path).map_err(|error| {
Self::fatal(KmsError::configuration_error(format!(
"Failed to read Vault token file {}: {error}",
self.path.display()
)))
})?;
let trimmed = raw.trim();
if trimmed.is_empty() {
raw.zeroize();
return Err(Self::fatal(KmsError::configuration_error(format!(
"Vault token file {} is empty",
self.path.display()
))));
}
let digest: [u8; 32] = sha2::Sha256::digest(trimmed.as_bytes()).into();
let previous = self
.last_digest
.lock()
.expect("token file digest mutex poisoned")
.replace(digest);
if previous.is_some_and(|previous| previous != digest) {
info!(
path = %self.path.display(),
modified = ?metadata.modified().ok(),
"Vault token file rotated; installing a new client generation"
);
}
let token = trimmed.to_string();
raw.zeroize();
Ok(TokenLease::new(
token,
Some(LeaseInfo {
ttl: self.validity,
renewable: false,
}),
))
}
}
impl fmt::Debug for TokenFileSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// The token itself is never stored on the source; only its digest is.
f.debug_struct("TokenFileSource")
.field("path", &self.path)
.field("validity", &self.validity)
.finish_non_exhaustive()
}
}
/// Map the configured auth method onto a token source.
pub(crate) fn token_source_for(
auth_method: &VaultAuthMethod,
settings: &VaultConnectionSettings,
) -> Result<Box<dyn TokenSource>> {
match auth_method {
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(token.clone()))),
VaultAuthMethod::AppRole {
role_id,
secret_id,
secret_id_file,
mount,
..
} => Ok(Box::new(AppRoleLogin::new(
settings,
mount.clone(),
role_id.clone(),
secret_id.clone(),
secret_id_file.clone(),
)?)),
VaultAuthMethod::Kubernetes {
role, mount, jwt_path, ..
} => Ok(Box::new(KubernetesLogin::new(settings, mount.clone(), role.clone(), jwt_path.clone())?)),
VaultAuthMethod::TokenFile {
path,
poll_interval_secs,
..
} => {
let poll_interval = Duration::from_secs(poll_interval_secs.unwrap_or(DEFAULT_TOKEN_FILE_POLL_INTERVAL_SECS).max(1));
// Validity is twice the poll interval because the renewal loop
// fires at half the lease TTL: the file is then re-read once per
// poll interval, and a file that stops being readable expires the
// token after roughly two missed polls minus the safety window.
Ok(Box::new(TokenFileSource::new(path.clone(), poll_interval * 2)))
}
}
}
/// Connection parameters shared by every client generation.
#[derive(Debug, Clone)]
pub(crate) struct VaultConnectionSettings {
pub(crate) address: String,
pub(crate) namespace: Option<String>,
/// Per-attempt HTTP timeout applied to the underlying reqwest client.
pub(crate) attempt_timeout: Duration,
/// Whether to accept an unverified Vault server certificate. Gated on
/// `allow_insecure_dev_defaults` by `KmsConfig::validate`.
pub(crate) skip_tls_verify: bool,
}
impl VaultConnectionSettings {
/// Build an authenticated client for one generation.
fn build_client(&self, token: &str) -> Result<VaultClient> {
let mut settings_builder = VaultClientSettingsBuilder::default();
settings_builder.address(&self.address);
// Defense in depth against stalled connections: vaultrs leaves the
// underlying reqwest client without any timeout by default, so a hung
// request would otherwise wait forever regardless of the
// operation-level retry policy.
settings_builder.timeout(Some(self.attempt_timeout));
settings_builder.token(token);
// Always set explicitly: left unset, vaultrs derives this from its own
// VAULT_SKIP_VERIFY variable, so a stray value in the environment would
// disable certificate verification behind the KMS configuration and its
// insecure-defaults gate.
settings_builder.verify(!self.skip_tls_verify);
if let Some(namespace) = &self.namespace {
settings_builder.namespace(Some(namespace.clone()));
}
let settings = settings_builder
.build()
.map_err(|e| KmsError::backend_error(format!("Failed to build Vault client settings: {e}")))?;
VaultClient::new(settings).map_err(|e| KmsError::backend_error(format!("Failed to create Vault client: {e}")))
}
/// Build the tokenless client used for login exchanges.
fn build_login_client(&self) -> Result<VaultClient> {
self.build_client("")
}
}
/// Refresh and fail-closed tuning for a [`VaultCredentialProvider`].
#[derive(Debug, Clone)]
pub(crate) struct VaultCredentialPolicy {
/// Retry budget for one login cycle.
pub(crate) retry: RetryPolicy,
/// Retry budget for one token-renewal cycle.
pub(crate) renew_retry: RetryPolicy,
/// Fail-closed margin: once the current token is within this window of
/// expiry without a successful refresh, [`VaultCredentialProvider::current`]
/// refuses to hand it out.
pub(crate) safety_window: Duration,
/// Pause between refresh cycles after a failed one.
pub(crate) retry_interval: Duration,
}
impl VaultCredentialPolicy {
/// Derive the policy from the KMS configuration.
///
/// The default safety window equals the per-attempt timeout: a request
/// issued now can stay in flight for up to one attempt timeout, so the
/// token must outlive at least that.
pub(crate) fn from_kms_config(
config: &KmsConfig,
auth_method: &VaultAuthMethod,
backend: &'static str,
endpoint: &str,
namespace: Option<&str>,
) -> Self {
let retry = RetryPolicy::for_credentials(config, backend, endpoint, namespace, "credentials-login");
let safety_window = match auth_method {
VaultAuthMethod::AppRole {
refresh_safety_window_secs: Some(secs),
..
}
| VaultAuthMethod::Kubernetes {
refresh_safety_window_secs: Some(secs),
..
}
| VaultAuthMethod::TokenFile {
refresh_safety_window_secs: Some(secs),
..
} => Duration::from_secs(*secs),
_ => retry.attempt_timeout,
};
Self {
retry,
renew_retry: RetryPolicy::for_credentials(config, backend, endpoint, namespace, "credentials-renew"),
safety_window,
retry_interval: DEFAULT_REFRESH_RETRY_INTERVAL,
}
}
}
/// One authenticated client generation.
///
/// Request paths hold the handle (via `Arc`) for the duration of a single
/// Vault call, so a rotation that swaps in a newer generation never tears the
/// client out from under an in-flight request.
pub(crate) struct VaultClientHandle {
/// Monotonic counter identifying the credential generation this client was
/// built from; bumped on every successful refresh.
pub(crate) generation: u64,
pub(crate) client: VaultClient,
/// When this generation's token was issued (or last renewed).
issued_at: Instant,
/// Lease of this generation's token; `None` when it never expires.
lease: Option<LeaseInfo>,
}
impl VaultClientHandle {
/// Absolute expiry of this generation's token.
///
/// `lease.ttl` is built from the `lease_duration` the Vault server sent, so
/// a value too large to add to `issued_at` would panic on the bare `+`. A
/// TTL that cannot be represented is indistinguishable from no expiry, so it
/// collapses to `None` — the same answer already given for the zero-lease
/// tokens Vault issues, which keeps the token in use and still fully
/// validated by Vault on every call.
fn expires_at(&self) -> Option<Instant> {
self.lease.and_then(|lease| self.issued_at.checked_add(lease.ttl))
}
/// When the renewal task should refresh this generation: half the TTL,
/// leaving the second half as budget for retries before the fail-closed
/// window is reached.
///
/// Unrepresentable TTLs collapse to `None` as in [`Self::expires_at`],
/// leaving a token that never expires with nothing to renew.
fn renew_at(&self) -> Option<Instant> {
self.lease.and_then(|lease| self.issued_at.checked_add(lease.ttl / 2))
}
}
impl fmt::Debug for VaultClientHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// `VaultClient` embeds its settings, including the token, so it must
// never appear in Debug output.
f.debug_struct("VaultClientHandle")
.field("generation", &self.generation)
.field("lease", &self.lease)
.finish_non_exhaustive()
}
}
/// Owns the authenticated Vault client for a backend and hands out
/// per-request snapshots.
pub(crate) struct VaultCredentialProvider {
settings: VaultConnectionSettings,
source: Box<dyn TokenSource>,
policy: VaultCredentialPolicy,
current: ArcSwap<VaultClientHandle>,
/// Serializes refreshes so concurrent triggers coalesce into one login.
refresh_lock: tokio::sync::Mutex<()>,
}
impl VaultCredentialProvider {
/// Authenticate with `source` and build the initial client generation.
pub(crate) async fn new(
settings: VaultConnectionSettings,
source: Box<dyn TokenSource>,
policy: VaultCredentialPolicy,
) -> Result<Self> {
let startup_cancel = CancellationToken::new();
let lease = policy::execute("vault_login", OpClass::Auth, &policy.retry, &startup_cancel, || source.acquire()).await?;
let client = settings.build_client(lease.expose())?;
Ok(Self {
current: ArcSwap::from_pointee(VaultClientHandle {
generation: 0,
client,
issued_at: Instant::now(),
lease: lease.lease_info(),
}),
settings,
source,
policy,
refresh_lock: tokio::sync::Mutex::new(()),
})
}
/// Snapshot the current generation without the expiry gate. Internal use
/// (renewal scheduling) and tests only; request paths go through
/// [`VaultCredentialProvider::current`].
pub(crate) fn snapshot(&self) -> Arc<VaultClientHandle> {
self.current.load_full()
}
/// Snapshot the current client generation for a single request.
///
/// Take one snapshot per Vault call: the returned `Arc` pins the
/// generation for exactly that call, so a concurrent rotation applies to
/// the next call without interrupting this one.
///
/// Fails closed when the token is inside the safety window of its expiry:
/// a request signed with such a token could lapse mid-flight, so refusing
/// it locally is strictly safer than an unpredictable remote failure.
pub(crate) fn current(&self) -> Result<Arc<VaultClientHandle>> {
let handle = self.current.load_full();
if let Some(expires_at) = handle.expires_at() {
let now = Instant::now();
if self.inside_safety_window(now, expires_at) {
return Err(KmsError::credentials_unavailable(format!(
"Vault token (generation {}) is within {:?} of expiry and has not been refreshed; refusing to use it",
handle.generation, self.policy.safety_window
)));
}
}
Ok(handle)
}
/// Whether the token expiring at `expires_at` is close enough to refuse.
///
/// `safety_window` reaches here from persisted configuration, so it is not
/// guaranteed to have passed this version's validation: a window too large
/// to add to the current instant would panic on the bare `+`. Such a window
/// means every token is always inside it, so saturating to "refuse" is both
/// the fail-closed answer and the one the arithmetic was reaching for.
fn inside_safety_window(&self, now: Instant, expires_at: Instant) -> bool {
now.checked_add(self.policy.safety_window)
.is_none_or(|deadline| deadline >= expires_at)
}
/// Publish the credential gauges for the generation currently installed.
///
/// The fail-closed gauge re-evaluates the very gate
/// [`VaultCredentialProvider::current`] applies, so what operators see and
/// what the request path does cannot drift apart.
fn record_credential_gauges(&self) {
let handle = self.current.load();
let now = Instant::now();
let fail_closed = match handle.expires_at() {
Some(expires_at) => {
metrics::gauge!(METRIC_TOKEN_TTL_SECONDS).set(expires_at.saturating_duration_since(now).as_secs_f64());
self.inside_safety_window(now, expires_at)
}
// A generation without an expiry has no remaining TTL to report
// and can never lapse, so it can never fail closed either.
None => false,
};
metrics::gauge!(METRIC_CREDENTIALS_FAIL_CLOSED).set(if fail_closed { 1.0 } else { 0.0 });
}
/// Wait until `deadline`, republishing the credential gauges on the
/// observation cadence. Reports `false` when cancellation cut the wait
/// short.
async fn wait_publishing_gauges(&self, deadline: Instant, cancel: &CancellationToken) -> bool {
loop {
self.record_credential_gauges();
let now = Instant::now();
if now >= deadline {
return true;
}
let slice = (deadline - now).min(CREDENTIAL_GAUGE_INTERVAL);
tokio::select! {
biased;
_ = cancel.cancelled() => return false,
_ = tokio::time::sleep(slice) => {}
}
}
}
/// Refresh the credentials if generation `observed` is still current.
///
/// Single-flight: concurrent callers serialize on the refresh lock, and a
/// caller that finds a newer generation already installed returns without
/// touching Vault. Renewable tokens are renewed in place; anything else
/// (or a failed renewal) falls back to a fresh login.
pub(crate) async fn refresh(&self, observed: u64, cancel: &CancellationToken) -> Result<()> {
let _guard = self.refresh_lock.lock().await;
let current = self.snapshot();
if current.generation != observed {
return Ok(());
}
let renewable = current.lease.map(|lease| lease.renewable).unwrap_or(false);
let renewed = if renewable {
match policy::execute("vault_token_renew", OpClass::Auth, &self.policy.renew_retry, cancel, || {
self.source.renew(&current.client)
})
.await
{
Ok(lease) => Some(lease),
Err(error @ KmsError::OperationCancelled { .. }) => return Err(error),
Err(error) => {
warn!(
generation = current.generation,
error = %error,
"Vault token renewal failed; falling back to a fresh login"
);
None
}
}
} else {
None
};
let lease = match renewed {
Some(lease) => lease,
None => policy::execute("vault_login", OpClass::Auth, &self.policy.retry, cancel, || self.source.acquire()).await?,
};
let client = self.settings.build_client(lease.expose())?;
self.current.store(Arc::new(VaultClientHandle {
generation: current.generation + 1,
client,
issued_at: Instant::now(),
lease: lease.lease_info(),
}));
Ok(())
}
/// Spawn the background renewal task for lease-bound credentials.
///
/// Returns `None` when the current token never expires (static tokens):
/// there is nothing to renew. The returned handle cancels the task when
/// dropped, tying the task's lifetime to whoever owns the handle (the
/// service version that owns this backend).
pub(crate) fn spawn_renewal_task(self: &Arc<Self>) -> Option<CredentialTaskHandle> {
self.snapshot().lease?;
let cancel = CancellationToken::new();
let join = tokio::spawn(renewal_loop(Arc::clone(self), cancel.clone()));
Some(CredentialTaskHandle {
cancel,
join: std::sync::Mutex::new(Some(join)),
})
}
}
impl fmt::Debug for VaultCredentialProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VaultCredentialProvider")
.field("source", &self.source)
.field("current", &self.current.load())
.field("policy", &self.policy)
.finish_non_exhaustive()
}
}
/// Drive credential refreshes until cancelled.
///
/// Each cycle sleeps until the current generation's renewal point (half TTL),
/// then refreshes. A failed cycle logs, waits `retry_interval`, and tries
/// again immediately (the renewal point is already in the past), so the
/// provider keeps trying to recover even after the fail-closed window has
/// been reached.
///
/// Both waits run through [`VaultCredentialProvider::wait_publishing_gauges`],
/// which is the only place the credential gauges are published: the loop is
/// already the component that tracks token expiry, and doing it here keeps the
/// request path free of any metric work.
async fn renewal_loop(provider: Arc<VaultCredentialProvider>, cancel: CancellationToken) {
describe_credential_metrics();
loop {
let handle = provider.snapshot();
let Some(renew_at) = handle.renew_at() else {
// The current generation never expires; nothing left to schedule.
provider.record_credential_gauges();
return;
};
if !provider.wait_publishing_gauges(renew_at, &cancel).await {
return;
}
match provider.refresh(handle.generation, &cancel).await {
Ok(()) => {}
Err(KmsError::OperationCancelled { .. }) => return,
Err(error) => {
warn!(
generation = handle.generation,
error = %error,
"Vault credential refresh failed; retrying until the credentials recover"
);
let retry_at = Instant::now() + provider.policy.retry_interval;
if !provider.wait_publishing_gauges(retry_at, &cancel).await {
return;
}
}
}
}
}
/// Owner handle for a spawned renewal task.
///
/// Dropping the handle cancels the task, so hanging it off the service version
/// recycles the task on stop and reconfigure without explicit lifecycle calls.
pub(crate) struct CredentialTaskHandle {
cancel: CancellationToken,
join: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl CredentialTaskHandle {
/// Cancel the renewal task and wait for it to exit.
pub(crate) async fn shutdown(&self) {
self.cancel.cancel();
let join = self.join.lock().expect("credential task join mutex poisoned").take();
if let Some(join) = join {
let _ = join.await;
}
}
}
impl Drop for CredentialTaskHandle {
fn drop(&mut self) {
self.cancel.cancel();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{DEFAULT_VAULT_KUBERNETES_MOUNT, REDACTED_SECRET};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
const TEST_TOKEN: &str = "vault-token-debug-leak-canary";
const TEST_SECRET_ID: &str = "approle-secret-id-leak-canary";
fn test_settings() -> VaultConnectionSettings {
VaultConnectionSettings {
address: "http://127.0.0.1:8200".to_string(),
namespace: Some("team-namespace".to_string()),
attempt_timeout: Duration::from_secs(30),
skip_tls_verify: false,
}
}
/// Tight retry budget so paused-clock tests stay deterministic: one
/// attempt per cycle, failed cycles spaced by `retry_interval`.
fn test_policy(safety_window: Duration, retry_interval: Duration) -> VaultCredentialPolicy {
let retry = RetryPolicy::for_test(
Duration::from_secs(1),
Duration::from_secs(1),
1,
Duration::from_millis(10),
Duration::from_millis(10),
);
let renew_retry = RetryPolicy::for_test(
Duration::from_secs(1),
Duration::from_secs(1),
1,
Duration::from_millis(10),
Duration::from_millis(10),
);
VaultCredentialPolicy {
retry,
renew_retry,
safety_window,
retry_interval,
}
}
#[test]
fn configured_login_and_renewal_use_reserved_credential_capacity() {
let config = KmsConfig::default();
let auth_method = VaultAuthMethod::Token {
token: TEST_TOKEN.to_string(),
};
let credentials = VaultCredentialPolicy::from_kms_config(
&config,
&auth_method,
"vault-kv2",
"https://credential-policy.example.invalid",
Some("team-namespace"),
);
let operations = RetryPolicy::for_backend(
&config,
"vault-kv2",
"https://credential-policy.example.invalid",
Some("team-namespace"),
"operations",
);
assert!(credentials.retry.uses_credential_reserve());
assert!(credentials.renew_retry.uses_credential_reserve());
assert!(!operations.uses_credential_reserve());
assert!(credentials.retry.shares_active_capacity_with(&credentials.renew_retry));
assert!(credentials.retry.shares_active_capacity_with(&operations));
}
/// Shared observable state of a [`ScriptedSource`].
#[derive(Debug, Default)]
struct ScriptedState {
login_calls: AtomicU32,
renew_calls: AtomicU32,
fail_login: AtomicBool,
fail_renew: AtomicBool,
}
/// Token source with scriptable outcomes for driving the provider without
/// a Vault server.
#[derive(Debug)]
struct ScriptedSource {
state: Arc<ScriptedState>,
ttl: Duration,
renewable: bool,
login_delay: Duration,
}
impl ScriptedSource {
fn lease(&self) -> Option<LeaseInfo> {
(!self.ttl.is_zero()).then_some(LeaseInfo {
ttl: self.ttl,
renewable: self.renewable,
})
}
fn failure() -> AttemptError {
AttemptError {
class: ErrorClass::RetryableStatus,
error: KmsError::backend_error("scripted auth failure (503)"),
}
}
}
#[async_trait]
impl TokenSource for ScriptedSource {
async fn acquire(&self) -> AttemptResult<TokenLease> {
if !self.login_delay.is_zero() {
tokio::time::sleep(self.login_delay).await;
}
let call = self.state.login_calls.fetch_add(1, Ordering::SeqCst);
if self.state.fail_login.load(Ordering::SeqCst) {
return Err(Self::failure());
}
Ok(TokenLease::new(format!("scripted-login-{call}"), self.lease()))
}
async fn renew(&self, _client: &VaultClient) -> AttemptResult<TokenLease> {
let call = self.state.renew_calls.fetch_add(1, Ordering::SeqCst);
if self.state.fail_renew.load(Ordering::SeqCst) {
return Err(Self::failure());
}
Ok(TokenLease::new(format!("scripted-renew-{call}"), self.lease()))
}
}
async fn scripted_provider(
ttl: Duration,
renewable: bool,
policy: VaultCredentialPolicy,
) -> (Arc<VaultCredentialProvider>, Arc<ScriptedState>) {
let state = Arc::new(ScriptedState::default());
let source = ScriptedSource {
state: state.clone(),
ttl,
renewable,
login_delay: Duration::ZERO,
};
let provider = VaultCredentialProvider::new(test_settings(), Box::new(source), policy)
.await
.expect("scripted provider must build without a live Vault");
(Arc::new(provider), state)
}
async fn static_provider() -> VaultCredentialProvider {
VaultCredentialProvider::new(
test_settings(),
Box::new(StaticToken::new(TEST_TOKEN.to_string())),
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await
.expect("static provider must build without a live Vault")
}
#[tokio::test]
async fn test_static_token_snapshots_pin_one_generation() {
let provider = static_provider().await;
let first = provider.current().expect("static tokens never expire");
let second = provider.current().expect("static tokens never expire");
assert_eq!(first.generation, 0);
assert!(
Arc::ptr_eq(&first, &second),
"without rotation every snapshot must return the same client generation"
);
}
#[tokio::test]
async fn test_static_token_spawns_no_renewal_task() {
let provider = Arc::new(static_provider().await);
assert!(provider.spawn_renewal_task().is_none(), "a token without a lease has nothing to renew");
}
#[tokio::test]
async fn test_static_token_source_yields_configured_token() {
let settings = test_settings();
let source = token_source_for(
&VaultAuthMethod::Token {
token: TEST_TOKEN.to_string(),
},
&settings,
)
.expect("token auth must map to a source");
let lease = source.acquire().await.expect("static acquire cannot fail");
assert_eq!(lease.expose(), TEST_TOKEN);
assert!(lease.lease_info().is_none(), "static tokens must not carry a lease");
}
#[tokio::test]
async fn test_approle_auth_method_maps_to_login_source() {
let settings = test_settings();
let source = token_source_for(&VaultAuthMethod::approle("role".to_string(), TEST_SECRET_ID.to_string()), &settings)
.expect("approle auth must map to a login source");
assert!(format!("{source:?}").contains("AppRoleLogin"));
}
#[tokio::test]
async fn test_kubernetes_auth_method_maps_to_login_source() {
let settings = test_settings();
let source = token_source_for(&VaultAuthMethod::kubernetes("rustfs".to_string()), &settings)
.expect("kubernetes auth must map to a login source");
assert!(format!("{source:?}").contains("KubernetesLogin"));
}
/// `refresh_safety_window_secs` is operator-supplied and reaches the request
/// path from persisted configuration, so the fail-closed comparison must
/// survive a window too large to add to the current instant. Before the
/// checked arithmetic this panicked with "overflow when adding duration to
/// instant" on the first request after a lease-bearing login.
#[tokio::test]
async fn test_current_refuses_rather_than_panics_on_an_unrepresentable_safety_window() {
let (provider, _state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(u64::MAX), Duration::from_secs(5)),
)
.await;
let error = provider
.current()
.expect_err("a window wider than any lease must refuse the token");
assert!(
matches!(error, KmsError::CredentialsUnavailable { .. }),
"expected CredentialsUnavailable, got {error:?}"
);
}
/// `lease_duration` is a bare u64 straight off the Vault response and forms
/// the other side of the same comparison, so an absurd one must not panic
/// either. It is indistinguishable from a non-expiring token, which is how
/// the zero-lease case already behaves.
#[tokio::test]
async fn test_an_unrepresentable_lease_is_treated_as_non_expiring() {
let (provider, _state) = scripted_provider(
Duration::from_secs(u64::MAX),
true,
test_policy(Duration::from_secs(30), Duration::from_secs(5)),
)
.await;
provider
.current()
.expect("a token whose expiry cannot be represented must stay usable");
}
/// The configured flag has to reach the HTTP client, not just the config
/// struct: every generation (authenticated and login) builds its own client,
/// and a Vault with a self-signed certificate fails the handshake unless
/// each one carries the setting.
#[test]
fn test_skip_tls_verify_reaches_every_vault_client_generation() {
for skip_tls_verify in [false, true] {
let settings = VaultConnectionSettings {
address: "https://vault.example.com:8200".to_string(),
namespace: None,
attempt_timeout: Duration::from_secs(30),
skip_tls_verify,
};
let authenticated = settings.build_client(TEST_TOKEN).expect("authenticated client must build");
assert_eq!(authenticated.settings.verify, !skip_tls_verify);
let login = settings.build_login_client().expect("login client must build");
assert_eq!(login.settings.verify, !skip_tls_verify);
}
}
/// vaultrs derives `verify` from its own VAULT_SKIP_VERIFY variable when the
/// builder leaves it unset, which would disable certificate verification
/// without passing the KMS insecure-defaults gate.
#[test]
fn test_vaultrs_skip_verify_env_cannot_override_the_configured_setting() {
temp_env::with_var("VAULT_SKIP_VERIFY", Some("true"), || {
let client = test_settings().build_client(TEST_TOKEN).expect("client must build");
assert!(
client.settings.verify,
"a stray VAULT_SKIP_VERIFY must not disable verification behind the KMS configuration"
);
});
}
/// The projected token is read fresh per login attempt and trimmed, so a
/// kubelet rotation is picked up without a restart and a trailing newline
/// does not corrupt the assertion sent to Vault.
#[tokio::test]
async fn test_kubernetes_login_rereads_and_trims_the_service_account_token() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("token");
tokio::fs::write(&path, " first-jwt\n").await.expect("write token");
let login = KubernetesLogin::new(
&test_settings(),
DEFAULT_VAULT_KUBERNETES_MOUNT.to_string(),
"rustfs".to_string(),
path.clone(),
)
.expect("login source must build");
assert_eq!(login.resolve_jwt().await.expect("first read").expose(), "first-jwt");
tokio::fs::write(&path, "rotated-jwt").await.expect("rotate token");
assert_eq!(
login.resolve_jwt().await.expect("second read").expose(),
"rotated-jwt",
"a rotated projected token must be picked up without a restart"
);
}
/// The ServiceAccount token is re-read per attempt, so an unreadable or
/// empty one fails that attempt without reaching Vault; the refresh loop
/// keeps retrying, which is what lets a late projection heal the source.
#[tokio::test]
async fn test_kubernetes_login_rejects_an_unusable_service_account_token() {
let dir = tempfile::tempdir().expect("temp dir");
let missing = dir.path().join("absent-token");
let empty = dir.path().join("empty-token");
tokio::fs::write(&empty, " \n").await.expect("write empty token");
for (path, expected) in [(missing, "Failed to read"), (empty, "is empty")] {
let login =
KubernetesLogin::new(&test_settings(), DEFAULT_VAULT_KUBERNETES_MOUNT.to_string(), "rustfs".to_string(), path)
.expect("login source must build");
let error = login
.acquire()
.await
.expect_err("an unusable ServiceAccount token must fail the attempt");
assert!(matches!(error.class, ErrorClass::Fatal));
assert!(error.error.to_string().contains(expected), "got {}", error.error);
}
}
#[tokio::test(start_paused = true)]
async fn test_renewal_task_renews_at_half_ttl() {
let (provider, state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await;
let task = provider.spawn_renewal_task().expect("lease-bound tokens need renewal");
tokio::time::sleep(Duration::from_secs(29)).await;
assert_eq!(state.renew_calls.load(Ordering::SeqCst), 0, "renewal must not run before half TTL");
assert_eq!(provider.snapshot().generation, 0);
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(state.renew_calls.load(Ordering::SeqCst), 1, "renewal must run at half TTL");
assert_eq!(state.login_calls.load(Ordering::SeqCst), 1, "renewable tokens must not re-login");
assert_eq!(provider.snapshot().generation, 1, "a successful renewal must install a new generation");
task.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn test_failed_renewal_falls_back_to_login() {
let (provider, state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await;
state.fail_renew.store(true, Ordering::SeqCst);
let task = provider.spawn_renewal_task().expect("renewal task");
tokio::time::sleep(Duration::from_secs(31)).await;
assert_eq!(state.renew_calls.load(Ordering::SeqCst), 1, "renewal must be attempted first");
assert_eq!(
state.login_calls.load(Ordering::SeqCst),
2,
"failed renewal must fall back to a fresh login"
);
assert_eq!(provider.snapshot().generation, 1);
task.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn test_current_fails_closed_inside_safety_window_and_recovers() {
let (provider, state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await;
state.fail_renew.store(true, Ordering::SeqCst);
state.fail_login.store(true, Ordering::SeqCst);
let task = provider.spawn_renewal_task().expect("renewal task");
// Refresh cycles at 30s, 35s, ... keep failing; the token stays usable
// until 50s (60s TTL minus the 10s safety window).
tokio::time::sleep(Duration::from_secs(49)).await;
provider
.current()
.expect("token outside the safety window must still be served");
tokio::time::sleep(Duration::from_secs(2)).await;
let error = provider
.current()
.expect_err("token inside the safety window must be refused");
assert!(
matches!(error, KmsError::CredentialsUnavailable { .. }),
"expected CredentialsUnavailable, got {error:?}"
);
// Recovery: after the failed-refresh circuit's cool-down, its single
// half-open probe succeeds, installs a fresh generation, and the
// provider serves requests again.
state.fail_renew.store(false, Ordering::SeqCst);
state.fail_login.store(false, Ordering::SeqCst);
tokio::time::sleep(Duration::from_secs(31)).await;
let handle = provider.current().expect("provider must recover after a successful refresh");
assert!(handle.generation >= 1);
task.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn test_shutdown_recycles_renewal_task_promptly() {
let (provider, _state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await;
let task = provider.spawn_renewal_task().expect("renewal task");
tokio::time::timeout(Duration::from_secs(1), task.shutdown())
.await
.expect("cancelled renewal task must exit promptly");
}
#[tokio::test(start_paused = true)]
async fn test_dropping_task_handle_cancels_renewal_task() {
let (provider, _state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await;
let task = provider.spawn_renewal_task().expect("renewal task");
let cancel_probe = task.cancel.clone();
drop(task);
tokio::time::timeout(Duration::from_secs(1), cancel_probe.cancelled())
.await
.expect("dropping the handle must cancel the renewal task");
}
#[tokio::test(start_paused = true)]
async fn test_concurrent_refreshes_coalesce_into_one_login() {
let state = Arc::new(ScriptedState::default());
let source = ScriptedSource {
state: state.clone(),
ttl: Duration::from_secs(60),
renewable: false,
login_delay: Duration::from_millis(100),
};
let provider = Arc::new(
VaultCredentialProvider::new(
test_settings(),
Box::new(source),
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await
.expect("provider"),
);
assert_eq!(state.login_calls.load(Ordering::SeqCst), 1, "initial login");
let cancel = CancellationToken::new();
let first = {
let provider = provider.clone();
let cancel = cancel.clone();
tokio::spawn(async move { provider.refresh(0, &cancel).await })
};
let second = {
let provider = provider.clone();
let cancel = cancel.clone();
tokio::spawn(async move { provider.refresh(0, &cancel).await })
};
first.await.expect("join").expect("refresh");
second.await.expect("join").expect("refresh");
assert_eq!(
state.login_calls.load(Ordering::SeqCst),
2,
"concurrent refreshes of the same generation must coalesce into one login"
);
assert_eq!(provider.snapshot().generation, 1);
}
#[tokio::test]
async fn test_approle_secret_id_file_missing_fails_fatally() {
let dir = tempfile::tempdir().expect("tempdir");
let source = AppRoleLogin::new(
&test_settings(),
"approle".to_string(),
"role".to_string(),
String::new(),
Some(dir.path().join("absent-secret-id")),
)
.expect("source");
let failure = source
.resolve_secret_id()
.await
.expect_err("missing secret_id file must fail the attempt");
assert_eq!(failure.class, ErrorClass::Fatal);
assert!(matches!(failure.error, KmsError::ConfigurationError { .. }));
}
#[tokio::test]
async fn test_approle_secret_id_file_empty_fails_fatally() {
let file = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(file.path(), " \n\t\n").expect("write whitespace");
let source = AppRoleLogin::new(
&test_settings(),
"approle".to_string(),
"role".to_string(),
String::new(),
Some(file.path().to_path_buf()),
)
.expect("source");
let failure = source
.resolve_secret_id()
.await
.expect_err("effectively empty secret_id file must fail the attempt");
assert_eq!(failure.class, ErrorClass::Fatal);
assert!(failure.error.to_string().contains("is empty"));
}
#[tokio::test]
async fn test_approle_secret_id_file_takes_precedence_and_is_trimmed() {
let file = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(file.path(), format!(" {TEST_SECRET_ID}\n")).expect("write secret");
let source = AppRoleLogin::new(
&test_settings(),
"approle".to_string(),
"role".to_string(),
"inline-secret-id-must-lose".to_string(),
Some(file.path().to_path_buf()),
)
.expect("source");
let secret_id = source.resolve_secret_id().await.expect("readable file must resolve");
assert_eq!(secret_id.expose(), TEST_SECRET_ID);
}
/// Leak regression: the Debug output of every credential-carrying type
/// must stay free of token and secret_id literals.
#[tokio::test]
async fn test_credential_types_debug_redacts_token() {
let provider = static_provider().await;
let handle = provider.current().expect("static token");
let lease = TokenLease::new(
TEST_TOKEN.to_string(),
Some(LeaseInfo {
ttl: Duration::from_secs(60),
renewable: true,
}),
);
let static_source = StaticToken::new(TEST_TOKEN.to_string());
let approle_source = AppRoleLogin::new(
&test_settings(),
"approle".to_string(),
"leak-test-role-id".to_string(),
TEST_SECRET_ID.to_string(),
None,
)
.expect("approle source");
for rendered in [
format!("{provider:?}"),
format!("{handle:?}"),
format!("{lease:?}"),
format!("{static_source:?}"),
format!("{approle_source:?}"),
] {
assert!(!rendered.contains(TEST_TOKEN), "debug output must not leak the vault token: {rendered}");
assert!(
!rendered.contains(TEST_SECRET_ID),
"debug output must not leak the approle secret_id: {rendered}"
);
}
assert!(format!("{lease:?}").contains(REDACTED_SECRET));
let approle_rendered = format!("{approle_source:?}");
assert!(approle_rendered.contains("leak-test-role-id"), "role_id is not a secret");
assert!(approle_rendered.contains(REDACTED_SECRET));
}
/// Write a token file with owner-only permissions, as a Vault Agent sink
/// would.
fn write_owner_only(path: &std::path::Path, contents: &str) {
std::fs::write(path, contents).expect("write token file");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).expect("chmod token file");
}
}
async fn token_file_provider(path: std::path::PathBuf, policy: VaultCredentialPolicy) -> Arc<VaultCredentialProvider> {
Arc::new(
VaultCredentialProvider::new(test_settings(), Box::new(TokenFileSource::new(path, Duration::from_secs(60))), policy)
.await
.expect("token file provider must build without a live Vault"),
)
}
#[tokio::test]
async fn test_token_file_source_reads_and_trims_token() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vault-token");
write_owner_only(&path, &format!(" {TEST_TOKEN}\n"));
let source = TokenFileSource::new(path, Duration::from_secs(60));
let lease = source.acquire().await.expect("readable token file must resolve");
assert_eq!(lease.expose(), TEST_TOKEN);
let lease_info = lease.lease_info().expect("token file leases carry an observed validity");
assert_eq!(lease_info.ttl, Duration::from_secs(60));
assert!(!lease_info.renewable, "agent-managed tokens are refreshed by re-reading, not renew-self");
}
#[tokio::test]
async fn test_token_file_missing_fails_fatally() {
let dir = tempfile::tempdir().expect("tempdir");
let source = TokenFileSource::new(dir.path().join("absent-token"), Duration::from_secs(60));
let failure = source.acquire().await.expect_err("missing token file must fail the attempt");
assert_eq!(failure.class, ErrorClass::Fatal);
assert!(matches!(failure.error, KmsError::ConfigurationError { .. }));
}
#[tokio::test]
async fn test_token_file_empty_fails_fatally() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vault-token");
write_owner_only(&path, " \n\t\n");
let source = TokenFileSource::new(path, Duration::from_secs(60));
let failure = source
.acquire()
.await
.expect_err("effectively empty token file must fail the attempt");
assert_eq!(failure.class, ErrorClass::Fatal);
assert!(failure.error.to_string().contains("is empty"));
}
#[cfg(unix)]
#[tokio::test]
async fn test_token_file_rejects_group_or_other_permission_bits() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vault-token");
write_owner_only(&path, TEST_TOKEN);
let source = TokenFileSource::new(path.clone(), Duration::from_secs(60));
for mode in [0o640u32, 0o604, 0o622, 0o660] {
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod");
let failure = source
.acquire()
.await
.expect_err("token file readable or writable beyond the owner must be rejected");
assert_eq!(failure.class, ErrorClass::Fatal, "mode {mode:#o}");
let rendered = failure.error.to_string();
assert!(rendered.contains("insecure permissions"), "mode {mode:#o}: {rendered}");
assert!(!rendered.contains(TEST_TOKEN), "permission errors must not echo the token");
}
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
source.acquire().await.expect("owner-only token file must resolve");
}
#[tokio::test(start_paused = true)]
async fn test_token_file_replacement_installs_new_generation_next_cycle() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vault-token");
write_owner_only(&path, "agent-token-v1");
let provider = token_file_provider(path.clone(), test_policy(Duration::from_secs(10), Duration::from_secs(5))).await;
let task = provider.spawn_renewal_task().expect("token file credentials are lease-bound");
tokio::time::sleep(Duration::from_secs(29)).await;
assert_eq!(provider.snapshot().generation, 0, "no re-read before half the observed validity");
// Atomic replacement, as a Vault Agent sink writes: temp file + rename.
let staged = dir.path().join("vault-token.tmp");
write_owner_only(&staged, "agent-token-v2");
std::fs::rename(&staged, &path).expect("atomic replace");
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(
provider.snapshot().generation,
1,
"the poll after a rotation must install a new generation"
);
// A poll without a rotation still installs a fresh generation: each
// successful read re-extends the token's observed validity.
tokio::time::sleep(Duration::from_secs(30)).await;
assert_eq!(provider.snapshot().generation, 2);
task.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn test_token_file_deletion_fails_closed_and_recovers() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vault-token");
write_owner_only(&path, "agent-token-v1");
let provider = token_file_provider(path.clone(), test_policy(Duration::from_secs(10), Duration::from_secs(5))).await;
let task = provider.spawn_renewal_task().expect("renewal task");
std::fs::remove_file(&path).expect("remove token file");
// Polls at 30s, 35s, ... keep failing; the last read keeps the token
// usable until 50s (60s observed validity minus the 10s safety window).
tokio::time::sleep(Duration::from_secs(49)).await;
provider
.current()
.expect("token outside the safety window must still be served");
tokio::time::sleep(Duration::from_secs(2)).await;
let error = provider
.current()
.expect_err("token inside the safety window must be refused");
assert!(
matches!(error, KmsError::CredentialsUnavailable { .. }),
"expected CredentialsUnavailable, got {error:?}"
);
// Restoring the file heals the provider on the next retry cycle.
write_owner_only(&path, "agent-token-v2");
tokio::time::sleep(Duration::from_secs(6)).await;
let handle = provider.current().expect("provider must recover once the token file is back");
assert!(handle.generation >= 1);
task.shutdown().await;
}
#[tokio::test]
async fn test_token_file_debug_redacts_token() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vault-token");
write_owner_only(&path, TEST_TOKEN);
let source = TokenFileSource::new(path.clone(), Duration::from_secs(60));
source.acquire().await.expect("token file must resolve");
let rendered = format!("{source:?}");
assert!(!rendered.contains(TEST_TOKEN), "debug output must not leak the vault token: {rendered}");
assert!(rendered.contains("vault-token"), "the file path is not a secret");
}
// -- Metric emission ----------------------------------------------------
//
// Each test installs a thread-local debugging recorder and drives a
// paused-clock current-thread runtime inside it, so the renewal loop's
// gauge publications land on exact virtual timestamps.
use metrics_util::MetricKind;
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
type MetricEntry = (
metrics_util::CompositeKey,
Option<metrics::Unit>,
Option<metrics::SharedString>,
DebugValue,
);
/// Run `test` on a paused current-thread runtime under a debugging
/// recorder and return one snapshot of everything it emitted.
///
/// A single snapshot per test on purpose: `Snapshotter::snapshot` drains
/// the recorded state, so taking it per assertion would only show the
/// first assertion any data.
fn record_metrics<Out>(
test: impl FnOnce() -> std::pin::Pin<Box<dyn std::future::Future<Output = Out>>>,
) -> (Vec<MetricEntry>, Out) {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let out = metrics::with_local_recorder(&recorder, || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.start_paused(true)
.build()
.expect("current-thread runtime must build");
runtime.block_on(test())
});
(snapshotter.snapshot().into_vec(), out)
}
/// Last value of a gauge, plus the labels it was published with.
fn gauge(snapshot: &[MetricEntry], name: &str) -> Option<(f64, Vec<String>)> {
snapshot.iter().find_map(|(composite, _unit, _description, value)| {
let matches = composite.kind() == MetricKind::Gauge && composite.key().name() == name;
match (matches, value) {
(true, DebugValue::Gauge(value)) => Some((
value.into_inner(),
composite.key().labels().map(|label| label.key().to_string()).collect(),
)),
_ => None,
}
})
}
#[test]
fn renewal_loop_republishes_token_ttl_while_it_waits() {
let (snapshot, ()) = record_metrics(|| {
Box::pin(async {
let (provider, _state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await;
let task = provider.spawn_renewal_task().expect("lease-bound tokens need renewal");
// Well inside the first half of the lease: nothing has been
// renewed yet, so only the observation cadence can have moved
// the gauge off its initial 60s.
tokio::time::sleep(Duration::from_secs(25)).await;
task.shutdown().await;
})
});
let (ttl, labels) = gauge(&snapshot, METRIC_TOKEN_TTL_SECONDS).expect("token TTL gauge must be published");
assert!(
(ttl - 40.0).abs() < 1.0,
"expected ~40s left of a 60s lease at the last observation, got {ttl}"
);
assert!(labels.is_empty(), "credential gauges must carry no labels, got {labels:?}");
assert_eq!(
gauge(&snapshot, METRIC_CREDENTIALS_FAIL_CLOSED).map(|(value, _)| value),
Some(0.0),
"a token outside its safety window must not report fail-closed"
);
}
#[test]
fn fail_closed_gauge_tracks_the_gate_the_request_path_applies() {
let (snapshot, refused) = record_metrics(|| {
Box::pin(async {
let (provider, state) = scripted_provider(
Duration::from_secs(60),
true,
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await;
state.fail_renew.store(true, Ordering::SeqCst);
state.fail_login.store(true, Ordering::SeqCst);
let task = provider.spawn_renewal_task().expect("renewal task");
// 60s lease minus the 10s safety window: by 51s the provider
// is refusing the token, and the gauge must already say so.
tokio::time::sleep(Duration::from_secs(51)).await;
let refused = provider.current().is_err();
task.shutdown().await;
refused
})
});
assert!(refused, "a token inside the safety window must be refused");
assert_eq!(
gauge(&snapshot, METRIC_CREDENTIALS_FAIL_CLOSED).map(|(value, _)| value),
Some(1.0),
"the gauge must report the same fail-closed state the request path enforces"
);
let (ttl, _labels) = gauge(&snapshot, METRIC_TOKEN_TTL_SECONDS).expect("token TTL gauge must be published");
assert!(
(ttl - 10.0).abs() < 1.0,
"expected the TTL gauge to track the lease down into its safety window, got {ttl}"
);
}
}